@joinremba/beacon 0.5.0 → 0.5.2

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 CHANGED
@@ -6,6 +6,10 @@
6
6
  <strong>@joinremba/beacon</strong>
7
7
  </p>
8
8
 
9
+ <p align="center">
10
+ Validate environment variables, config, secrets, and runtime feature gates before production breaks.
11
+ </p>
12
+
9
13
  <p align="center">
10
14
  <a href="https://www.npmjs.com/package/@joinremba/beacon"><img src="https://img.shields.io/npm/v/@joinremba/beacon.svg" alt="npm version"></a>
11
15
  <a href="LICENSE"><img src="https://img.shields.io/npm/l/@joinremba/beacon.svg" alt="License"></a>
@@ -14,32 +18,19 @@
14
18
  <img src="https://img.shields.io/badge/TypeScript-6-blue" alt="TypeScript">
15
19
  </p>
16
20
 
17
- Beacon validates environment variables, config, secrets, and runtime feature gates before production breaks. Define your schema once — get type coercion, secret redaction, profile merging, per-command CLI tooling, and AES-256-GCM encryption for your `.env` files.
18
-
19
- ```sh
20
- bun add @joinremba/beacon
21
- bunx beacon init
22
- bunx beacon check
23
- ```
24
-
25
21
  ---
26
22
 
27
23
  ## Features
28
24
 
29
- - **Schema-based validation** with type coercion `string`, `url`, `number`, `integer`, `boolean`, `enum`, `port`, `host`, `email`
30
- - **Custom Zod schemas** for advanced validation rules
31
- - **Aggregated error reporting** — all errors collected at once, not fail-fast
32
- - **Secret redaction** — `[REDACTED]` in error messages and CLI output
33
- - **Default values** for optional variables
34
- - **Profile merging** — define different schemas per environment (dev, staging, production)
35
- - **Feature gates** with percentage-based rollout (deterministic djb2 hash)
36
- - **Kill switches** — `KILL_<NAME>` env var takes priority over feature gates
37
- - **Environment overrides** — `FEATURE_<NAME>` env var toggles features at runtime
38
- - **Remote config** via `client` option — connect to Nexus backend
39
- - **`strict: false` mode** for tests and bootstrap
40
- - **CLI commands** — `init`, `check`, `encrypt`, `decrypt`, `rotate`, `drift`, `docker`
41
- - **AES-256-GCM encryption** for `.env` files
42
- - **Zero runtime dependencies** beyond `zod` and `@joinremba/core`
25
+ - **Schema-based validation** Define env vars with simple string types (`"url"`, `"port"`, `"enum"`, etc.) or raw Zod schemas.
26
+ - **Aggregated errors** All missing/invalid variables are reported at once, not one at a time.
27
+ - **Secrets redaction** — Values marked `secret: true` are automatically replaced with `[REDACTED]` in errors and CLI output.
28
+ - **Profile overrides** — Define per-environment schemas (development, staging, production) with inheritance.
29
+ - **Feature gates & kill switches** Runtime toggles with env-var overrides and deterministic rollout hashing.
30
+ - **CLI tool** — `beacon init` generates `.env.example`, `beacon check` validates the runtime environment, `beacon drift` detects config drift.
31
+ - **Encrypted .env** AES-256-GCM encrypt/decrypt `.env` files for safe committing.
32
+ - **Remote config** — Fetches config from the Remba cloud via `@joinremba/core` client with local fallback.
33
+ - **TypeScript-first** — Strict types, generic getter, full type exports.
43
34
 
44
35
  ---
45
36
 
@@ -56,302 +47,220 @@ bun add @joinremba/beacon
56
47
  ```ts
57
48
  import { createBeacon } from "@joinremba/beacon";
58
49
 
59
- const beacon = createBeacon({
60
- PORT: { type: "port", default: 3000 },
61
- DATABASE_URL: { type: "url", secret: true },
62
- NODE_ENV: { type: "enum", values: ["dev", "prod"], default: "dev" },
63
- FEATURE_NEW_CHECKOUT: { type: "boolean", default: "false" },
50
+ const config = createBeacon({
51
+ DATABASE_URL: { type: "url", required: true },
52
+ REDIS_URL: { type: "url", required: true },
53
+ PORT: { type: "port", default: 3000 },
54
+ NODE_ENV: { type: "enum", values: ["development", "production"], default: "development" },
55
+ API_KEY: { type: "string", required: true, secret: true },
64
56
  });
65
57
 
66
- await beacon.ensure();
67
- const port = beacon.get<number>("PORT"); // 3000
68
- const dbUrl = beacon.get<string>("DATABASE_URL"); // process.env value
58
+ await config.ensure();
59
+
60
+ const dbUrl = config.get<string>("DATABASE_URL");
61
+ const port = config.get<number>("PORT");
69
62
  ```
70
63
 
71
- If any variable is missing or invalid, `ensure()` throws a `ConfigValidationError` with **all issues reported at once**so you fix everything in a single pass.
64
+ If any required variable is missing or invalid, `ensure()` throws a `ConfigValidationError` with **all** issues collected at once — fix everything in a single pass.
72
65
 
73
66
  ---
74
67
 
75
- ## Schema Field Types
68
+ ## Schema Types
76
69
 
