@joinremba/beacon 0.1.0 → 0.4.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.
- package/README.md +121 -12
- package/package.json +2 -1
- package/src/cli-config.ts +15 -49
- package/src/cli.test.ts +51 -1
- package/src/cli.ts +407 -22
- package/src/encryption.test.ts +24 -0
- package/src/encryption.ts +59 -0
- package/src/index.test.ts +190 -30
- package/src/index.ts +95 -71
- package/src/schema.ts +56 -0
- package/src/types.ts +19 -1
package/src/index.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { expect, test,
|
|
1
|
+
import { expect, test, beforeEach, mock } from "bun:test";
|
|
2
2
|
import { createBeacon, ConfigValidationError } from "./index";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
|
|
@@ -6,11 +6,11 @@ const zodStringMin3 = z.string().min(3);
|
|
|
6
6
|
|
|
7
7
|
const ORIGINAL_ENV = { ...process.env };
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
beforeEach(() => {
|
|
10
10
|
process.env = { ...ORIGINAL_ENV };
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
-
test("returns typed config values after ensure()", () => {
|
|
13
|
+
test("returns typed config values after ensure()", async () => {
|
|
14
14
|
process.env.DATABASE_URL = "https://example.com/db";
|
|
15
15
|
process.env.REDIS_URL = "https://example.com/redis";
|
|
16
16
|
|
|
@@ -19,44 +19,44 @@ test("returns typed config values after ensure()", () => {
|
|
|
19
19
|
REDIS_URL: { type: "url", required: true },
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
config.ensure();
|
|
22
|
+
await config.ensure();
|
|
23
23
|
expect(config.get<string>("DATABASE_URL")).toBe("https://example.com/db");
|
|
24
24
|
expect(config.get<string>("REDIS_URL")).toBe("https://example.com/redis");
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
test("throws ValidationError for missing required vars", () => {
|
|
27
|
+
test("throws ValidationError for missing required vars", async () => {
|
|
28
28
|
delete process.env.MISSING_VAR;
|
|
29
29
|
|
|
30
30
|
const config = createBeacon({
|
|
31
31
|
MISSING_VAR: { type: "string", required: true },
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
expect(
|
|
34
|
+
await expect(config.ensure()).rejects.toThrow(ConfigValidationError);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
test("does not throw for optional vars with defaults", () => {
|
|
37
|
+
test("does not throw for optional vars with defaults", async () => {
|
|
38
38
|
delete process.env.MY_PORT;
|
|
39
39
|
|
|
40
40
|
const config = createBeacon({
|
|
41
41
|
MY_PORT: { type: "port", default: 3000 },
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
config.ensure();
|
|
44
|
+
await config.ensure();
|
|
45
45
|
expect(config.get<number>("MY_PORT")).toBe(3000);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
test("coerces number types", () => {
|
|
48
|
+
test("coerces number types", async () => {
|
|
49
49
|
process.env.MY_NUMBER = "42";
|
|
50
50
|
|
|
51
51
|
const config = createBeacon({
|
|
52
52
|
MY_NUMBER: { type: "number" },
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
config.ensure();
|
|
55
|
+
await config.ensure();
|
|
56
56
|
expect(config.get<number>("MY_NUMBER")).toBe(42);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
test("coerces boolean types", () => {
|
|
59
|
+
test("coerces boolean types", async () => {
|
|
60
60
|
process.env.FEATURE_X = "true";
|
|
61
61
|
process.env.FEATURE_Y = "false";
|
|
62
62
|
process.env.FEATURE_Z = "1";
|
|
@@ -67,13 +67,13 @@ test("coerces boolean types", () => {
|
|
|
67
67
|
FEATURE_Z: { type: "boolean" },
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
config.ensure();
|
|
70
|
+
await config.ensure();
|
|
71
71
|
expect(config.get<boolean>("FEATURE_X")).toBe(true);
|
|
72
72
|
expect(config.get<boolean>("FEATURE_Y")).toBe(false);
|
|
73
73
|
expect(config.get<boolean>("FEATURE_Z")).toBe(true);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
test("validates enum values", () => {
|
|
76
|
+
test("validates enum values", async () => {
|
|
77
77
|
process.env.NODE_ENV = "production";
|
|
78
78
|
|
|
79
79
|
const config = createBeacon({
|
|
@@ -83,11 +83,11 @@ test("validates enum values", () => {
|
|
|
83
83
|
},
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
config.ensure();
|
|
86
|
+
await config.ensure();
|
|
87
87
|
expect(config.get<string>("NODE_ENV")).toBe("production");
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
test("throws for invalid enum values", () => {
|
|
90
|
+
test("throws for invalid enum values", async () => {
|
|
91
91
|
process.env.NODE_ENV = "invalid";
|
|
92
92
|
|
|
93
93
|
const config = createBeacon({
|
|
@@ -97,17 +97,17 @@ test("throws for invalid enum values", () => {
|
|
|
97
97
|
},
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
expect(
|
|
100
|
+
await expect(config.ensure()).rejects.toThrow();
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
test("validates port range", () => {
|
|
103
|
+
test("validates port range", async () => {
|
|
104
104
|
process.env.PORT = "99999";
|
|
105
105
|
|
|
106
106
|
const config = createBeacon({
|
|
107
107
|
PORT: { type: "port" },
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
expect(
|
|
110
|
+
await expect(config.ensure()).rejects.toThrow();
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
test("throws when accessing before ensure()", () => {
|
|
@@ -118,7 +118,7 @@ test("throws when accessing before ensure()", () => {
|
|
|
118
118
|
expect(() => config.get("DB_URL")).toThrow("Call beacon.ensure()");
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
-
test("uses profile overrides when profile is set", () => {
|
|
121
|
+
test("uses profile overrides when profile is set", async () => {
|
|
122
122
|
process.env.DB_HOST = "prod.example.com";
|
|
123
123
|
|
|
124
124
|
const config = createBeacon(
|
|
@@ -135,22 +135,22 @@ test("uses profile overrides when profile is set", () => {
|
|
|
135
135
|
}
|
|
136
136
|
);
|
|
137
137
|
|
|
138
|
-
config.ensure();
|
|
138
|
+
await config.ensure();
|
|
139
139
|
expect(config.get<string>("DB_HOST")).toBe("prod.example.com");
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
-
test("accepts Zod schemas directly", () => {
|
|
142
|
+
test("accepts Zod schemas directly", async () => {
|
|
143
143
|
process.env.ZOD_VAR = "hello";
|
|
144
144
|
|
|
145
145
|
const config = createBeacon({
|
|
146
146
|
ZOD_VAR: { schema: zodStringMin3 },
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
-
config.ensure();
|
|
149
|
+
await config.ensure();
|
|
150
150
|
expect(config.get<string>("ZOD_VAR")).toBe("hello");
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
-
test("collects all errors before throwing", () => {
|
|
153
|
+
test("collects all errors before throwing", async () => {
|
|
154
154
|
delete process.env.VAR_A;
|
|
155
155
|
delete process.env.VAR_B;
|
|
156
156
|
|
|
@@ -159,13 +159,18 @@ test("collects all errors before throwing", () => {
|
|
|
159
159
|
VAR_B: { type: "number", required: true },
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
162
|
+
await expect(config.ensure()).rejects.toBeInstanceOf(ConfigValidationError);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("ensure({ strict: false }) skips missing required vars without throwing", async () => {
|
|
166
|
+
delete process.env.STRICT_VAR;
|
|
167
|
+
|
|
168
|
+
const config = createBeacon({
|
|
169
|
+
STRICT_VAR: { type: "string", required: true },
|
|
170
|
+
OPTIONAL_VAR: { type: "number", required: false },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await expect(config.ensure({ strict: false })).resolves.toBeDefined();
|
|
169
174
|
});
|
|
170
175
|
|
|
171
176
|
test("tracks secret keys", () => {
|
|
@@ -176,3 +181,158 @@ test("tracks secret keys", () => {
|
|
|
176
181
|
|
|
177
182
|
expect(config.secret).toEqual({ API_KEY: true });
|
|
178
183
|
});
|
|
184
|
+
|
|
185
|
+
test("isEnabled returns false for undefined feature", () => {
|
|
186
|
+
const config = createBeacon({ PORT: { type: "port", default: 3000 } });
|
|
187
|
+
expect(config.isEnabled("nonexistent")).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("isEnabled returns true for enabled feature", () => {
|
|
191
|
+
const config = createBeacon(
|
|
192
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
193
|
+
{ features: { newDashboard: { enabled: true } } }
|
|
194
|
+
);
|
|
195
|
+
expect(config.isEnabled("newDashboard")).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("isEnabled returns false for disabled feature", () => {
|
|
199
|
+
const config = createBeacon(
|
|
200
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
201
|
+
{ features: { darkMode: { enabled: false } } }
|
|
202
|
+
);
|
|
203
|
+
expect(config.isEnabled("darkMode")).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("isEnabled respects env override", () => {
|
|
207
|
+
const prev = process.env.FEATURE_NEW_DASHBOARD;
|
|
208
|
+
try {
|
|
209
|
+
process.env.FEATURE_NEW_DASHBOARD = "false";
|
|
210
|
+
const config = createBeacon(
|
|
211
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
212
|
+
{ features: { newDashboard: { enabled: true } } }
|
|
213
|
+
);
|
|
214
|
+
expect(config.isEnabled("newDashboard")).toBe(false);
|
|
215
|
+
} finally {
|
|
216
|
+
if (prev === undefined) {
|
|
217
|
+
delete process.env.FEATURE_NEW_DASHBOARD;
|
|
218
|
+
} else {
|
|
219
|
+
process.env.FEATURE_NEW_DASHBOARD = prev;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("isEnabled uses rollout when enabled is true", () => {
|
|
225
|
+
const config = createBeacon(
|
|
226
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
227
|
+
{ features: { gradual: { enabled: true, rollout: 1 } } }
|
|
228
|
+
);
|
|
229
|
+
expect(config.isEnabled("gradual")).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("isKilled returns false by default", () => {
|
|
233
|
+
const config = createBeacon({ PORT: { type: "port", default: 3000 } });
|
|
234
|
+
expect(config.isKilled("newDashboard")).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("isKilled returns true when set in options", () => {
|
|
238
|
+
const config = createBeacon(
|
|
239
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
240
|
+
{ killSwitches: { newDashboard: true } }
|
|
241
|
+
);
|
|
242
|
+
expect(config.isKilled("newDashboard")).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("isKilled respects KILL_ env override", () => {
|
|
246
|
+
const prev = process.env.KILL_NEW_DASHBOARD;
|
|
247
|
+
try {
|
|
248
|
+
process.env.KILL_NEW_DASHBOARD = "true";
|
|
249
|
+
const config = createBeacon(
|
|
250
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
251
|
+
{ killSwitches: { newDashboard: false } }
|
|
252
|
+
);
|
|
253
|
+
expect(config.isKilled("newDashboard")).toBe(true);
|
|
254
|
+
} finally {
|
|
255
|
+
if (prev === undefined) delete process.env.KILL_NEW_DASHBOARD;
|
|
256
|
+
else process.env.KILL_NEW_DASHBOARD = prev;
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("isEnabled returns false when feature is killed", () => {
|
|
261
|
+
const config = createBeacon(
|
|
262
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
263
|
+
{
|
|
264
|
+
features: { newDashboard: { enabled: true } },
|
|
265
|
+
killSwitches: { newDashboard: true },
|
|
266
|
+
}
|
|
267
|
+
);
|
|
268
|
+
expect(config.isEnabled("newDashboard")).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("fetches remote config when client is provided", async () => {
|
|
272
|
+
const config = createBeacon(
|
|
273
|
+
{ LOCAL_VAR: { type: "string", default: "local" } },
|
|
274
|
+
{
|
|
275
|
+
client: {
|
|
276
|
+
getConfig: mock(async () => [
|
|
277
|
+
{ key: "REMOTE_VAR", value: "from-cloud", secret: false, updatedAt: "" },
|
|
278
|
+
]),
|
|
279
|
+
} as any,
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
await config.ensure();
|
|
284
|
+
expect(config.get<string>("REMOTE_VAR")).toBe("from-cloud");
|
|
285
|
+
expect(config.get<string>("LOCAL_VAR")).toBe("local");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("schema entries take priority over remote config", async () => {
|
|
289
|
+
const config = createBeacon(
|
|
290
|
+
{ SHARED_KEY: { type: "string", default: "from-schema" } },
|
|
291
|
+
{
|
|
292
|
+
client: {
|
|
293
|
+
getConfig: mock(async () => [
|
|
294
|
+
{ key: "SHARED_KEY", value: "from-cloud", secret: false, updatedAt: "" },
|
|
295
|
+
]),
|
|
296
|
+
} as any,
|
|
297
|
+
}
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
await config.ensure();
|
|
301
|
+
expect(config.get<string>("SHARED_KEY")).toBe("from-schema");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("remote config fills gaps not in schema", async () => {
|
|
305
|
+
const config = createBeacon(
|
|
306
|
+
{ DEFINED_KEY: { type: "string", default: "schema-val" } },
|
|
307
|
+
{
|
|
308
|
+
client: {
|
|
309
|
+
getConfig: mock(async () => [
|
|
310
|
+
{ key: "DEFINED_KEY", value: "remote-val", secret: false, updatedAt: "" },
|
|
311
|
+
{ key: "REMOTE_ONLY", value: "remote-only", secret: false, updatedAt: "" },
|
|
312
|
+
]),
|
|
313
|
+
} as any,
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
await config.ensure();
|
|
318
|
+
expect(config.get<string>("DEFINED_KEY")).toBe("schema-val");
|
|
319
|
+
expect(config.get<string>("REMOTE_ONLY")).toBe("remote-only");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("network error on remote config silently falls back to local", async () => {
|
|
323
|
+
const { NetworkError } = await import("@joinremba/core");
|
|
324
|
+
|
|
325
|
+
const config = createBeacon(
|
|
326
|
+
{ LOCAL_VAR: { type: "string", default: "fallback" } },
|
|
327
|
+
{
|
|
328
|
+
client: {
|
|
329
|
+
getConfig: mock(async () => {
|
|
330
|
+
throw new NetworkError("connection lost");
|
|
331
|
+
}),
|
|
332
|
+
} as any,
|
|
333
|
+
}
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
await config.ensure();
|
|
337
|
+
expect(config.get<string>("LOCAL_VAR")).toBe("fallback");
|
|
338
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -2,66 +2,27 @@ import { z } from "zod";
|
|
|
2
2
|
import type {
|
|
3
3
|
Beacon as BeaconInterface,
|
|
4
4
|
BeaconOptions,
|
|
5
|
+
EnsureOptions,
|
|
6
|
+
FeatureGate,
|
|
5
7
|
FieldDefinition,
|
|
6
8
|
FieldDefinitionWithSchema,
|
|
7
9
|
SchemaEntry,
|
|
8
10
|
FieldType,
|
|
9
11
|
} from "./types";
|
|
10
12
|
import { ConfigError, ConfigValidationError } from "./errors";
|
|
13
|
+
import { typeToSchema } from "./schema";
|
|
11
14
|
|
|
12
|
-
export type {
|
|
15
|
+
export type {
|
|
16
|
+
BeaconOptions,
|
|
17
|
+
FeatureGate,
|
|
18
|
+
FieldDefinition,
|
|
19
|
+
FieldDefinitionWithSchema,
|
|
20
|
+
SchemaEntry,
|
|
21
|
+
FieldType,
|
|
22
|
+
};
|
|
13
23
|
export { ConfigError, ConfigValidationError };
|
|
14
24
|
export type { BeaconInterface as Beacon };
|
|
15
25
|
|
|
16
|
-
const typeToSchema = (entry: FieldDefinition): { schema: z.ZodType<unknown>; secret: boolean } => {
|
|
17
|
-
const secret = entry.secret ?? false;
|
|
18
|
-
const { type } = entry;
|
|
19
|
-
|
|
20
|
-
let base: z.ZodType<unknown>;
|
|
21
|
-
|
|
22
|
-
switch (type) {
|
|
23
|
-
case "string":
|
|
24
|
-
case "host":
|
|
25
|
-
base = z.string();
|
|
26
|
-
break;
|
|
27
|
-
case "url":
|
|
28
|
-
base = z.string().url();
|
|
29
|
-
break;
|
|
30
|
-
case "number":
|
|
31
|
-
base = z.coerce.number();
|
|
32
|
-
break;
|
|
33
|
-
case "integer":
|
|
34
|
-
base = z.coerce.number().int();
|
|
35
|
-
break;
|
|
36
|
-
case "boolean":
|
|
37
|
-
base = z
|
|
38
|
-
.string()
|
|
39
|
-
.transform((v) => v === "true" || v === "1")
|
|
40
|
-
.pipe(z.boolean());
|
|
41
|
-
break;
|
|
42
|
-
case "enum":
|
|
43
|
-
if (!entry.values || entry.values.length === 0) {
|
|
44
|
-
throw new Error(`Enum field must have values defined`);
|
|
45
|
-
}
|
|
46
|
-
base = z.enum(entry.values as [string, ...string[]]);
|
|
47
|
-
break;
|
|
48
|
-
case "port":
|
|
49
|
-
base = z.coerce.number().int().min(1).max(65535);
|
|
50
|
-
break;
|
|
51
|
-
case "email":
|
|
52
|
-
base = z.string().email();
|
|
53
|
-
break;
|
|
54
|
-
default:
|
|
55
|
-
base = z.string();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (entry.default !== undefined) {
|
|
59
|
-
base = base.default(entry.default);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return { schema: base, secret };
|
|
63
|
-
};
|
|
64
|
-
|
|
65
26
|
const resolveEntry = (
|
|
66
27
|
entry: SchemaEntry
|
|
67
28
|
): {
|
|
@@ -72,20 +33,21 @@ const resolveEntry = (
|
|
|
72
33
|
description?: string;
|
|
73
34
|
} => {
|
|
74
35
|
if ("schema" in entry && entry.schema instanceof z.ZodType) {
|
|
36
|
+
const hasDefault = entry.schema.safeParse(undefined).success;
|
|
75
37
|
return {
|
|
76
38
|
schema: entry.schema,
|
|
77
39
|
required: entry.required ?? true,
|
|
78
40
|
secret: entry.secret ?? false,
|
|
79
|
-
hasDefault
|
|
41
|
+
hasDefault,
|
|
80
42
|
description: entry.description,
|
|
81
43
|
};
|
|
82
44
|
}
|
|
83
45
|
const field = entry as FieldDefinition;
|
|
84
|
-
const
|
|
46
|
+
const schema = typeToSchema(field);
|
|
85
47
|
return {
|
|
86
48
|
schema,
|
|
87
49
|
required: field.required ?? true,
|
|
88
|
-
secret,
|
|
50
|
+
secret: field.secret ?? false,
|
|
89
51
|
hasDefault: field.default !== undefined,
|
|
90
52
|
description: field.description,
|
|
91
53
|
};
|
|
@@ -93,10 +55,27 @@ const resolveEntry = (
|
|
|
93
55
|
|
|
94
56
|
const SECRET_CENSOR = "[REDACTED]";
|
|
95
57
|
|
|
58
|
+
const featureEnvName = (name: string): string =>
|
|
59
|
+
`FEATURE_${name
|
|
60
|
+
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
61
|
+
.replace(/[^a-zA-Z0-9]/g, "_")
|
|
62
|
+
.toUpperCase()}`;
|
|
63
|
+
|
|
64
|
+
const parseEnvBoolean = (val: string): boolean => val === "true" || val === "1" || val === "yes";
|
|
65
|
+
|
|
66
|
+
const featureRollup = (name: string): number => {
|
|
67
|
+
let hash = 5381;
|
|
68
|
+
for (let i = 0; i < name.length; i++) {
|
|
69
|
+
hash = ((hash << 5) + hash + name.charCodeAt(i)) | 0;
|
|
70
|
+
}
|
|
71
|
+
return Math.abs(hash % 10000) / 10000;
|
|
72
|
+
};
|
|
73
|
+
|
|
96
74
|
export function createBeacon(
|
|
97
75
|
schema: Record<string, SchemaEntry>,
|
|
98
76
|
options?: BeaconOptions
|
|
99
77
|
): BeaconInterface {
|
|
78
|
+
const client = options?.client;
|
|
100
79
|
const mergedSchema: Record<string, SchemaEntry> = { ...schema };
|
|
101
80
|
|
|
102
81
|
if (options?.profile && options?.profiles?.[options.profile]) {
|
|
@@ -111,35 +90,51 @@ export function createBeacon(
|
|
|
111
90
|
const secretKeys = new Set(resolved.filter((e) => e.secret).map((e) => e.key));
|
|
112
91
|
|
|
113
92
|
let validated: Record<string, unknown> | null = null;
|
|
93
|
+
const features: Record<string, FeatureGate> = options?.features ?? {};
|
|
94
|
+
const killSwitches: Record<string, boolean> = options?.killSwitches ?? {};
|
|
114
95
|
|
|
115
96
|
const beacon: BeaconInterface = {
|
|
116
|
-
ensure(): BeaconInterface {
|
|
97
|
+
async ensure(options?: EnsureOptions): Promise<BeaconInterface> {
|
|
98
|
+
const strict = options?.strict ?? true;
|
|
117
99
|
const errors: ConfigError[] = [];
|
|
118
100
|
|
|
101
|
+
if (client) {
|
|
102
|
+
try {
|
|
103
|
+
const remote = await client.getConfig();
|
|
104
|
+
for (const entry of remote) {
|
|
105
|
+
if (process.env[entry.key] !== undefined) continue;
|
|
106
|
+
if (mergedSchema[entry.key]) continue;
|
|
107
|
+
(validated ??= {})[entry.key] = entry.value;
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Network error — fall back to local-only
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
119
114
|
for (const { key, schema, required, secret, hasDefault } of resolved) {
|
|
120
115
|
const raw = process.env[key];
|
|
121
116
|
const isMissing = raw === undefined || raw === "";
|
|
122
117
|
|
|
123
|
-
if (isMissing
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
118
|
+
if (isMissing) {
|
|
119
|
+
if (hasDefault) {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = schema.parse(undefined);
|
|
122
|
+
(validated ??= {})[key] = parsed;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err instanceof z.ZodError) {
|
|
125
|
+
errors.push(
|
|
126
|
+
new ConfigError(
|
|
127
|
+
key,
|
|
128
|
+
`Environment variable ${key}: ${err.issues[0]?.message ?? "Invalid value"}`,
|
|
129
|
+
secret
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
}
|
|
136
133
|
}
|
|
134
|
+
continue;
|
|
137
135
|
}
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (isMissing) {
|
|
142
136
|
if (!required) continue;
|
|
137
|
+
if (!strict) continue;
|
|
143
138
|
errors.push(
|
|
144
139
|
new ConfigError(key, `Missing required environment variable: ${key}`, secret)
|
|
145
140
|
);
|
|
@@ -153,6 +148,7 @@ export function createBeacon(
|
|
|
153
148
|
if (err instanceof z.ZodError) {
|
|
154
149
|
const issue = err.issues[0];
|
|
155
150
|
const val = secret ? SECRET_CENSOR : raw;
|
|
151
|
+
if (!strict) continue;
|
|
156
152
|
errors.push(
|
|
157
153
|
new ConfigError(
|
|
158
154
|
key,
|
|
@@ -189,6 +185,34 @@ export function createBeacon(
|
|
|
189
185
|
}
|
|
190
186
|
return map;
|
|
191
187
|
},
|
|
188
|
+
|
|
189
|
+
isKilled(feature: string): boolean {
|
|
190
|
+
const envName = `KILL_${feature
|
|
191
|
+
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
192
|
+
.replace(/[^a-zA-Z0-9]/g, "_")
|
|
193
|
+
.toUpperCase()}`;
|
|
194
|
+
const envVal = process.env[envName];
|
|
195
|
+
if (envVal !== undefined && envVal !== "") {
|
|
196
|
+
return parseEnvBoolean(envVal);
|
|
197
|
+
}
|
|
198
|
+
return killSwitches[feature] ?? false;
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
isEnabled(feature: string): boolean {
|
|
202
|
+
if (this.isKilled(feature)) return false;
|
|
203
|
+
const envName = featureEnvName(feature);
|
|
204
|
+
const envVal = process.env[envName];
|
|
205
|
+
if (envVal !== undefined && envVal !== "") {
|
|
206
|
+
return parseEnvBoolean(envVal);
|
|
207
|
+
}
|
|
208
|
+
const gate = features[feature];
|
|
209
|
+
if (!gate) return false;
|
|
210
|
+
if (!gate.enabled) return false;
|
|
211
|
+
if (gate.rollout !== undefined) {
|
|
212
|
+
return featureRollup(feature) < gate.rollout;
|
|
213
|
+
}
|
|
214
|
+
return true;
|
|
215
|
+
},
|
|
192
216
|
};
|
|
193
217
|
|
|
194
218
|
return beacon;
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export interface SchemaField {
|
|
4
|
+
type?: string;
|
|
5
|
+
default?: unknown;
|
|
6
|
+
values?: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function typeToSchema(field: SchemaField): z.ZodType<unknown> {
|
|
10
|
+
const type = field.type ?? "string";
|
|
11
|
+
let base: z.ZodType<unknown>;
|
|
12
|
+
|
|
13
|
+
switch (type) {
|
|
14
|
+
case "string":
|
|
15
|
+
case "host":
|
|
16
|
+
base = z.string();
|
|
17
|
+
break;
|
|
18
|
+
case "url":
|
|
19
|
+
base = z.string().url();
|
|
20
|
+
break;
|
|
21
|
+
case "number":
|
|
22
|
+
base = z.coerce.number();
|
|
23
|
+
break;
|
|
24
|
+
case "integer":
|
|
25
|
+
base = z.coerce.number().int();
|
|
26
|
+
break;
|
|
27
|
+
case "boolean":
|
|
28
|
+
base = z
|
|
29
|
+
.string()
|
|
30
|
+
.transform((v) => v === "true" || v === "1" || v === "yes")
|
|
31
|
+
.pipe(z.boolean());
|
|
32
|
+
break;
|
|
33
|
+
case "enum": {
|
|
34
|
+
const values = field.values;
|
|
35
|
+
if (!values || values.length === 0) {
|
|
36
|
+
throw new Error("Enum field must have values defined");
|
|
37
|
+
}
|
|
38
|
+
base = z.enum(values as [string, ...string[]]);
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
case "port":
|
|
42
|
+
base = z.coerce.number().int().min(1).max(65535);
|
|
43
|
+
break;
|
|
44
|
+
case "email":
|
|
45
|
+
base = z.string().email();
|
|
46
|
+
break;
|
|
47
|
+
default:
|
|
48
|
+
base = z.string();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (field.default !== undefined) {
|
|
52
|
+
base = base.default(field.default as string | number | boolean);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return base;
|
|
56
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
|
+
import type { Client } from "@joinremba/core";
|
|
2
3
|
|
|
3
4
|
export type FieldType =
|
|
4
5
|
| "string"
|
|
@@ -29,13 +30,30 @@ export interface FieldDefinitionWithSchema {
|
|
|
29
30
|
|
|
30
31
|
export type SchemaEntry = FieldDefinition | FieldDefinitionWithSchema;
|
|
31
32
|
|
|
33
|
+
export interface FeatureGate {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
rollout?: number;
|
|
36
|
+
description?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
export interface BeaconOptions {
|
|
33
40
|
profile?: string;
|
|
34
41
|
profiles?: Record<string, Record<string, SchemaEntry>>;
|
|
42
|
+
features?: Record<string, FeatureGate>;
|
|
43
|
+
killSwitches?: Record<string, boolean>;
|
|
44
|
+
client?: Client;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface EnsureOptions {
|
|
48
|
+
/** When true (default), throws ConfigValidationError for missing required vars.
|
|
49
|
+
* When false, silently skips missing required vars — useful in test environments. */
|
|
50
|
+
strict?: boolean;
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
export interface Beacon {
|
|
38
|
-
ensure(): Beacon
|
|
54
|
+
ensure(options?: EnsureOptions): Promise<Beacon>;
|
|
39
55
|
get<T = unknown>(key: string): T;
|
|
40
56
|
readonly secret: Record<string, boolean>;
|
|
57
|
+
isEnabled(feature: string): boolean;
|
|
58
|
+
isKilled(feature: string): boolean;
|
|
41
59
|
}
|