@chidchanun/bcp 0.2.1 → 0.2.3

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.
@@ -0,0 +1,677 @@
1
+ export type BcpEnvironmentValueType =
2
+ | "string"
3
+ | "number"
4
+ | "boolean"
5
+ | "url";
6
+
7
+ export interface BcpEnvironmentVariableRule {
8
+ type: BcpEnvironmentValueType;
9
+ required?: boolean;
10
+ secret?: boolean;
11
+ minLength?: number;
12
+ maxLength?: number;
13
+ min?: number;
14
+ max?: number;
15
+ default?: string | number | boolean;
16
+ description?: string;
17
+ }
18
+
19
+ export type BcpEnvironmentSchema =
20
+ Record<
21
+ string,
22
+ BcpEnvironmentVariableRule
23
+ >;
24
+
25
+ export interface BcpEnvironmentValidationIssue {
26
+ key: string;
27
+ severity: "error" | "warning";
28
+ code:
29
+ | "missing"
30
+ | "invalid_type"
31
+ | "too_short"
32
+ | "too_long"
33
+ | "too_small"
34
+ | "too_large"
35
+ | "public_secret"
36
+ | "invalid_default";
37
+ message: string;
38
+ }
39
+
40
+ export interface BcpEnvironmentValidationResult {
41
+ ok: boolean;
42
+ checked: number;
43
+ present: number;
44
+ defaults: number;
45
+ issues: BcpEnvironmentValidationIssue[];
46
+ values: Record<
47
+ string,
48
+ string | number | boolean
49
+ >;
50
+ }
51
+
52
+ export function defineEnvironment<
53
+ const TSchema extends BcpEnvironmentSchema
54
+ >(
55
+ schema: TSchema
56
+ ): TSchema {
57
+ assertEnvironmentSchema(
58
+ schema
59
+ );
60
+
61
+ return schema;
62
+ }
63
+
64
+ export function applyEnvironmentDefaults(
65
+ schema: BcpEnvironmentSchema,
66
+ target:
67
+ NodeJS.ProcessEnv |
68
+ Record<string, string | undefined> =
69
+ process.env
70
+ ): number {
71
+ assertEnvironmentSchema(
72
+ schema
73
+ );
74
+
75
+ let applied = 0;
76
+
77
+ for (
78
+ const [
79
+ key,
80
+ rule,
81
+ ]
82
+ of Object.entries(
83
+ schema
84
+ )
85
+ ) {
86
+ if (
87
+ rule.default === undefined ||
88
+ (
89
+ target[key] !== undefined &&
90
+ target[key] !== ""
91
+ )
92
+ ) {
93
+ continue;
94
+ }
95
+
96
+ target[key] =
97
+ serializeEnvironmentValue(
98
+ rule.default
99
+ );
100
+ applied++;
101
+ }
102
+
103
+ return applied;
104
+ }
105
+
106
+ export function validateEnvironment(
107
+ schema: BcpEnvironmentSchema,
108
+ source:
109
+ NodeJS.ProcessEnv |
110
+ Record<string, string | undefined> =
111
+ process.env
112
+ ): BcpEnvironmentValidationResult {
113
+ assertEnvironmentSchema(
114
+ schema
115
+ );
116
+
117
+ const issues:
118
+ BcpEnvironmentValidationIssue[] = [];
119
+ const values:
120
+ Record<
121
+ string,
122
+ string | number | boolean
123
+ > = {};
124
+
125
+ let present = 0;
126
+ let defaults = 0;
127
+
128
+ for (
129
+ const [
130
+ key,
131
+ rule,
132
+ ]
133
+ of Object.entries(
134
+ schema
135
+ )
136
+ ) {
137
+ if (
138
+ rule.secret &&
139
+ key.startsWith(
140
+ "BCP_PUBLIC_"
141
+ )
142
+ ) {
143
+ issues.push({
144
+ key,
145
+ severity: "error",
146
+ code: "public_secret",
147
+ message:
148
+ `${key} is marked secret but uses the BCP_PUBLIC_ prefix. Public variables are embedded in client bundles.`,
149
+ });
150
+ }
151
+
152
+ const raw =
153
+ source[key];
154
+ const hasSourceValue =
155
+ raw !== undefined &&
156
+ raw !== "";
157
+
158
+ if (hasSourceValue) {
159
+ present++;
160
+ }
161
+
162
+ const input:
163
+ string | number | boolean | undefined =
164
+ hasSourceValue
165
+ ? raw
166
+ : rule.default;
167
+
168
+ if (
169
+ input === undefined
170
+ ) {
171
+ if (
172
+ rule.required
173
+ ) {
174
+ issues.push({
175
+ key,
176
+ severity: "error",
177
+ code: "missing",
178
+ message:
179
+ `${key} is required but was not provided.`,
180
+ });
181
+ }
182
+
183
+ continue;
184
+ }
185
+
186
+ if (
187
+ !hasSourceValue
188
+ ) {
189
+ defaults++;
190
+ }
191
+
192
+ const parsed =
193
+ parseEnvironmentRuleValue(
194
+ key,
195
+ input,
196
+ rule,
197
+ issues
198
+ );
199
+
200
+ if (
201
+ parsed !== undefined
202
+ ) {
203
+ values[key] =
204
+ parsed;
205
+ }
206
+ }
207
+
208
+ return {
209
+ ok:
210
+ !issues.some(
211
+ (issue) =>
212
+ issue.severity ===
213
+ "error"
214
+ ),
215
+ checked:
216
+ Object.keys(
217
+ schema
218
+ ).length,
219
+ present,
220
+ defaults,
221
+ issues,
222
+ values,
223
+ };
224
+ }
225
+
226
+ export function assertEnvironmentSchema(
227
+ schema: unknown
228
+ ): asserts schema is BcpEnvironmentSchema {
229
+ if (
230
+ !isPlainObject(
231
+ schema
232
+ )
233
+ ) {
234
+ throw new Error(
235
+ "BCP Framework: environment config must be an object."
236
+ );
237
+ }
238
+
239
+ for (
240
+ const [
241
+ key,
242
+ value,
243
+ ]
244
+ of Object.entries(
245
+ schema
246
+ )
247
+ ) {
248
+ if (
249
+ !/^[A-Za-z_][A-Za-z0-9_]*$/.test(
250
+ key
251
+ )
252
+ ) {
253
+ throw new Error(
254
+ `BCP Framework: invalid environment schema key "${key}".`
255
+ );
256
+ }
257
+
258
+ assertEnvironmentRule(
259
+ key,
260
+ value
261
+ );
262
+ }
263
+ }
264
+
265
+ function assertEnvironmentRule(
266
+ key: string,
267
+ value: unknown
268
+ ): asserts value is BcpEnvironmentVariableRule {
269
+ if (
270
+ !isPlainObject(
271
+ value
272
+ )
273
+ ) {
274
+ throw new Error(
275
+ `BCP Framework: environment.${key} must be an object.`
276
+ );
277
+ }
278
+
279
+ const allowed =
280
+ new Set([
281
+ "type",
282
+ "required",
283
+ "secret",
284
+ "minLength",
285
+ "maxLength",
286
+ "min",
287
+ "max",
288
+ "default",
289
+ "description",
290
+ ]);
291
+
292
+ for (
293
+ const property
294
+ of Object.keys(
295
+ value
296
+ )
297
+ ) {
298
+ if (
299
+ !allowed.has(
300
+ property
301
+ )
302
+ ) {
303
+ throw new Error(
304
+ `BCP Framework: unknown config option "environment.${key}.${property}".`
305
+ );
306
+ }
307
+ }
308
+
309
+ if (
310
+ value.type !== "string" &&
311
+ value.type !== "number" &&
312
+ value.type !== "boolean" &&
313
+ value.type !== "url"
314
+ ) {
315
+ throw new Error(
316
+ `BCP Framework: environment.${key}.type must be string, number, boolean, or url.`
317
+ );
318
+ }
319
+
320
+ assertOptionalBoolean(
321
+ value.required,
322
+ `environment.${key}.required`
323
+ );
324
+ assertOptionalBoolean(
325
+ value.secret,
326
+ `environment.${key}.secret`
327
+ );
328
+ assertOptionalNonNegativeInteger(
329
+ value.minLength,
330
+ `environment.${key}.minLength`
331
+ );
332
+ assertOptionalNonNegativeInteger(
333
+ value.maxLength,
334
+ `environment.${key}.maxLength`
335
+ );
336
+ assertOptionalFiniteNumber(
337
+ value.min,
338
+ `environment.${key}.min`
339
+ );
340
+ assertOptionalFiniteNumber(
341
+ value.max,
342
+ `environment.${key}.max`
343
+ );
344
+
345
+ if (
346
+ value.description !== undefined &&
347
+ (
348
+ typeof value.description !== "string" ||
349
+ value.description.trim() === ""
350
+ )
351
+ ) {
352
+ throw new Error(
353
+ `BCP Framework: environment.${key}.description must be a non-empty string.`
354
+ );
355
+ }
356
+
357
+ if (
358
+ value.minLength !== undefined &&
359
+ value.maxLength !== undefined &&
360
+ value.minLength > value.maxLength
361
+ ) {
362
+ throw new Error(
363
+ `BCP Framework: environment.${key}.minLength cannot be greater than maxLength.`
364
+ );
365
+ }
366
+
367
+ if (
368
+ value.min !== undefined &&
369
+ value.max !== undefined &&
370
+ value.min > value.max
371
+ ) {
372
+ throw new Error(
373
+ `BCP Framework: environment.${key}.min cannot be greater than max.`
374
+ );
375
+ }
376
+
377
+ if (
378
+ value.default !== undefined
379
+ ) {
380
+ const issues:
381
+ BcpEnvironmentValidationIssue[] = [];
382
+
383
+ const parsed =
384
+ parseEnvironmentRuleValue(
385
+ key,
386
+ value.default,
387
+ value as BcpEnvironmentVariableRule,
388
+ issues
389
+ );
390
+
391
+ if (
392
+ parsed === undefined ||
393
+ issues.some(
394
+ (issue) =>
395
+ issue.severity ===
396
+ "error"
397
+ )
398
+ ) {
399
+ throw new Error(
400
+ `BCP Framework: environment.${key}.default does not satisfy its declared rule.`
401
+ );
402
+ }
403
+ }
404
+ }
405
+
406
+ function parseEnvironmentRuleValue(
407
+ key: string,
408
+ input: string | number | boolean,
409
+ rule: BcpEnvironmentVariableRule,
410
+ issues: BcpEnvironmentValidationIssue[]
411
+ ): string | number | boolean | undefined {
412
+ if (
413
+ rule.type === "string" ||
414
+ rule.type === "url"
415
+ ) {
416
+ if (
417
+ typeof input !==
418
+ "string"
419
+ ) {
420
+ issues.push({
421
+ key,
422
+ severity: "error",
423
+ code: "invalid_type",
424
+ message:
425
+ `${key} must be a ${rule.type}.`,
426
+ });
427
+ return undefined;
428
+ }
429
+
430
+ if (
431
+ rule.minLength !== undefined &&
432
+ input.length <
433
+ rule.minLength
434
+ ) {
435
+ issues.push({
436
+ key,
437
+ severity: "error",
438
+ code: "too_short",
439
+ message:
440
+ `${key} must contain at least ${rule.minLength} character(s).`,
441
+ });
442
+ }
443
+
444
+ if (
445
+ rule.maxLength !== undefined &&
446
+ input.length >
447
+ rule.maxLength
448
+ ) {
449
+ issues.push({
450
+ key,
451
+ severity: "error",
452
+ code: "too_long",
453
+ message:
454
+ `${key} must contain at most ${rule.maxLength} character(s).`,
455
+ });
456
+ }
457
+
458
+ if (
459
+ rule.type === "url"
460
+ ) {
461
+ try {
462
+ const url =
463
+ new URL(
464
+ input
465
+ );
466
+
467
+ if (
468
+ url.protocol !== "http:" &&
469
+ url.protocol !== "https:"
470
+ ) {
471
+ throw new Error(
472
+ "unsupported protocol"
473
+ );
474
+ }
475
+ } catch {
476
+ issues.push({
477
+ key,
478
+ severity: "error",
479
+ code: "invalid_type",
480
+ message:
481
+ `${key} must be an absolute http(s) URL.`,
482
+ });
483
+ return undefined;
484
+ }
485
+ }
486
+
487
+ return input;
488
+ }
489
+
490
+ if (
491
+ rule.type === "number"
492
+ ) {
493
+ const parsed =
494
+ typeof input === "number"
495
+ ? input
496
+ : typeof input === "string" &&
497
+ input.trim() !== ""
498
+ ? Number(
499
+ input
500
+ )
501
+ : Number.NaN;
502
+
503
+ if (
504
+ !Number.isFinite(
505
+ parsed
506
+ )
507
+ ) {
508
+ issues.push({
509
+ key,
510
+ severity: "error",
511
+ code: "invalid_type",
512
+ message:
513
+ `${key} must be a finite number.`,
514
+ });
515
+ return undefined;
516
+ }
517
+
518
+ if (
519
+ rule.min !== undefined &&
520
+ parsed < rule.min
521
+ ) {
522
+ issues.push({
523
+ key,
524
+ severity: "error",
525
+ code: "too_small",
526
+ message:
527
+ `${key} must be greater than or equal to ${rule.min}.`,
528
+ });
529
+ }
530
+
531
+ if (
532
+ rule.max !== undefined &&
533
+ parsed > rule.max
534
+ ) {
535
+ issues.push({
536
+ key,
537
+ severity: "error",
538
+ code: "too_large",
539
+ message:
540
+ `${key} must be less than or equal to ${rule.max}.`,
541
+ });
542
+ }
543
+
544
+ return parsed;
545
+ }
546
+
547
+ if (
548
+ typeof input === "boolean"
549
+ ) {
550
+ return input;
551
+ }
552
+
553
+ if (
554
+ typeof input === "string"
555
+ ) {
556
+ const normalized =
557
+ input
558
+ .trim()
559
+ .toLowerCase();
560
+
561
+ if (
562
+ [
563
+ "1",
564
+ "true",
565
+ "yes",
566
+ "on",
567
+ ].includes(
568
+ normalized
569
+ )
570
+ ) {
571
+ return true;
572
+ }
573
+
574
+ if (
575
+ [
576
+ "0",
577
+ "false",
578
+ "no",
579
+ "off",
580
+ ].includes(
581
+ normalized
582
+ )
583
+ ) {
584
+ return false;
585
+ }
586
+ }
587
+
588
+ issues.push({
589
+ key,
590
+ severity: "error",
591
+ code: "invalid_type",
592
+ message:
593
+ `${key} must be a boolean value (true/false, 1/0, yes/no, on/off).`,
594
+ });
595
+
596
+ return undefined;
597
+ }
598
+
599
+ function serializeEnvironmentValue(
600
+ value: string | number | boolean
601
+ ): string {
602
+ if (
603
+ typeof value === "boolean"
604
+ ) {
605
+ return value
606
+ ? "true"
607
+ : "false";
608
+ }
609
+
610
+ return String(
611
+ value
612
+ );
613
+ }
614
+
615
+ function assertOptionalBoolean(
616
+ value: unknown,
617
+ label: string
618
+ ): void {
619
+ if (
620
+ value !== undefined &&
621
+ typeof value !== "boolean"
622
+ ) {
623
+ throw new Error(
624
+ `BCP Framework: ${label} must be boolean.`
625
+ );
626
+ }
627
+ }
628
+
629
+ function assertOptionalNonNegativeInteger(
630
+ value: unknown,
631
+ label: string
632
+ ): void {
633
+ if (
634
+ value !== undefined &&
635
+ (
636
+ !Number.isInteger(
637
+ value
638
+ ) ||
639
+ Number(value) < 0
640
+ )
641
+ ) {
642
+ throw new Error(
643
+ `BCP Framework: ${label} must be a non-negative integer.`
644
+ );
645
+ }
646
+ }
647
+
648
+ function assertOptionalFiniteNumber(
649
+ value: unknown,
650
+ label: string
651
+ ): void {
652
+ if (
653
+ value !== undefined &&
654
+ (
655
+ typeof value !== "number" ||
656
+ !Number.isFinite(
657
+ value
658
+ )
659
+ )
660
+ ) {
661
+ throw new Error(
662
+ `BCP Framework: ${label} must be a finite number.`
663
+ );
664
+ }
665
+ }
666
+
667
+ function isPlainObject(
668
+ value: unknown
669
+ ): value is Record<string, any> {
670
+ return (
671
+ typeof value === "object" &&
672
+ value !== null &&
673
+ !Array.isArray(
674
+ value
675
+ )
676
+ );
677
+ }