77
- | Type | Description | Coercion / Validation |
78
- |-----------|--------------------------------------|----------------------------------------|
79
- | `string` | Any string value | `z.string()` |
80
- | `url` | Valid URL | `z.string().url()` |
81
- | `number` | Coerced to number | `z.coerce.number()` |
82
- | `integer` | Coerced to integer | `z.coerce.number().int()` |
83
- | `boolean` | `true` / `false` / `1` / `0` / `yes` / `no` | String transform + `z.boolean()` |
84
- | `enum` | Must match one of `values[]` | `z.enum(values)` |
85
- | `port` | Integer 1–65535 | `z.coerce.number().int().min(1).max(65535)` |
86
- | `host` | Hostname string | `z.string()` |
87
- | `email` | Valid email format | `z.string().email()` |
70
+ Each field in the schema can be defined with a `type` string. Beacon coerces and validates the raw `process.env` value accordingly.
88
71
 
89
- ### Schema Entry Options
72
+ | Type | Validation | Coercion | Example |
73
+ | --------- | ------------------------------------------------------- | ------------------------------------------- | -------------------------------------- |
74
+ | `string` | Any string | — | `{ type: "string" }` |
75
+ | `host` | Any string (alias for `string`) | — | `{ type: "host" }` |
76
+ | `url` | Valid URL | — | `{ type: "url" }` |
77
+ | `number` | Finite number | `z.coerce.number()` | `{ type: "number" }` |
78
+ | `integer` | Integer | `z.coerce.number().int()` | `{ type: "integer" }` |
79
+ | `port` | Integer in range 1–65535 | `z.coerce.number().int().min(1).max(65535)` | `{ type: "port", default: 3000 }` |
80
+ | `boolean` | `"true"` / `"false"` / `"1"` / `"0"` / `"yes"` / `"no"` | String transform | `{ type: "boolean" }` |
81
+ | `enum` | Must be one of `values[]` | `z.enum(values)` | `{ type: "enum", values: ["a", "b"] }` |
82
+ | `email` | Valid email | `z.string().email()` | `{ type: "email" }` |
90
83
 
91
84
  ```ts
92
- { type: "url", required: true, default: "https://localhost", secret: true, description: "DB connection string" }
85
+ const config = createBeacon({
86
+ APP_NAME: { type: "string", default: "my-app" },
87
+ DATABASE_URL: { type: "url", required: true },
88
+ WORKERS: { type: "integer", default: 4 },
89
+ PORT: { type: "port", default: 3000 },
90
+ SUPPORT_EMAIL: { type: "email", required: true },
91
+ LOG_LEVEL: { type: "enum", values: ["debug", "info", "warn", "error"], default: "info" },
92
+ ENABLE_METRICS: { type: "boolean", default: false },
93
+ });
93
94
  ```
94
95
 
95
- | Field | Type | Default | Description |
96
- |---------------|-------------|----------|------------------------------------------|
97
- | `type` | `FieldType` | — | The field type to validate against. |
98
- | `required` | `boolean` | `true` | Whether the variable must be set. |
99
- | `default` | `unknown` | — | Default value when not set in env. |
100
- | `secret` | `boolean` | `false` | Redact value from errors and CLI output. |
101
- | `values` | `string[]` | — | Allowed values (for `"enum"` type only). |
102
- | `description` | `string` | — | Used when generating `.env.example`. |
103
-
104
- ### Custom Zod Schemas
105
-
106
- For advanced validation, pass a Zod schema directly:
96
+ You can also pass raw **Zod schemas** directly for custom validation:
107
97
 
108
98
  ```ts
109
99
  import { z } from "zod";
110
100
 
111
- const beacon = createBeacon({
112
- PORT: { schema: z.coerce.number().positive().max(9999) },
101
+ const config = createBeacon({
113
102
  WHITELIST: { schema: z.string().regex(/^[\d,]+$/) },
103
+ TIMEOUT: { schema: z.coerce.number().positive().max(30000) },
114
104
  });
115
105
  ```
116
106
 
117
107
  ---
118
108
 
119
- ## Feature Gates
109
+ ## `.ensure()`
120
110
 
121
- Toggle features on and off without redeploying. Define gates in the `features` option and check them with `isEnabled()`.
111
+ Validates every variable in the schema against the current `process.env`. Must be called before any `get()` call.
122
112
 
123
113
  ```ts
124
- const beacon = createBeacon(
125
- { DATABASE_URL: { type: "url" } },
126
- {
127
- features: {
128
- newDashboard: { enabled: true },
129
- darkMode: { enabled: false },
130
- gradualRollout: { enabled: true, rollout: 0.5 },
131
- },
132
- killSwitches: {
133
- brokenFeature: true, // force-disabled
134
- },
135
- }
136
- );
137
-
138
- beacon.isEnabled("newDashboard"); // true
139
- beacon.isEnabled("darkMode"); // false
140
- beacon.isEnabled("gradualRollout"); // true for ~50% of instances (deterministic hash)
141
- beacon.isEnabled("brokenFeature"); // false — killed
142
- beacon.isKilled("brokenFeature"); // true
114
+ await config.ensure();
143
115
  ```
144
116
 
145
- ### `FeatureGate`
146
-
147
- | Option | Type | Default | Description |
148
- |---------------|-----------|----------|-----------------------------------------------------------|
149
- | `enabled` | `boolean` | `false` | Whether the gate is on by default. |
150
- | `rollout` | `number` | — | Fraction of instances (0–1) that see the feature. Uses deterministic djb2 hash. |
151
- | `description` | `string` | — | Human-readable description. |
117
+ **Behavior:**
152
118
 
