@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/src/index.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { expect, test, beforeAll } from "bun: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
- beforeAll(() => {
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(() => config.ensure()).toThrow(ConfigValidationError);
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(() => config.ensure()).toThrow();
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(() => config.ensure()).toThrow();
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
- try {
163
- config.ensure();
164
- expect.unreachable();
165
- } catch (err) {
166
- expect(err).toBeInstanceOf(ConfigValidationError);
167
- expect((err as ConfigValidationError).errors).toHaveLength(2);
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 { BeaconOptions, FieldDefinition, FieldDefinitionWithSchema, SchemaEntry, FieldType };
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: false,
41
+ hasDefault,
80
42
  description: entry.description,
81
43
  };
82
44
  }
83
45
  const field = entry as FieldDefinition;
84
- const { schema, secret } = typeToSchema(field);
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 && hasDefault) {
124
- try {
125
- const parsed = schema.parse(undefined);
126
- (validated ??= {})[key] = parsed;
127
- } catch (err) {
128
- if (err instanceof z.ZodError) {
129
- errors.push(
130
- new ConfigError(
131
- key,
132
- `Environment variable ${key}: ${err.issues[0]?.message ?? "Invalid value"}`,
133
- secret
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
  }