@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 +2 -1
- package/src/cli-config.ts +9 -48
- package/src/cli.test.ts +51 -1
- package/src/encryption.ts +19 -19
- package/src/index.test.ts +104 -30
- package/src/index.ts +40 -70
- package/src/schema.ts +56 -0
- package/src/types.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joinremba/beacon",
|
|
3
|
-
"version": "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
|
|
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
|
|
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 (${
|
|
178
|
+
message: `Set (${display.length > 25 ? display.substring(0, 25) + "..." : display})`,
|
|
218
179
|
});
|
|
219
180
|
} catch {
|
|
220
|
-
results.push({ key, status: "invalid", message: `Invalid: "${
|
|
221
|
-
errors.push({ key, message: `Invalid value: ${
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
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", () => {
|
|
@@ -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
|
|
41
|
+
hasDefault,
|
|
88
42
|
description: entry.description,
|
|
89
43
|
};
|
|
90
44
|
}
|
|
91
45
|
const field = entry as FieldDefinition;
|
|
92
|
-
const
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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;
|