153
- ### Environment Overrides
119
+ | Condition | strict: true (default) | strict: false |
120
+ | --------------------------------- | ------------------------------ | ---------------------------- |
121
+ | Required var present, valid | Passes | Passes |
122
+ | Required var missing | `ConfigValidationError` thrown | Silently skipped |
123
+ | Required var present, invalid | `ConfigValidationError` thrown | Silently skipped |
124
+ | Optional var not set, has default | Default applied | Default applied |
125
+ | Optional var not set, no default | Skipped | Skipped |
126
+ | Remote config available | Merged into validated values | Merged into validated values |
154
127
 
155
- Set `FEATURE_<NAME>` in the environment to override any feature gate at runtime. CamelCase names are converted to `SCREAMING_SNAKE_CASE` (`newDashboard` `FEATURE_NEW_DASHBOARD`). Accepted truthy values: `true`, `1`, `yes`.
128
+ Errors are aggregated into a single `ConfigValidationError` (extends `AggregateError`). Access individual issues via `err.errors`:
156
129
 
157
- ```sh
158
- FEATURE_DARK_MODE=true bun start
159
- FEATURE_NEW_DASHBOARD=false bun start
160
- ```
161
-
162
- ### Kill Switches
163
-
164
- Kill switches **always take priority** over feature gates. Set them via the `killSwitches` option or the `KILL_<NAME>` env var.
130
+ ```ts
131
+ import { ConfigValidationError } from "@joinremba/beacon";
165
132
 
166
- ```sh
167
- KILL_BROKEN_FEATURE=true bun start
133
+ try {
134
+ await config.ensure();
135
+ } catch (err) {
136
+ if (err instanceof ConfigValidationError) {
137
+ for (const issue of err.errors) {
138
+ console.error(`[${issue.key}] ${issue.message}`);
139
+ }
140
+ }
141
+ process.exit(1);
142
+ }
168
143
  ```
169
144
 
170
- ```ts
171
- beacon.isKilled("brokenFeature"); // true
172
- beacon.isEnabled("brokenFeature"); // false — killed overrides enabled
173
- ```
145
+ The `ensure({ strict: false })` mode is useful during testing or bootstrap when some env vars may not yet be configured.
174
146
 
175
147
  ---
176
148
 
177
- ## Remote Config
149
+ ## `.getAll()`
178
150
 
179
- Beacon can merge in remote configuration entries from a Nexus backend before local validation. Pass a `Client` from `@joinremba/core` via the `client` option.
151
+ Returns a `Record<string, unknown>` of **all** resolved values after `ensure()` completes.
180
152
 
181
153
  ```ts
182
- import { createBeacon } from "@joinremba/beacon";
183
- import { createClient } from "@joinremba/core";
184
-
185
- const beacon = createBeacon(
186
- {
187
- PORT: { type: "port", default: 3000 },
188
- },
189
- {
190
- client: createClient({ apiKey: "api_core_live_..." }),
191
- }
192
- );
193
-
194
- await beacon.ensure();
195
- // Remote entries fill gaps not defined in the schema.
196
- // Schema entries always take priority over remote values.
197
- // Network failures fall back silently to local-only validation.
154
+ await config.ensure();
155
+ const all = config.getAll();
156
+ // { DATABASE_URL: "postgres://...", PORT: 3000, NODE_ENV: "development", ... }
198
157
  ```
199
158
 
200
- ---
201
-
202
- ## CLI Commands
203
-
204
- Beacon ships with a CLI for development, CI, and ops workflows.
205
-
206
- | Command | Description |
207
- |--------------|--------------------------------------------------|
208
- | `init` | Generate `.env.example` from your config file. |
209
- | `check` | Validate current environment against schema. |
210
- | `encrypt` | Encrypt `.env` (AES-256-GCM) for safe committing.|
211
- | `decrypt` | Decrypt `.env.encrypted` back to plaintext. |
212
- | `rotate` | Print step-by-step secret rotation checklist. |
213
- | `drift` | Detect config drift — schema vs. actual env. |
214
- | `docker` | Validate env in Docker / Kubernetes contexts. |
215
-
216
- ### `beacon init`
217
-
218
- ```sh
219
- bunx beacon init # generate .env.example
220
- bunx beacon init --profile production # with profile merge
221
- bunx beacon init --all-profiles # generate for every profile
222
- bunx beacon init -c ./config/beacon.json -o .env.example.prod
223
- ```
159
+ Useful for debugging, logging startup configuration, or passing the full config object to subsystems.
224
160
 
225
- Output includes types, defaults, descriptions, and secret markers:
161
+ ---
226
162
 
227
- ```
228
- # PostgreSQL connection string
229
- # Type: url
230
- # Required: yes
231
- # DATABASE_URL=
232
-
233
- # HTTP server port
234
- # Type: port
235
- # Default: 3000
236
- PORT=3000
237
- ```
163
+ ## `.get<T>(key)`
238
164
 
239
- ### `beacon check`
165
+ Type-safe accessor for individual validated values. Call `.get<T>(key)` with an optional generic type parameter.
240
166
 
241
- ```sh
242
- bunx beacon check # validate env
243
- bunx beacon check --profile staging # with profile
244
- bunx beacon check -c ./config/production.json
167
+ ```ts
168
+ const port = config.get<number>("PORT"); // type: number
169
+ const dbUrl = config.get<string>("DATABASE_URL"); // type: string
170
+ const debug = config.get<boolean>("DEBUG"); // type: boolean
245
171
  ```
246
172
 
247
- Colour-coded table output:
173
+ Throws `ConfigError` if:
248
174
 
