@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,138 @@
1
+ import {
2
+ APIError,
3
+ createdUpdatedPlugin,
4
+ findExactlyOne,
5
+ findOneOrNone,
6
+ isDeletedPlugin,
7
+ } from "@terreno/api";
8
+ import mongoose from "mongoose";
9
+ import type {FeatureFlagDocument, FeatureFlagModel} from "./types";
10
+
11
+ const featureFlagSchema = new mongoose.Schema<FeatureFlagDocument, FeatureFlagModel>(
12
+ {
13
+ archived: {
14
+ default: false,
15
+ description:
16
+ "Archived flags are excluded from evaluation. Use this instead of deleting flags to prevent bloat as new features are added.",
17
+ type: Boolean,
18
+ },
19
+ description: {
20
+ default: "",
21
+ description: "Explanation of what this flag controls",
22
+ type: String,
23
+ },
24
+ enabled: {
25
+ default: false,
26
+ description: "Global kill switch — if false, flag is off for everyone",
27
+ type: Boolean,
28
+ },
29
+ key: {
30
+ description: "Unique identifier for the flag, e.g., 'new-checkout-flow'",
31
+ required: true,
32
+ trim: true,
33
+ type: String,
34
+ unique: true,
35
+ },
36
+ name: {
37
+ description: "Human-readable display name",
38
+ required: true,
39
+ type: String,
40
+ },
41
+ rolloutPercentage: {
42
+ default: 100,
43
+ description: "For boolean flags with no matching rules: percentage of users who get true",
44
+ max: 100,
45
+ min: 0,
46
+ type: Number,
47
+ },
48
+ rules: [
49
+ {
50
+ enabled: {
51
+ description:
52
+ "Whether the flag/variant is turned on when this rule matches. For boolean flags this is the override value; for variant flags it gates whether the forced variant applies.",
53
+ type: Boolean,
54
+ },
55
+ field: {
56
+ description:
57
+ "User field to match against (use with operator + value). Supports dot notation, e.g., 'email', 'admin', 'address.zip'. Use field/operator/value together, OR segment alone.",
58
+ type: String,
59
+ },
60
+ operator: {
61
+ description:
62
+ "Comparison operator for field-based rules (use with field + value). Use field/operator/value together, OR segment alone.",
63
+ enum: ["eq", "neq", "in", "nin", "gt", "lt", "contains"],
64
+ type: String,
65
+ },
66
+ segment: {
67
+ description:
68
+ "Name of a registered segment function, e.g., 'pro-users'. Use segment alone, OR field/operator/value together.",
69
+ type: String,
70
+ },
71
+ value: {
72
+ description:
73
+ "Value to compare against (use with field + operator). String, number, boolean, or array for in/nin. Use field/operator/value together, OR segment alone.",
74
+ type: mongoose.Schema.Types.Mixed,
75
+ },
76
+ variant: {
77
+ description:
78
+ "For variant flags only: forced variant key when this rule matches. Use field/operator/value together, OR segment alone.",
79
+ type: String,
80
+ },
81
+ },
82
+ ],
83
+ type: {
84
+ default: "boolean",
85
+ description: "Boolean toggle or multi-variant A/B test",
86
+ enum: ["boolean", "variant"],
87
+ type: String,
88
+ },
89
+ variants: [
90
+ {
91
+ key: {
92
+ description: "Variant identifier, e.g., 'control', 'variant-a'",
93
+ required: true,
94
+ type: String,
95
+ },
96
+ weight: {
97
+ description: "Percentage weight for assignment (0-100, all must sum to 100)",
98
+ max: 100,
99
+ min: 0,
100
+ required: true,
101
+ type: Number,
102
+ },
103
+ },
104
+ ],
105
+ },
106
+ {strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
107
+ );
108
+
109
+ featureFlagSchema.plugin(createdUpdatedPlugin);
110
+ featureFlagSchema.plugin(isDeletedPlugin);
111
+ featureFlagSchema.plugin(findExactlyOne);
112
+ featureFlagSchema.plugin(findOneOrNone);
113
+
114
+ featureFlagSchema.index({archived: 1, enabled: 1});
115
+
116
+ featureFlagSchema.pre("save", function () {
117
+ if (this.type === "variant") {
118
+ if (!this.variants || this.variants.length === 0) {
119
+ throw new APIError({
120
+ status: 400,
121
+ title: "Variant flags must have at least one variant",
122
+ });
123
+ }
124
+ const totalWeight = this.variants.reduce((sum, v) => sum + v.weight, 0);
125
+ if (totalWeight !== 100) {
126
+ throw new APIError({
127
+ detail: `Total weight is ${totalWeight}, expected 100`,
128
+ status: 400,
129
+ title: "Variant weights must sum to 100",
130
+ });
131
+ }
132
+ }
133
+ });
134
+
135
+ export const FeatureFlag = mongoose.model<FeatureFlagDocument, FeatureFlagModel>(
136
+ "FeatureFlag",
137
+ featureFlagSchema
138
+ );
@@ -0,0 +1,95 @@
1
+ import {
2
+ APIError,
3
+ asyncHandler,
4
+ authenticateMiddleware,
5
+ type ModelRouterOptions,
6
+ modelRouter,
7
+ type OpenApiMiddleware,
8
+ Permissions,
9
+ type TerrenoPlugin,
10
+ } from "@terreno/api";
11
+ import type express from "express";
12
+ import type {Model} from "mongoose";
13
+ import {evaluateAllFlags} from "./evaluate";
14
+ import {FeatureFlag} from "./featureFlagModel";
15
+ import type {FeatureFlagDocument, FeatureFlagsOptions, SegmentFunction} from "./types";
16
+
17
+ /**
18
+ * TerrenoPlugin that provides feature flags and A/B testing.
19
+ *
20
+ * Creates admin CRUD endpoints for managing flags and a user-facing
21
+ * evaluation endpoint that returns all flag values for the current user.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * const segments = {
26
+ * "pro-users": (user) => user.plan === "pro",
27
+ * "beta-testers": (user) => user.betaTester === true,
28
+ * };
29
+ *
30
+ * new TerrenoApp({ userModel: User })
31
+ * .register(new FeatureFlagsApp({ segments }))
32
+ * .start();
33
+ * ```
34
+ */
35
+ export class FeatureFlagsApp implements TerrenoPlugin {
36
+ private options: FeatureFlagsOptions;
37
+ private segments: Record<string, SegmentFunction>;
38
+
39
+ constructor(options?: FeatureFlagsOptions) {
40
+ this.options = options ?? {};
41
+ this.segments = this.options.segments ?? {};
42
+ }
43
+
44
+ register(app: express.Application, openApi?: unknown): void {
45
+ const basePath = this.options.basePath ?? "/feature-flags";
46
+ const routerOptions: ModelRouterOptions<FeatureFlagDocument> = {
47
+ ...(openApi ? {openApi: openApi as OpenApiMiddleware} : {}),
48
+ permissions: {
49
+ create: [Permissions.IsAdmin],
50
+ delete: [Permissions.IsAdmin],
51
+ list: [Permissions.IsAdmin],
52
+ read: [Permissions.IsAdmin],
53
+ update: [Permissions.IsAdmin],
54
+ },
55
+ sort: "-created",
56
+ };
57
+
58
+ // Admin CRUD routes for flags
59
+ app.use(
60
+ `${basePath}/flags`,
61
+ modelRouter(FeatureFlag as Model<FeatureFlagDocument>, routerOptions)
62
+ );
63
+
64
+ // GET /feature-flags/evaluate — evaluate all flags for current user
65
+ app.get(
66
+ `${basePath}/evaluate`,
67
+ authenticateMiddleware(),
68
+ asyncHandler(async (req: express.Request, res: express.Response) => {
69
+ const user = req.user as {_id?: unknown; id?: string} | undefined;
70
+ if (!user) {
71
+ throw new APIError({status: 401, title: "Authentication required"});
72
+ }
73
+
74
+ const userId = String(user._id ?? user.id);
75
+ const results = await evaluateAllFlags(FeatureFlag, userId, user, this.segments);
76
+
77
+ return res.json({data: results});
78
+ })
79
+ );
80
+
81
+ // GET /feature-flags/segments — list registered segment names (admin only)
82
+ app.get(
83
+ `${basePath}/segments`,
84
+ authenticateMiddleware(),
85
+ asyncHandler(async (req: express.Request, res: express.Response) => {
86
+ const user = req.user as {admin?: boolean} | undefined;
87
+ if (!user?.admin) {
88
+ throw new APIError({status: 403, title: "Only admins can view segments"});
89
+ }
90
+
91
+ return res.json({data: Object.keys(this.segments)});
92
+ })
93
+ );
94
+ }
95
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export {deterministicHash, evaluateAllFlags, evaluateFlag} from "./evaluate";
2
+ export {FeatureFlag} from "./featureFlagModel";
3
+ export {FeatureFlagsApp} from "./featureFlagsApp";
4
+ export type {
5
+ EvaluationResult,
6
+ FeatureFlagDocument,
7
+ FeatureFlagModel,
8
+ FeatureFlagRule,
9
+ FeatureFlagsOptions,
10
+ FeatureFlagType,
11
+ FeatureFlagVariant,
12
+ SegmentFunction,
13
+ } from "./types";
@@ -0,0 +1,36 @@
1
+ import {mock} from "bun:test";
2
+
3
+ // Mock @sentry/bun module
4
+ mock.module("@sentry/bun", () => {
5
+ const mockFn = (): ReturnType<typeof mock> => mock(() => {});
6
+
7
+ return {
8
+ addBreadcrumb: mockFn(),
9
+ captureException: mockFn(),
10
+ captureMessage: mockFn(),
11
+ close: mock(() => Promise.resolve(true)),
12
+ default: {
13
+ captureException: mockFn(),
14
+ captureMessage: mockFn(),
15
+ Handlers: {
16
+ errorHandler: mock(
17
+ () => (err: unknown, _req: unknown, _res: unknown, next: (e?: unknown) => void) =>
18
+ next(err)
19
+ ),
20
+ requestHandler: mock(() => (_req: unknown, _res: unknown, next: () => void) => next()),
21
+ tracingHandler: mock(() => (_req: unknown, _res: unknown, next: () => void) => next()),
22
+ },
23
+ init: mockFn(),
24
+ isInitialized: mock(() => true),
25
+ setupExpressErrorHandler: mockFn(),
26
+ },
27
+ flush: mock(() => Promise.resolve(true)),
28
+ init: mockFn(),
29
+ isInitialized: mock(() => true),
30
+ setContext: mockFn(),
31
+ setTag: mockFn(),
32
+ setUser: mockFn(),
33
+ setupExpressErrorHandler: mockFn(),
34
+ withScope: mock((callback: (scope: unknown) => void) => callback({})),
35
+ };
36
+ });