@joinremba/beacon 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joinremba/beacon",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Validate environment variables, config, secrets, and runtime feature gates before production breaks.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -68,6 +68,7 @@
68
68
  "bun": ">=1.3.1"
69
69
  },
70
70
  "dependencies": {
71
+ "@joinremba/core": "^0.4.0",
71
72
  "zod": "^4.4.2"
72
73
  },
73
74
  "devDependencies": {
package/src/cli-config.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { z } from "zod";
2
1
  import type { FeatureGate, SchemaEntry } from "./types";
2
+ import { typeToSchema, type SchemaField } from "./schema";
3
3
 
4
4
  export interface BeaconConfigFile {
5
5
  schema: Record<string, SchemaEntry>;
@@ -119,49 +119,7 @@ export function generateEnvExample(config: BeaconConfigFile, activeProfile?: str
119
119
  return lines.join("\n");
120
120
  }
121
121
 
122
- const typeToSchema = (entry: Record<string, unknown>): z.ZodType<unknown> => {
123
- const type = entry.type as string;
124
- let base: z.ZodType<unknown>;
125
-
126
- switch (type) {
127
- case "string":
128
- case "host":
129
- base = z.string();
130
- break;
131
- case "url":
132
- base = z.string().url();
133
- break;
134
- case "number":
135
- base = z.coerce.number();
136
- break;
137
- case "integer":
138
- base = z.coerce.number().int();
139
- break;
140
- case "boolean":
141
- base = z
142
- .string()
143
- .transform((v) => v === "true" || v === "1")
144
- .pipe(z.boolean());
145
- break;
146
- case "enum":
147
- base = z.enum((entry.values ?? []) as unknown as [string, ...string[]]);
148
- break;
149
- case "port":
150
- base = z.coerce.number().int().min(1).max(65535);
151
- break;
152
- case "email":
153
- base = z.string().email();
154
- break;
155
- default:
156
- base = z.string();
157
- }
158
-
159
- if (entry.default !== undefined) {
160
- base = base.default(entry.default as string | number | boolean);
161
- }
162
-
163
- return base;
164
- };
122
+ const SECRET_CENSOR = "[REDACTED]";
165
123
 
166
124
  export async function runCheck(
167
125
  config: BeaconConfigFile,
@@ -206,19 +164,22 @@ export async function runCheck(
206
164
  continue;
207
165
  }
208
166
 
167
+ const isSecret = isField ? (entry as { secret?: boolean }).secret : false;
168
+ const display = isSecret ? SECRET_CENSOR : raw;
169
+
209
170
  try {
210
171
  if (isField) {
211
- const schema = typeToSchema(entry as unknown as Record<string, unknown>);
172
+ const schema = typeToSchema(entry as SchemaField);
212
173
  schema.parse(raw);
213
174
  }
214
175
  results.push({
215
176
  key,
216
177
  status: "ok",
217
- message: `Set (${raw.length > 25 ? raw.substring(0, 25) + "..." : raw})`,
178
+ message: `Set (${display.length > 25 ? display.substring(0, 25) + "..." : display})`,
218
179
  });
219
180
  } catch {
220
- results.push({ key, status: "invalid", message: `Invalid: "${raw}"` });
221
- errors.push({ key, message: `Invalid value: ${raw}` });
181
+ results.push({ key, status: "invalid", message: `Invalid: "${display}"` });
182
+ errors.push({ key, message: `Invalid value: ${display}` });
222
183
  }
223
184
  }
224
185
 
package/src/cli.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { expect, test, describe } from "bun:test";
2
- import { generateEnvExample } from "./cli-config";
2
+ import { generateEnvExample, runCheck } from "./cli-config";
3
3
  import type { BeaconConfigFile } from "./cli-config";
4
4
 
5
5
  describe("generateEnvExample", () => {
@@ -51,3 +51,53 @@ describe("generateEnvExample", () => {
51
51
  expect(result).toContain("Secret: yes");
52
52
  });
53
53
  });
54
+
55
+ describe("runCheck", () => {
56
+ test("returns ok for present and valid env vars", async () => {
57
+ const prev = process.env.TEST_DB_URL;
58
+ process.env.TEST_DB_URL = "https://example.com/db";
59
+ try {
60
+ const config: BeaconConfigFile = {
61
+ schema: {
62
+ TEST_DB_URL: { type: "url", required: true },
63
+ },
64
+ };
65
+ const result = await runCheck(config);
66
+ expect(result.results).toHaveLength(1);
67
+ expect(result.results[0]?.status).toBe("ok");
68
+ } finally {
69
+ if (prev === undefined) delete process.env.TEST_DB_URL;
70
+ else process.env.TEST_DB_URL = prev;
71
+ }
72
+ });
73
+
74
+ test("returns missing for absent required var", async () => {
75
+ delete process.env.TEST_MISSING;
76
+ const config: BeaconConfigFile = {
77
+ schema: {
78
+ TEST_MISSING: { type: "string", required: true },
79
+ },
80
+ };
81
+ const result = await runCheck(config);
82
+ expect(result.results[0]?.status).toBe("missing");
83
+ });
84
+
85
+ test("redacts secret values on invalid", async () => {
86
+ const prev = process.env.TEST_SECRET;
87
+ process.env.TEST_SECRET = "my-secret-value-that-should-not-leak";
88
+ try {
89
+ const config: BeaconConfigFile = {
90
+ schema: {
91
+ TEST_SECRET: { type: "integer", secret: true },
92
+ },
93
+ };
94
+ const result = await runCheck(config);
95
+ expect(result.results[0]?.status).toBe("invalid");
96
+ expect(result.results[0]?.message).not.toContain("my-secret-value");
97
+ expect(result.errors[0]?.message).not.toContain("my-secret-value");
98
+ } finally {
99
+ if (prev === undefined) delete process.env.TEST_SECRET;
100
+ else process.env.TEST_SECRET = prev;
101
+ }
102
+ });
103
+ });
package/src/encryption.ts CHANGED
@@ -2,26 +2,26 @@ const ALGORITHM = "AES-GCM";
2
2
  const IV_LENGTH = 12;
3
3
  const TAG_LENGTH = 128;
4
4
 
5
- function keyToBuf(key: Uint8Array): ArrayBuffer {
6
- const buf = new ArrayBuffer(key.byteLength);
7
- new Uint8Array(buf).set(key);
8
- return buf;
9
- }
10
-
11
5
  async function deriveKey(password: string): Promise<CryptoKey> {
12
- const encoded = new TextEncoder().encode(password);
13
- const raw =
14
- encoded.length >= 32
15
- ? encoded.slice(0, 32)
16
- : (() => {
17
- const p = new Uint8Array(32);
18
- p.set(encoded);
19
- return p;
20
- })();
21
- return crypto.subtle.importKey("raw", keyToBuf(raw), { name: ALGORITHM }, false, [
22
- "encrypt",
23
- "decrypt",
24
- ]);
6
+ const keyMaterial = await crypto.subtle.importKey(
7
+ "raw",
8
+ new TextEncoder().encode(password),
9
+ "HKDF",
10
+ false,
11
+ ["deriveKey"]
12
+ );
13
+ return crypto.subtle.deriveKey(
14
+ {
15
+ name: "HKDF",
16
+ hash: "SHA-256",
17
+ salt: new Uint8Array(32),
18
+ info: new TextEncoder().encode("beacon-env-encryption-v1"),
19
+ },
20
+ keyMaterial,
21
+ { name: ALGORITHM, length: 256 },
22
+ false,
23
+ ["encrypt", "decrypt"]
24
+ );
25
25
  }
26
26
 
27
27
  export async function encryptEnv(envContent: string, key: string): Promise<string> {
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", () => {
@@ -262,3 +267,72 @@ test("isEnabled returns false when feature is killed", () => {
262
267
  );
263
268
  expect(config.isEnabled("newDashboard")).toBe(false);
264
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,6 +2,7 @@ import { z } from "zod";
2
2
  import type {
3
3
  Beacon as BeaconInterface,
4
4
  BeaconOptions,
5
+ EnsureOptions,
5
6
  FeatureGate,
6
7
  FieldDefinition,
7
8
  FieldDefinitionWithSchema,
@@ -9,6 +10,7 @@ import type {
9
10
  FieldType,
10
11
  } from "./types";
11
12
  import { ConfigError, ConfigValidationError } from "./errors";
13
+ import { typeToSchema } from "./schema";
12
14
 
13
15
  export type {
14
16
  BeaconOptions,
@@ -21,55 +23,6 @@ export type {
21
23
  export { ConfigError, ConfigValidationError };
22
24
  export type { BeaconInterface as Beacon };
23
25
 
24
- const typeToSchema = (entry: FieldDefinition): { schema: z.ZodType<unknown>; secret: boolean } => {
25
- const secret = entry.secret ?? false;
26
- const { type } = entry;
27
-
28
- let base: z.ZodType<unknown>;
29
-
30
- switch (type) {
31
- case "string":
32
- case "host":
33
- base = z.string();
34
- break;
35
- case "url":
36
- base = z.string().url();
37
- break;
38
- case "number":
39
- base = z.coerce.number();
40
- break;
41
- case "integer":
42
- base = z.coerce.number().int();
43
- break;
44
- case "boolean":
45
- base = z
46
- .string()
47
- .transform((v) => v === "true" || v === "1")
48
- .pipe(z.boolean());
49
- break;
50
- case "enum":
51
- if (!entry.values || entry.values.length === 0) {
52
- throw new Error(`Enum field must have values defined`);
53
- }
54
- base = z.enum(entry.values as [string, ...string[]]);
55
- break;
56
- case "port":
57
- base = z.coerce.number().int().min(1).max(65535);
58
- break;
59
- case "email":
60
- base = z.string().email();
61
- break;
62
- default:
63
- base = z.string();
64
- }
65
-
66
- if (entry.default !== undefined) {
67
- base = base.default(entry.default);
68
- }
69
-
70
- return { schema: base, secret };
71
- };
72
-
73
26
  const resolveEntry = (
74
27
  entry: SchemaEntry
75
28
  ): {
@@ -80,20 +33,21 @@ const resolveEntry = (
80
33
  description?: string;
81
34
  } => {
82
35
  if ("schema" in entry && entry.schema instanceof z.ZodType) {
36
+ const hasDefault = entry.schema.safeParse(undefined).success;
83
37
  return {
84
38
  schema: entry.schema,
85
39
  required: entry.required ?? true,
86
40
  secret: entry.secret ?? false,
87
- hasDefault: false,
41
+ hasDefault,
88
42
  description: entry.description,
89
43
  };
90
44
  }
91
45
  const field = entry as FieldDefinition;
92
- const { schema, secret } = typeToSchema(field);
46
+ const schema = typeToSchema(field);
93
47
  return {
94
48
  schema,
95
49
  required: field.required ?? true,
96
- secret,
50
+ secret: field.secret ?? false,
97
51
  hasDefault: field.default !== undefined,
98
52
  description: field.description,
99
53
  };
@@ -121,6 +75,7 @@ export function createBeacon(
121
75
  schema: Record<string, SchemaEntry>,
122
76
  options?: BeaconOptions
123
77
  ): BeaconInterface {
78
+ const client = options?.client;
124
79
  const mergedSchema: Record<string, SchemaEntry> = { ...schema };
125
80
 
126
81
  if (options?.profile && options?.profiles?.[options.profile]) {
@@ -139,33 +94,47 @@ export function createBeacon(
139
94
  const killSwitches: Record<string, boolean> = options?.killSwitches ?? {};
140
95
 
141
96
  const beacon: BeaconInterface = {
142
- ensure(): BeaconInterface {
97
+ async ensure(options?: EnsureOptions): Promise<BeaconInterface> {
98
+ const strict = options?.strict ?? true;
143
99
  const errors: ConfigError[] = [];
144
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
+
145
114
  for (const { key, schema, required, secret, hasDefault } of resolved) {
146
115
  const raw = process.env[key];
147
116
  const isMissing = raw === undefined || raw === "";
148
117
 
149
- if (isMissing && hasDefault) {
150
- try {
151
- const parsed = schema.parse(undefined);
152
- (validated ??= {})[key] = parsed;
153
- } catch (err) {
154
- if (err instanceof z.ZodError) {
155
- errors.push(
156
- new ConfigError(
157
- key,
158
- `Environment variable ${key}: ${err.issues[0]?.message ?? "Invalid value"}`,
159
- secret
160
- )
161
- );
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
+ }
162
133
  }
134
+ continue;
163
135
  }
164
- continue;
165
- }
166
-
167
- if (isMissing) {
168
136
  if (!required) continue;
137
+ if (!strict) continue;
169
138
  errors.push(
170
139
  new ConfigError(key, `Missing required environment variable: ${key}`, secret)
171
140
  );
@@ -179,6 +148,7 @@ export function createBeacon(
179
148
  if (err instanceof z.ZodError) {
180
149
  const issue = err.issues[0];
181
150
  const val = secret ? SECRET_CENSOR : raw;
151
+ if (!strict) continue;
182
152
  errors.push(
183
153
  new ConfigError(
184
154
  key,
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"
@@ -40,10 +41,17 @@ export interface BeaconOptions {
40
41
  profiles?: Record<string, Record<string, SchemaEntry>>;
41
42
  features?: Record<string, FeatureGate>;
42
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;
43
51
  }
44
52
 
45
53
  export interface Beacon {
46
- ensure(): Beacon;
54
+ ensure(options?: EnsureOptions): Promise<Beacon>;
47
55
  get<T = unknown>(key: string): T;
48
56
  readonly secret: Record<string, boolean>;
49
57
  isEnabled(feature: string): boolean;