249
- ```
250
- KEY STATUS VALUE
251
- ──────────── ──────── ────────────────────
252
- DATABASE_URL pass postgres://localhost...
253
- PORT pass Using default value
254
- NODE_ENV pass Using default value
255
- API_KEY MISSING Not set
256
- LOG_LEVEL pass Optional, not set
257
- DB_HOST MISSING Not set
258
- Did you mean DB_HOSTNAME?
259
- ✓ 3 pass, 2 issue(s)
260
- ```
261
-
262
- Exit codes: `0` if all pass, `1` if any issues.
175
+ - Called before `ensure()` — `"Call beacon.ensure() before accessing config values"`
176
+ - Key doesn't exist in the schema — `"Unknown config key: <key>"`
263
177
 
264
- ### `beacon encrypt` / `beacon decrypt`
178
+ ---
265
179
 
266
- ```sh
267
- # Encrypt .env → .env.encrypted
268
- BEACON_ENCRYPTION_KEY=your-key beacon encrypt
180
+ ## Secrets
269
181
 
270
- # Decrypt back to plaintext
271
- BEACON_ENCRYPTION_KEY=your-key beacon decrypt
182
+ Mark sensitive fields with `secret: true` to prevent their values from appearing in error messages, CLI output, or logs.
272
183
 
273
- # Custom paths
274
- beacon encrypt -i .env.prod -o .env.prod.encrypted --key "your-key"
275
- beacon decrypt -i .env.prod.encrypted -o .env.prod --key "your-key"
184
+ ```ts
185
+ const config = createBeacon({
186
+ API_KEY: { type: "string", required: true, secret: true },
187
+ DATABASE_URL: { type: "url", secret: true },
188
+ });
276
189
  ```
277
190
 
278
- Uses **AES-256-GCM** with HKDF-SHA256 key derivation. The key can be passed via `--key` flag or `BEACON_ENCRYPTION_KEY` environment variable.
279
-
280
- ### `beacon rotate`
191
+ When a secret value fails validation, the error message shows `[REDACTED]` instead of the actual value:
281
192
 
282
- Prints a step-by-step checklist for safely rotating secrets (generate → deploy alongside → update consumers → verify → revoke → audit).
283
-
284
- ```sh
285
- bunx beacon rotate
286
193
  ```
287
-
288
- ### `beacon drift`
289
-
290
- Detects config drift — missing required variables, type mismatches, and unexpected enum values.
291
-
292
- ```sh
293
- bunx beacon drift
294
- bunx beacon drift --profile production
194
+ Environment variable API_KEY=[REDACTED]: Invalid value
295
195
  ```
296
196
 
297
- ### `beacon docker`
298
-
299
- Detects Docker and Kubernetes runtimes, checks common container env vars, and runs a full schema validation.
197
+ The secret metadata is also accessible at runtime:
300
198
 
301
- ```sh
302
- bunx beacon docker
303
- bunx beacon docker --profile staging
199
+ ```ts
200
+ config.secret; // { API_KEY: true, DATABASE_URL: true }
304
201
  ```
305
202
 
306
203
  ---
307
204
 
308
- ## Profiles
205
+ ## NODE_ENV Handling
309
206
 
310
- Define different schemas per environment using the `profiles` option. The active profile is selected via `profile`.
207
+ Beacon handles `NODE_ENV` like any other env var define it in your schema with an `enum` type and a default:
311
208
 
312
209
  ```ts
313
- const beacon = createBeacon(
314
- {
315
- DB_HOST: { type: "string", default: "localhost" },
316
- DB_PORT: { type: "port", default: 5432 },
210
+ const config = createBeacon({
211
+ NODE_ENV: {
212
+ type: "enum",
213
+ values: ["development", "test", "staging", "production"],
214
+ default: "development",
317
215
  },
318
- {
319
- profile: "production",
320
- profiles: {
321
- production: {
322
- DB_HOST: { type: "host", required: true },
323
- DB_PORT: { type: "port", required: true },
324
- },
325
- staging: {
326
- DB_HOST: { type: "host", required: true },
327
- },
328
- },
329
- }
330
- );
216
+ });
331
217
  ```
332
218
 
333
- Profile entries are **merged into the base schema** — they can add new variables, override types, change defaults, or toggle required flags.
219
+ **Defaults behavior:**
334
220
 
335
- You can also use profiles with the CLI:
221
+ - When an env var is not set and has a `default`, the default value is used silently.
222
+ - When an env var is not set, has no default, but `required: false`, it is silently skipped.
223
+ - When an env var is not set, has no default, and `required: true` (the default), it fails validation.
336
224
 
337
- ```sh
338
- bunx beacon init --profile production
339
- bunx beacon check --profile staging
340
- ```
225
+ This means you can safely deploy with minimal env vars during development as long as sensible defaults are provided.
341
226
 
342
- ### Config file (`.beaconrc.json`)
227
+ ---
228
+
229
+ ## `.beaconrc.json` (CLI Config File)
230
+
231
+ The CLI reads schema, profiles, and features from a JSON config file. It looks for `.beaconrc.json` or `beacon.config.json` in the project root.
343
232
 
344
233
  ```json
345
234
  {
346
235
  "schema": {
347
- "DATABASE_URL": { "type": "url", "required": true, "description": "PostgreSQL connection string" },
348
- "PORT": { "type": "port", "default": 3000, "description": "HTTP server port" },
349
- "NODE_ENV": { "type": "enum", "values": ["development", "production"], "default": "development" },
350
- "API_KEY": { "type": "string", "required": true, "secret": true }
236
+ "DATABASE_URL": {
237
+ "type": "url",
238
+ "required": true,
239
+ "description": "PostgreSQL connection string"
240
+ },
241
+ "PORT": {
242
+ "type": "port",
243
+ "default": 3000,
244
+ "description": "HTTP server port"
245
+ },
246
+ "NODE_ENV": {
247
+ "type": "enum",
248
+ "values": ["development", "staging", "production"],
249
+ "default": "development"
250
+ },
251
+ "API_KEY": {
252
+ "type": "string",
253
+ "required": true,
254
+ "secret": true,
255
+ "description": "API signing key"
256
+ }
351
257
  },
352
258
  "profiles": {
353
259
  "production": {
354
260
  "DB_HOST": { "type": "host", "required": true, "description": "Production DB hostname" }
261
+ },
262
+ "staging": {
263
+ "DB_HOST": { "type": "host", "required": true, "description": "Staging DB hostname" }
355
264
  }
356
265
  },
357
266
  "features": {
@@ -361,128 +270,176 @@ bunx beacon check --profile staging
361
270
  }
362
271
  ```
363
272
 
364
- The CLI auto-discovers `.beaconrc.json` or `beacon.config.json`.
273
+ **CLI commands:**
365
274
 
366
- ---
275
+ ```sh
276
+ # Generate .env.example from the config
277
+ bunx beacon init
278
+ bunx beacon init --profile production
279
+ bunx beacon init --all-profiles
367
280
 
368
- ## Error Handling
281
+ # Validate current environment against schema
282
+ bunx beacon check
283
+ bunx beacon check --profile staging
369
284
 
370
- All validation errors are **collected and thrown together** as a single `ConfigValidationError` (an `AggregateError` subclass). Each individual issue is a `ConfigError` with the offending key and a human-readable message.
285
+ # Detect config drift (missing vars, type mismatches)
286
+ bunx beacon drift
287
+ bunx beacon drift --profile production
371
288
 
372
- ```ts
373
- import { createBeacon, ConfigValidationError, ConfigError } from "@joinremba/beacon";
289
+ # Encrypt/decrypt .env files for safe committing
290
+ BEACON_ENCRYPTION_KEY=... beacon encrypt
291
+ BEACON_ENCRYPTION_KEY=... beacon decrypt
374
292
 
375
- try {
376
- await beacon.ensure();
377
- } catch (err) {
378
- if (err instanceof ConfigValidationError) {
379
- for (const issue of err.errors) {
380
- console.error(`[${issue.key}] ${issue.message}`);
381
- // issue.redacted — true if the value was hidden
382
- }
383
- }
384
- }
293
+ # Validate env in Docker/Kubernetes
294
+ bunx beacon docker
295
+
296
+ # Print secret rotation checklist
297
+ bunx beacon rotate
385
298
  ```
386
299
 
387
- ### `strict: false` mode
300
+ ---
388
301
 
389
- Pass `{ strict: false }` to `ensure()` to silently skip missing required variables without throwing. Useful in test environments or bootstrap scripts.
302
+ ## Configuration Reference
390
303
 
391
- ```ts
392
- await beacon.ensure({ strict: false });
393
- // Missing required vars are skipped instead of throwing
394
- ```
304
+ ### `createBeacon(schema, options?)`
305
+
306
+ | Option | Type | Default | Description |
307
+ | -------------- | --------------------------------------------- | ------- | --------------------------------------------------------- |
308
+ | `schema` | `Record<string, SchemaEntry>` | — | Map of env var names to field definitions (required). |
309
+ | `profile` | `string` | — | Active profile name. Merges matching entry from profiles. |
310
+ | `profiles` | `Record<string, Record<string, SchemaEntry>>` | — | Named profile overrides. Keyed by profile name. |
311
+ | `features` | `Record<string, FeatureGate>` | — | Feature gate definitions for `isEnabled()`. |
312
+ | `killSwitches` | `Record<string, boolean>` | — | Kill-switch flags. Overrides feature gates. |
313
+ | `client` | `Client` (from `@joinremba/core`) | — | Remote config client. Fetches config from cloud. |
314
+
315
+ ### `SchemaEntry`
316
+
317
+ **String-based field definition:**
318
+
319
+ | Field | Type | Default | Description |
320
+ | ------------- | ----------- | ------- | ----------------------------------------------------------------------------------------- |
321
+ | `type` | `FieldType` | — | One of: `string`, `url`, `number`, `integer`, `boolean`, `port`, `enum`, `host`, `email`. |
322
+ | `required` | `boolean` | `true` | Whether the variable must be present in env. |
323
+ | `default` | `unknown` | — | Default value when not set. |
324
+ | `secret` | `boolean` | `false` | Redact value from errors, logs, and CLI output. |
325
+ | `values` | `string[]` | — | Allowed values (required for `"enum"` type). |
326
+ | `description` | `string` | — | Human-readable description. Used in `.env.example`. |
327
+
328
+ **Zod-based field definition:**
329
+
330
+ | Field | Type | Default | Description |
331
+ | ------------- | ----------- | ------- | ----------------------------------------------- |
332
+ | `schema` | `z.ZodType` | — | A Zod schema for custom validation logic. |
333
+ | `required` | `boolean` | `true` | Whether the variable must be present in env. |
334
+ | `secret` | `boolean` | `false` | Redact value from errors, logs, and CLI output. |
335
+ | `description` | `string` | — | Human-readable description. |
336
+
337
+ ### Returned Beacon instance
338
+
339
+ | Method / Property | Returns | Description |
340
+ | -------------------- | ------------------------- | ------------------------------------------ |
341
+ | `ensure(options?)` | `Promise<Beacon>` | Validates all env vars. Throws on failure. |
342
+ | `getAll()` | `Record<string, unknown>` | Returns all resolved values. |
343
+ | `get<T>(key)` | `T` | Returns a single typed value. |
344
+ | `secret` | `Record<string, boolean>` | Which keys are marked as secrets. |
345
+ | `isEnabled(feature)` | `boolean` | Checks if a feature gate is enabled. |
346
+ | `isKilled(feature)` | `boolean` | Checks if a kill switch is active. |
347
+
348
+ ### `EnsureOptions`
349
+
350
+ | Option | Type | Default | Description |
351
+ | -------- | --------- | ------- | ------------------------------------------------ |
352
+ | `strict` | `boolean` | `true` | When `false`, missing required vars are skipped. |
395
353
 
396
- ### Secret Redaction
354
+ ### `FeatureGate`
397
355
 
398
- When a field is marked `{ secret: true }`, its value is replaced with `[REDACTED]` in all error messages and CLI output — never leaked into logs or terminal.
356
+ | Option | Type | Default | Description |
357
+ | ------------- | --------- | ------- | -------------------------------------------------- |
358
+ | `enabled` | `boolean` | `false` | Whether the feature is on by default. |
359
+ | `rollout` | `number` | — | Rollout percentage (0–1). Uses deterministic hash. |
360
+ | `description` | `string` | — | Human-readable description. |
399
361
 
400
362
  ---
401
363
 
402
- ## Encryption
403
-
404
- Beacon provides AES-256-GCM encryption for `.env` files via the CLI and programmatic API.
364
+ ## TypeScript
405
365
 
406
- ### Programmatic
366
+ Beacon ships with strict TypeScript types. All public APIs are fully typed.
407
367
 
408
368
  ```ts
409
- import { encryptEnv, decryptEnv } from "@joinremba/beacon";
410
-
411
- const encrypted = await encryptEnv("DATABASE_URL=postgres://...", "your-key");
412
- const decrypted = await decryptEnv(encrypted, "your-key");
369
+ import { createBeacon } from "@joinremba/beacon";
370
+ import type {
371
+ Beacon,
372
+ BeaconOptions,
373
+ SchemaEntry,
374
+ FieldDefinition,
375
+ FieldType,
376
+ FeatureGate,
377
+ EnsureOptions,
378
+ ConfigError,
379
+ ConfigValidationError,
380
+ } from "@joinremba/beacon";
413
381
  ```
414
382
 
415
- ### CLI
383
+ **Generic inference with `.get<T>()`:**
416
384
 
417
- ```sh
418
- beacon encrypt -i .env -o .env.encrypted --key "your-key"
419
- beacon decrypt -i .env.encrypted -o .env --key "your-key"
420
- ```
385
+ ```ts
386
+ const config = createBeacon({
387
+ PORT: { type: "port", default: 3000 },
388
+ DEBUG: { type: "boolean", default: false },
389
+ });
421
390
 
422
- The key can also be set via `BEACON_ENCRYPTION_KEY` env var. Key derivation uses HKDF-SHA256 with a 32-byte salt.
391
+ await config.ensure();
392
+
393
+ const port = config.get<number>("PORT"); // inferred as number
394
+ const debug = config.get<boolean>("DEBUG"); // inferred as boolean
395
+ ```
423
396
 
424
397
  ---
425
398
 
426
- ## API Reference
399
+ ## Integration with `@joinremba/core`
427
400
 
428
- ### `createBeacon(schema, options?)`
401
+ Beacon can fetch remote configuration from the Remba cloud by passing a `Client` instance from `@joinremba/core`.
429
402
 
430
- | Parameter | Type | Description |
431
- |-----------|-----------------------------------------|------------------------------------------|
432
- | `schema` | `Record<string, SchemaEntry>` | Map of env var names to field defs. |
433
- | `options` | `BeaconOptions` | Optional config (features, profiles, etc.) |
434
-
435
- #### `BeaconOptions`
403
+ ```ts
404
+ import { createBeacon } from "@joinremba/beacon";
405
+ import { createClient } from "@joinremba/core";
436
406
 
437
- | Option | Type | Description |
438
- |----------------|--------------------------------------------------|--------------------------------------------------|
439
- | `profile` | `string` | Active profile name. |
440
- | `profiles` | `Record<string, Record<string, SchemaEntry>>` | Named profile overrides. |
441
- | `features` | `Record<string, FeatureGate>` | Feature gate definitions. |
442
- | `killSwitches` | `Record<string, boolean>` | Force-disabled features. |
443
- | `client` | `Client` (from `@joinremba/core`) | Remote config client. |
407
+ const client = createClient({
408
+ apiKey: "api_core_live_abc123",
409
+ });
444
410
 
445
- #### `Beacon` (returned by `createBeacon`)
411
+ const config = createBeacon(
412
+ {
413
+ LOCAL_VAR: { type: "string", default: "local-fallback" },
414
+ PORT: { type: "port", default: 3000 },
415
+ },
416
+ { client }
417
+ );
446
418
 
447
- | Method / Property | Description |
448
- |--------------------------------|-------------------------------------------------------------------|
449
- | `ensure(options?)` | Validates all env vars. Throws `ConfigValidationError` on failure. Returns the beacon instance. |
450
- | `get<T>(key)` | Returns the validated, coerced value. Throws if called before `ensure()`. |
451
- | `secret` | `Record<string, boolean>` — which keys are marked as secrets. |
452
- | `isEnabled(feature)` | Checks if a feature gate is enabled. Respects kill switches, env overrides, and rollout. |
453
- | `isKilled(feature)` | Checks if a feature is force-disabled (kill switch or env). |
419
+ await config.ensure();
420
+ ```
454
421
 
455
- #### `EnsureOptions`
422
+ **How remote config merging works:**
456
423
 
457
- | Option | Type | Default | Description |
458
- |----------|-----------|---------|----------------------------------------------------------|
459
- | `strict` | `boolean` | `true` | When `false`, missing required vars are skipped silently. |
424
+ 1. On `ensure()`, beacon calls `client.getConfig()`.
425
+ 2. Remote entries whose keys are **not** in the local schema and **not** already set in `process.env` are added to the validated values.
426
+ 3. Remote entries whose keys **are** in the local schema are **ignored** — the schema definition always wins.
427
+ 4. If the network request fails (timeout, DNS error, etc.), beacon silently falls back to local-only mode.
460
428
 
461
- #### Types
429
+ This gives you a layered config priority:
462
430
 
463
- ```ts
464
- import type {
465
- Beacon,
466
- BeaconOptions,
467
- EnsureOptions,
468
- SchemaEntry,
469
- FieldDefinition,
470
- FieldDefinitionWithSchema,
471
- FieldType,
472
- FeatureGate,
473
- ConfigError,
474
- ConfigValidationError,
475
- } from "@joinremba/beacon";
476
431
  ```
432
+ process.env > schema defaults > remote config
433
+ ```
434
+
435
+ Use this to share non-sensitive config (feature flags, service URLs) across deployments without storing everything in every CI/CD pipeline.
436
+
437
+ ---
477
438
 
478
- #### Utilities
439
+ ## Related Packages
479
440
 
480
- | Export | Description |
481
- |----------------|--------------------------------------------------|
482
- | `ConfigError` | Individual config validation error (`key`, `message`, `redacted`). |
483
- | `ConfigValidationError` | `AggregateError` subclass containing all `ConfigError` instances. |
484
- | `encryptEnv` | `(content: string, key: string) => Promise<string>` — AES-256-GCM encrypt. |
485
- | `decryptEnv` | `(encrypted: string, key: string) => Promise<string>` — AES-256-GCM decrypt. |
441
+ - [@joinremba/catalog](https://github.com/joinremba/catalog) — Production-ready logging and error event layer on Pino.
442
+ - [@joinremba/gate](https://github.com/joinremba/gate) — API safety layer: validation, responses, idempotency, rate limiting, and API keys.
486
443
 
487
444
  ---
488
445
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joinremba/beacon",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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,10 +68,10 @@
68
68
  "bun": ">=1.3.1"
69
69
  },
70
70
  "dependencies": {
71
- "@joinremba/core": "^0.4.0",
72
71
  "zod": "^4.4.2"
73
72
  },
74
73
  "devDependencies": {
74
+ "@joinremba/core": "^0.4.0",
75
75
  "@types/bun": "latest",
76
76
  "@typescript-eslint/eslint-plugin": "^7.18.0",
77
77
  "@typescript-eslint/parser": "^7.18.0",
package/src/cli-config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { z } from "zod";
1
2
  import type { FeatureGate, SchemaEntry } from "./types";
2
3
  import { typeToSchema, type SchemaField } from "./schema";
3
4
 
@@ -147,9 +148,10 @@ export async function runCheck(
147
148
  const field = entry as { required?: boolean; default?: unknown };
148
149
  required = field.required !== false;
149
150
  hasDefault = field.default !== undefined;
150
- } else {
151
- const f = entry as { required?: boolean };
151
+ } else if ("schema" in entry) {
152
+ const f = entry as { required?: boolean; schema: z.ZodType<unknown> };
152
153
  required = f.required !== false;
154
+ hasDefault = f.schema.safeParse(undefined).success;
153
155
  }
154
156
 
155
157
  if (raw === undefined || raw === "") {
@@ -164,13 +166,16 @@ export async function runCheck(
164
166
  continue;
165
167
  }
166
168
 
167
- const isSecret = isField ? (entry as { secret?: boolean }).secret : false;
169
+ const isSecret = (entry as { secret?: boolean }).secret ?? false;
168
170
  const display = isSecret ? SECRET_CENSOR : raw;
169
171
 
170
172
  try {
171
173
  if (isField) {
172
174
  const schema = typeToSchema(entry as SchemaField);
173
175
  schema.parse(raw);
176
+ } else if ("schema" in entry) {
177
+ const f = entry as { schema: z.ZodType<unknown> };
178
+ f.schema.parse(raw);
174
179
  }
175
180
  results.push({
176
181
  key,
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { loadConfig, type BeaconConfigFile } from "./cli-config";
4
4
  import { generateEnvExample } from "./cli-config";
5
5
  import { runCheck } from "./cli-config";
6
+ import { typeToSchema, type SchemaField } from "./schema";
6
7
  import { encryptEnv, decryptEnv } from "./encryption";
7
8
  import { color, icon, formatCheckResult, formatSummary, suggestKeys } from "./cli-format";
8
9
 
@@ -170,9 +171,7 @@ async function handleInit(args: ParsedArgs) {
170
171
  : [args.profile];
171
172
 
172
173
  for (const profile of profilesToGenerate) {
173
- const output = profile
174
- ? args.output || `.env.example.${profile}`
175
- : args.output || ".env.example";
174
+ const output = profile ? `.env.example.${profile}` : args.output || ".env.example";
176
175
  const example = generateEnvExample(config, profile);
177
176
  await Bun.write(output, example);
178
177
  console.log(` ${icon.pass} Generated ${color.bold(output)}`);
@@ -206,6 +205,29 @@ async function handleCheck(args: ParsedArgs) {
206
205
 
207
206
  process.stdout.write(formatCheckResult(result.results));
208
207
 
208
+ if (config.features) {
209
+ const featureEntries = Object.entries(config.features);
210
+ if (featureEntries.length > 0) {
211
+ process.stdout.write(`\n ${color.bold("Feature Gates")}\n\n`);
212
+ for (const [name, gate] of featureEntries) {
213
+ const envName = `FEATURE_${name
214
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
215
+ .replace(/[^a-zA-Z0-9]/g, "_")
216
+ .toUpperCase()}`;
217
+ const envVal = process.env[envName];
218
+ const enabled =
219
+ envVal !== undefined && envVal !== ""
220
+ ? envVal === "true" || envVal === "1" || envVal === "yes"
221
+ : gate.enabled;
222
+ const icon_ = enabled ? icon.pass : icon.fail;
223
+ const status = enabled ? color.green("enabled") : color.red("disabled");
224
+ const desc = gate.description ? ` ${color.dim(`— ${gate.description}`)}` : "";
225
+ process.stdout.write(` ${icon_} ${color.bold(name)}: ${status}${desc}\n`);
226
+ }
227
+ process.stdout.write("\n");
228
+ }
229
+ }
230
+
209
231
  if (result.errors.length > 0) {
210
232
  for (const err of result.errors) {
211
233
  const suggestions = suggestKeys(
@@ -343,13 +365,24 @@ async function handleDrift(args: ParsedArgs) {
343
365
  }
344
366
 
345
367
  if (isField) {
346
- const field = entry as { type?: string; values?: readonly string[] };
347
- if (field.type === "enum" && field.values && !field.values.includes(raw)) {
348
- driftResults.push({
349
- key,
350
- expected: `one of: ${field.values.join(" | ")}`,
351
- actual: raw,
352
- });
368
+ const schema = typeToSchema(entry as SchemaField);
369
+ const result = schema.safeParse(raw);
370
+ if (!result.success) {
371
+ const field = entry as { type?: string; values?: readonly string[] };
372
+ if (field.type === "enum" && field.values) {
373
+ driftResults.push({
374
+ key,
375
+ expected: `one of: ${field.values.join(" | ")}`,
376
+ actual: raw,
377
+ });
378
+ } else {
379
+ const issue = result.error.issues[0];
380
+ driftResults.push({
381
+ key,
382
+ expected: issue?.message ?? "valid value",
383
+ actual: raw,
384
+ });
385
+ }
353
386
  }
354
387
  }
355
388
  }
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { typeToSchema } from "./schema";
14
14
 
15
15
  export type {
16
16
  BeaconOptions,
17
+ EnsureOptions,
17
18
  FeatureGate,
18
19
  FieldDefinition,
19
20
  FieldDefinitionWithSchema,
@@ -178,6 +179,13 @@ export function createBeacon(
178
179
  return validated[key] as T;
179
180
  },
180
181
 
182
+ getAll(): Record<string, unknown> {
183
+ if (validated === null) {
184
+ throw new ConfigError("beacon", "Call beacon.ensure() before accessing config values");
185
+ }
186
+ return { ...validated };
187
+ },
188
+
181
189
  get secret(): Record<string, boolean> {
182
190
  const map: Record<string, boolean> = {};
183
191
  for (const key of secretKeys) {
@@ -186,13 +194,6 @@ export function createBeacon(
186
194
  return map;
187
195
  },
188
196
 
189
- getAll(): Record<string, unknown> {
190
- if (validated === null) {
191
- throw new ConfigError("", "Call beacon.ensure() before accessing config values");
192
- }
193
- return { ...validated };
194
- },
195
-
196
197
  isKilled(feature: string): boolean {
197
198
  const envName = `KILL_${feature
198
199
  .replace(/([a-z])([A-Z])/g, "$1_$2")
package/src/schema.ts CHANGED
@@ -27,6 +27,9 @@ export function typeToSchema(field: SchemaField): z.ZodType<unknown> {
27
27
  case "boolean":
28
28
  base = z
29
29
  .string()
30
+ .refine((v) => ["true", "false", "1", "0", "yes", "no"].includes(v), {
31
+ message: "Expected one of: true, false, 1, 0, yes, no",
32
+ })
30
33
  .transform((v) => v === "true" || v === "1" || v === "yes")
31
34
  .pipe(z.boolean());
32
35
  break;
package/src/types.ts CHANGED
@@ -53,10 +53,8 @@ export interface EnsureOptions {
53
53
  export interface Beacon {
54
54
  ensure(options?: EnsureOptions): Promise<Beacon>;
55
55
  get<T = unknown>(key: string): T;
56
- /** Returns all validated entries as a flat key-value record.
57
- * Secrets are included — callers should redact as needed. */
58
56
  getAll(): Record<string, unknown>;
59
57
  readonly secret: Record<string, boolean>;
60
- isEnabled(feature: string): boolean;
61
58
  isKilled(feature: string): boolean;
59
+ isEnabled(feature: string): boolean;
62
60
  }