@chidchanun/bcp 0.2.16 → 0.2.18

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,1132 @@
1
+ // packages/config/src/index.ts
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ pathToFileURL
6
+ } from "node:url";
7
+ var CONFIG_FILES = [
8
+ "bcp.config.ts",
9
+ "bcp.config.mts",
10
+ "bcp.config.js",
11
+ "bcp.config.mjs"
12
+ ];
13
+ var defaultBcpConfig = {
14
+ server: {
15
+ port: 3e3,
16
+ hostname: "localhost",
17
+ bodyLimit: 1024 * 1024
18
+ },
19
+ compression: true,
20
+ build: {
21
+ minify: true,
22
+ sourceMaps: false
23
+ },
24
+ cache: {
25
+ response: true
26
+ },
27
+ experimental: {
28
+ partialHydration: true,
29
+ islands: true
30
+ },
31
+ security: {
32
+ poweredByHeader: false,
33
+ contentSecurityPolicy: false,
34
+ frameOptions: "SAMEORIGIN",
35
+ referrerPolicy: "strict-origin-when-cross-origin",
36
+ permissionsPolicy: "camera=(), microphone=(), geolocation=()"
37
+ }
38
+ };
39
+ function defineConfig(config) {
40
+ return config;
41
+ }
42
+ function getConfigFileNames() {
43
+ return [
44
+ ...CONFIG_FILES
45
+ ];
46
+ }
47
+ async function loadBcpConfig(rootDirectory) {
48
+ const matches = CONFIG_FILES.map(
49
+ (fileName) => path.join(
50
+ rootDirectory,
51
+ fileName
52
+ )
53
+ ).filter(
54
+ (filePath) => fs.existsSync(
55
+ filePath
56
+ )
57
+ );
58
+ if (matches.length > 1) {
59
+ throw new Error(
60
+ `BCP Framework: multiple config files found: ${matches.map((file2) => path.basename(file2)).join(", ")}. Keep only one bcp.config file.`
61
+ );
62
+ }
63
+ if (matches.length === 0) {
64
+ return {
65
+ file: null,
66
+ config: {}
67
+ };
68
+ }
69
+ const file = matches[0];
70
+ const url = pathToFileURL(
71
+ file
72
+ );
73
+ url.searchParams.set(
74
+ "bcp-config",
75
+ `${Date.now()}-${Math.random()}`
76
+ );
77
+ const module = await import(url.href);
78
+ const config = module.default;
79
+ if (config === void 0) {
80
+ throw new Error(
81
+ `BCP Framework: ${path.basename(file)} must export a default config object.`
82
+ );
83
+ }
84
+ assertConfig(
85
+ config,
86
+ path.basename(file)
87
+ );
88
+ return {
89
+ file,
90
+ config
91
+ };
92
+ }
93
+ async function resolveBcpConfig(rootDirectory, overrides = {}, environment = process.env) {
94
+ const loaded = await loadBcpConfig(
95
+ rootDirectory
96
+ );
97
+ const config = loaded.config;
98
+ const resolved = {
99
+ server: {
100
+ port: overrides.port ?? parseEnvironmentPort(
101
+ environment.BCP_PORT
102
+ ) ?? config.server?.port ?? defaultBcpConfig.server.port,
103
+ hostname: overrides.hostname ?? nonEmptyEnvironmentValue(
104
+ environment.BCP_HOSTNAME
105
+ ) ?? config.server?.hostname ?? defaultBcpConfig.server.hostname,
106
+ bodyLimit: parsePositiveInteger(
107
+ environment.BCP_BODY_LIMIT,
108
+ "BCP_BODY_LIMIT"
109
+ ) ?? config.server?.bodyLimit ?? defaultBcpConfig.server.bodyLimit
110
+ },
111
+ compression: parseEnvironmentBoolean(
112
+ environment.BCP_COMPRESSION,
113
+ "BCP_COMPRESSION"
114
+ ) ?? config.compression ?? defaultBcpConfig.compression,
115
+ build: {
116
+ minify: parseEnvironmentBoolean(
117
+ environment.BCP_BUILD_MINIFY,
118
+ "BCP_BUILD_MINIFY"
119
+ ) ?? config.build?.minify ?? defaultBcpConfig.build.minify,
120
+ sourceMaps: parseEnvironmentBoolean(
121
+ environment.BCP_BUILD_SOURCE_MAPS,
122
+ "BCP_BUILD_SOURCE_MAPS"
123
+ ) ?? config.build?.sourceMaps ?? defaultBcpConfig.build.sourceMaps
124
+ },
125
+ cache: {
126
+ response: parseEnvironmentBoolean(
127
+ environment.BCP_RESPONSE_CACHE,
128
+ "BCP_RESPONSE_CACHE"
129
+ ) ?? config.cache?.response ?? defaultBcpConfig.cache.response
130
+ },
131
+ experimental: {
132
+ partialHydration: parseEnvironmentBoolean(
133
+ environment.BCP_EXPERIMENTAL_PARTIAL_HYDRATION,
134
+ "BCP_EXPERIMENTAL_PARTIAL_HYDRATION"
135
+ ) ?? config.experimental?.partialHydration ?? defaultBcpConfig.experimental.partialHydration,
136
+ islands: parseEnvironmentBoolean(
137
+ environment.BCP_EXPERIMENTAL_ISLANDS,
138
+ "BCP_EXPERIMENTAL_ISLANDS"
139
+ ) ?? config.experimental?.islands ?? defaultBcpConfig.experimental.islands
140
+ },
141
+ security: {
142
+ poweredByHeader: parseEnvironmentBoolean(
143
+ environment.BCP_POWERED_BY_HEADER,
144
+ "BCP_POWERED_BY_HEADER"
145
+ ) ?? config.security?.poweredByHeader ?? defaultBcpConfig.security.poweredByHeader,
146
+ contentSecurityPolicy: parseOptionalHeaderEnvironment(
147
+ environment.BCP_SECURITY_CSP,
148
+ "BCP_SECURITY_CSP"
149
+ ) ?? config.security?.contentSecurityPolicy ?? defaultBcpConfig.security.contentSecurityPolicy,
150
+ frameOptions: parseFrameOptionsEnvironment(
151
+ environment.BCP_SECURITY_FRAME_OPTIONS
152
+ ) ?? config.security?.frameOptions ?? defaultBcpConfig.security.frameOptions,
153
+ referrerPolicy: nonEmptyEnvironmentValue(
154
+ environment.BCP_SECURITY_REFERRER_POLICY
155
+ ) ?? config.security?.referrerPolicy ?? defaultBcpConfig.security.referrerPolicy,
156
+ permissionsPolicy: parseOptionalHeaderEnvironment(
157
+ environment.BCP_SECURITY_PERMISSIONS_POLICY,
158
+ "BCP_SECURITY_PERMISSIONS_POLICY"
159
+ ) ?? config.security?.permissionsPolicy ?? defaultBcpConfig.security.permissionsPolicy
160
+ }
161
+ };
162
+ validateResolvedConfig(
163
+ resolved
164
+ );
165
+ return {
166
+ file: loaded.file,
167
+ config: resolved
168
+ };
169
+ }
170
+ function applyResolvedBcpConfig(config) {
171
+ process.env.BCP_RESOLVED_CONFIG = JSON.stringify(
172
+ config
173
+ );
174
+ process.env.BCP_COMPRESSION = config.compression ? "1" : "0";
175
+ process.env.BCP_BODY_LIMIT = String(
176
+ config.server.bodyLimit
177
+ );
178
+ process.env.BCP_BUILD_MINIFY = config.build.minify ? "1" : "0";
179
+ process.env.BCP_BUILD_SOURCE_MAPS = config.build.sourceMaps ? "1" : "0";
180
+ process.env.BCP_RESPONSE_CACHE = config.cache.response ? "1" : "0";
181
+ process.env.BCP_EXPERIMENTAL_PARTIAL_HYDRATION = config.experimental.partialHydration ? "1" : "0";
182
+ process.env.BCP_EXPERIMENTAL_ISLANDS = config.experimental.islands ? "1" : "0";
183
+ process.env.BCP_POWERED_BY_HEADER = config.security.poweredByHeader ? "1" : "0";
184
+ process.env.BCP_SECURITY_CSP = config.security.contentSecurityPolicy === false ? "0" : config.security.contentSecurityPolicy;
185
+ process.env.BCP_SECURITY_FRAME_OPTIONS = config.security.frameOptions === false ? "0" : config.security.frameOptions;
186
+ process.env.BCP_SECURITY_REFERRER_POLICY = config.security.referrerPolicy;
187
+ process.env.BCP_SECURITY_PERMISSIONS_POLICY = config.security.permissionsPolicy === false ? "0" : config.security.permissionsPolicy;
188
+ }
189
+ function readResolvedBcpConfig() {
190
+ const serialized = process.env.BCP_RESOLVED_CONFIG;
191
+ if (!serialized) {
192
+ return structuredClone(
193
+ defaultBcpConfig
194
+ );
195
+ }
196
+ try {
197
+ const value = JSON.parse(
198
+ serialized
199
+ );
200
+ validateResolvedConfig(
201
+ value
202
+ );
203
+ return value;
204
+ } catch (error) {
205
+ throw new Error(
206
+ `BCP Framework: invalid resolved configuration state. ${error instanceof Error ? error.message : String(error)}`
207
+ );
208
+ }
209
+ }
210
+ function assertConfig(value, label) {
211
+ if (!isPlainObject(
212
+ value
213
+ )) {
214
+ throw new Error(
215
+ `BCP Framework: ${label} must export an object.`
216
+ );
217
+ }
218
+ const allowed = /* @__PURE__ */ new Set([
219
+ "server",
220
+ "compression",
221
+ "build",
222
+ "cache",
223
+ "experimental",
224
+ "security"
225
+ ]);
226
+ for (const key of Object.keys(
227
+ value
228
+ )) {
229
+ if (!allowed.has(
230
+ key
231
+ )) {
232
+ throw new Error(
233
+ `BCP Framework: unknown config option "${key}" in ${label}.`
234
+ );
235
+ }
236
+ }
237
+ if (value.server !== void 0) {
238
+ assertObjectKeys(
239
+ value.server,
240
+ "server",
241
+ [
242
+ "port",
243
+ "hostname",
244
+ "bodyLimit"
245
+ ]
246
+ );
247
+ if (value.server.port !== void 0) {
248
+ assertPort(
249
+ value.server.port,
250
+ "server.port"
251
+ );
252
+ }
253
+ if (value.server.hostname !== void 0 && (typeof value.server.hostname !== "string" || value.server.hostname.trim() === "")) {
254
+ throw new Error(
255
+ "BCP Framework: server.hostname must be a non-empty string."
256
+ );
257
+ }
258
+ if (value.server.bodyLimit !== void 0) {
259
+ assertPositiveInteger(
260
+ value.server.bodyLimit,
261
+ "server.bodyLimit"
262
+ );
263
+ }
264
+ }
265
+ assertOptionalBoolean(
266
+ value.compression,
267
+ "compression"
268
+ );
269
+ if (value.build !== void 0) {
270
+ assertObjectKeys(
271
+ value.build,
272
+ "build",
273
+ [
274
+ "minify",
275
+ "sourceMaps"
276
+ ]
277
+ );
278
+ assertOptionalBoolean(
279
+ value.build.minify,
280
+ "build.minify"
281
+ );
282
+ assertOptionalBoolean(
283
+ value.build.sourceMaps,
284
+ "build.sourceMaps"
285
+ );
286
+ }
287
+ if (value.cache !== void 0) {
288
+ assertObjectKeys(
289
+ value.cache,
290
+ "cache",
291
+ [
292
+ "response"
293
+ ]
294
+ );
295
+ assertOptionalBoolean(
296
+ value.cache.response,
297
+ "cache.response"
298
+ );
299
+ }
300
+ if (value.experimental !== void 0) {
301
+ assertObjectKeys(
302
+ value.experimental,
303
+ "experimental",
304
+ [
305
+ "partialHydration",
306
+ "islands"
307
+ ]
308
+ );
309
+ assertOptionalBoolean(
310
+ value.experimental.partialHydration,
311
+ "experimental.partialHydration"
312
+ );
313
+ assertOptionalBoolean(
314
+ value.experimental.islands,
315
+ "experimental.islands"
316
+ );
317
+ }
318
+ if (value.security !== void 0) {
319
+ assertObjectKeys(
320
+ value.security,
321
+ "security",
322
+ [
323
+ "poweredByHeader",
324
+ "contentSecurityPolicy",
325
+ "frameOptions",
326
+ "referrerPolicy",
327
+ "permissionsPolicy"
328
+ ]
329
+ );
330
+ assertOptionalBoolean(
331
+ value.security.poweredByHeader,
332
+ "security.poweredByHeader"
333
+ );
334
+ assertOptionalHeaderOrFalse(
335
+ value.security.contentSecurityPolicy,
336
+ "security.contentSecurityPolicy"
337
+ );
338
+ assertFrameOptions(
339
+ value.security.frameOptions,
340
+ "security.frameOptions"
341
+ );
342
+ assertOptionalHeaderString(
343
+ value.security.referrerPolicy,
344
+ "security.referrerPolicy"
345
+ );
346
+ assertOptionalHeaderOrFalse(
347
+ value.security.permissionsPolicy,
348
+ "security.permissionsPolicy"
349
+ );
350
+ }
351
+ }
352
+ function validateResolvedConfig(value) {
353
+ if (!value || !value.server || !value.build || !value.cache || !value.experimental || !value.security) {
354
+ throw new Error(
355
+ "Resolved config is incomplete."
356
+ );
357
+ }
358
+ assertPort(
359
+ value.server.port,
360
+ "server.port"
361
+ );
362
+ assertPositiveInteger(
363
+ value.server.bodyLimit,
364
+ "server.bodyLimit"
365
+ );
366
+ if (typeof value.server.hostname !== "string" || value.server.hostname.trim() === "") {
367
+ throw new Error(
368
+ "server.hostname must be a non-empty string."
369
+ );
370
+ }
371
+ for (const [
372
+ label,
373
+ item
374
+ ] of [
375
+ ["compression", value.compression],
376
+ ["build.minify", value.build.minify],
377
+ ["build.sourceMaps", value.build.sourceMaps],
378
+ ["cache.response", value.cache.response],
379
+ ["experimental.partialHydration", value.experimental.partialHydration],
380
+ ["experimental.islands", value.experimental.islands],
381
+ ["security.poweredByHeader", value.security.poweredByHeader]
382
+ ]) {
383
+ if (typeof item !== "boolean") {
384
+ throw new Error(
385
+ `${label} must be boolean.`
386
+ );
387
+ }
388
+ }
389
+ assertHeaderOrFalse(
390
+ value.security.contentSecurityPolicy,
391
+ "security.contentSecurityPolicy"
392
+ );
393
+ assertFrameOptions(
394
+ value.security.frameOptions,
395
+ "security.frameOptions"
396
+ );
397
+ assertHeaderString(
398
+ value.security.referrerPolicy,
399
+ "security.referrerPolicy"
400
+ );
401
+ assertHeaderOrFalse(
402
+ value.security.permissionsPolicy,
403
+ "security.permissionsPolicy"
404
+ );
405
+ }
406
+ function assertObjectKeys(value, label, keys) {
407
+ if (!isPlainObject(
408
+ value
409
+ )) {
410
+ throw new Error(
411
+ `BCP Framework: ${label} must be an object.`
412
+ );
413
+ }
414
+ const allowed = new Set(
415
+ keys
416
+ );
417
+ for (const key of Object.keys(
418
+ value
419
+ )) {
420
+ if (!allowed.has(
421
+ key
422
+ )) {
423
+ throw new Error(
424
+ `BCP Framework: unknown config option "${label}.${key}".`
425
+ );
426
+ }
427
+ }
428
+ }
429
+ function assertOptionalBoolean(value, label) {
430
+ if (value !== void 0 && typeof value !== "boolean") {
431
+ throw new Error(
432
+ `BCP Framework: ${label} must be boolean.`
433
+ );
434
+ }
435
+ }
436
+ function assertPort(value, label) {
437
+ if (!Number.isInteger(
438
+ value
439
+ ) || Number(value) < 1 || Number(value) > 65535) {
440
+ throw new Error(
441
+ `BCP Framework: ${label} must be an integer between 1 and 65535.`
442
+ );
443
+ }
444
+ }
445
+ function assertPositiveInteger(value, label) {
446
+ if (!Number.isInteger(
447
+ value
448
+ ) || Number(value) < 1) {
449
+ throw new Error(
450
+ `BCP Framework: ${label} must be a positive integer.`
451
+ );
452
+ }
453
+ }
454
+ function assertOptionalHeaderOrFalse(value, label) {
455
+ if (value === void 0) {
456
+ return;
457
+ }
458
+ assertHeaderOrFalse(
459
+ value,
460
+ label
461
+ );
462
+ }
463
+ function assertHeaderOrFalse(value, label) {
464
+ if (value === false) {
465
+ return;
466
+ }
467
+ assertHeaderString(
468
+ value,
469
+ label
470
+ );
471
+ }
472
+ function assertOptionalHeaderString(value, label) {
473
+ if (value === void 0) {
474
+ return;
475
+ }
476
+ assertHeaderString(
477
+ value,
478
+ label
479
+ );
480
+ }
481
+ function assertHeaderString(value, label) {
482
+ if (typeof value !== "string" || value.trim() === "") {
483
+ throw new Error(
484
+ `BCP Framework: ${label} must be a non-empty string.`
485
+ );
486
+ }
487
+ if (value.includes("\r") || value.includes("\n") || value.includes("\0")) {
488
+ throw new Error(
489
+ `BCP Framework: ${label} must not contain control characters.`
490
+ );
491
+ }
492
+ }
493
+ function assertFrameOptions(value, label) {
494
+ if (value === void 0 || value === false || value === "DENY" || value === "SAMEORIGIN") {
495
+ return;
496
+ }
497
+ throw new Error(
498
+ `BCP Framework: ${label} must be "DENY", "SAMEORIGIN", or false.`
499
+ );
500
+ }
501
+ function parseEnvironmentPort(value) {
502
+ if (value === void 0 || value.trim() === "") {
503
+ return void 0;
504
+ }
505
+ const port = Number(
506
+ value
507
+ );
508
+ assertPort(
509
+ port,
510
+ "BCP_PORT"
511
+ );
512
+ return port;
513
+ }
514
+ function parsePositiveInteger(value, label) {
515
+ if (value === void 0 || value.trim() === "") {
516
+ return void 0;
517
+ }
518
+ const parsed = Number(
519
+ value
520
+ );
521
+ assertPositiveInteger(
522
+ parsed,
523
+ label
524
+ );
525
+ return parsed;
526
+ }
527
+ function parseEnvironmentBoolean(value, label) {
528
+ if (value === void 0 || value.trim() === "") {
529
+ return void 0;
530
+ }
531
+ const normalized = value.trim().toLowerCase();
532
+ if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") {
533
+ return true;
534
+ }
535
+ if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") {
536
+ return false;
537
+ }
538
+ throw new Error(
539
+ `BCP Framework: ${label} must be a boolean value (true/false, 1/0, yes/no, on/off).`
540
+ );
541
+ }
542
+ function parseOptionalHeaderEnvironment(value, label) {
543
+ if (value === void 0 || value.trim() === "") {
544
+ return void 0;
545
+ }
546
+ const normalized = value.trim();
547
+ const lowered = normalized.toLowerCase();
548
+ if (lowered === "0" || lowered === "false" || lowered === "off") {
549
+ return false;
550
+ }
551
+ assertHeaderString(
552
+ normalized,
553
+ label
554
+ );
555
+ return normalized;
556
+ }
557
+ function parseFrameOptionsEnvironment(value) {
558
+ if (value === void 0 || value.trim() === "") {
559
+ return void 0;
560
+ }
561
+ const normalized = value.trim().toUpperCase();
562
+ if (normalized === "0" || normalized === "FALSE" || normalized === "OFF") {
563
+ return false;
564
+ }
565
+ if (normalized === "DENY" || normalized === "SAMEORIGIN") {
566
+ return normalized;
567
+ }
568
+ throw new Error(
569
+ "BCP Framework: BCP_SECURITY_FRAME_OPTIONS must be DENY, SAMEORIGIN, or off."
570
+ );
571
+ }
572
+ function nonEmptyEnvironmentValue(value) {
573
+ const normalized = value?.trim();
574
+ return normalized ? normalized : void 0;
575
+ }
576
+ function isPlainObject(value) {
577
+ return typeof value === "object" && value !== null && !Array.isArray(
578
+ value
579
+ );
580
+ }
581
+
582
+ // packages/config/src/environment-schema.ts
583
+ function defineEnvironment(schema) {
584
+ assertEnvironmentSchema(
585
+ schema
586
+ );
587
+ return schema;
588
+ }
589
+ function applyEnvironmentDefaults(schema, target = process.env) {
590
+ assertEnvironmentSchema(
591
+ schema
592
+ );
593
+ let applied = 0;
594
+ for (const [
595
+ key,
596
+ rule
597
+ ] of Object.entries(
598
+ schema
599
+ )) {
600
+ if (rule.default === void 0 || target[key] !== void 0 && target[key] !== "") {
601
+ continue;
602
+ }
603
+ target[key] = serializeEnvironmentValue(
604
+ rule.default
605
+ );
606
+ applied++;
607
+ }
608
+ return applied;
609
+ }
610
+ function validateEnvironment(schema, source = process.env) {
611
+ assertEnvironmentSchema(
612
+ schema
613
+ );
614
+ const issues = [];
615
+ const values = {};
616
+ let present = 0;
617
+ let defaults = 0;
618
+ for (const [
619
+ key,
620
+ rule
621
+ ] of Object.entries(
622
+ schema
623
+ )) {
624
+ if (rule.secret && key.startsWith(
625
+ "BCP_PUBLIC_"
626
+ )) {
627
+ issues.push({
628
+ key,
629
+ severity: "error",
630
+ code: "public_secret",
631
+ message: `${key} is marked secret but uses the BCP_PUBLIC_ prefix. Public variables are embedded in client bundles.`
632
+ });
633
+ }
634
+ const raw = source[key];
635
+ const hasSourceValue = raw !== void 0 && raw !== "";
636
+ if (hasSourceValue) {
637
+ present++;
638
+ }
639
+ const input = hasSourceValue ? raw : rule.default;
640
+ if (input === void 0) {
641
+ if (rule.required) {
642
+ issues.push({
643
+ key,
644
+ severity: "error",
645
+ code: "missing",
646
+ message: `${key} is required but was not provided.`
647
+ });
648
+ }
649
+ continue;
650
+ }
651
+ if (!hasSourceValue) {
652
+ defaults++;
653
+ }
654
+ const parsed = parseEnvironmentRuleValue(
655
+ key,
656
+ input,
657
+ rule,
658
+ issues
659
+ );
660
+ if (parsed !== void 0) {
661
+ values[key] = parsed;
662
+ }
663
+ }
664
+ return {
665
+ ok: !issues.some(
666
+ (issue) => issue.severity === "error"
667
+ ),
668
+ checked: Object.keys(
669
+ schema
670
+ ).length,
671
+ present,
672
+ defaults,
673
+ issues,
674
+ values
675
+ };
676
+ }
677
+ function assertEnvironmentSchema(schema) {
678
+ if (!isPlainObject2(
679
+ schema
680
+ )) {
681
+ throw new Error(
682
+ "BCP Framework: environment config must be an object."
683
+ );
684
+ }
685
+ for (const [
686
+ key,
687
+ value
688
+ ] of Object.entries(
689
+ schema
690
+ )) {
691
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(
692
+ key
693
+ )) {
694
+ throw new Error(
695
+ `BCP Framework: invalid environment schema key "${key}".`
696
+ );
697
+ }
698
+ assertEnvironmentRule(
699
+ key,
700
+ value
701
+ );
702
+ }
703
+ }
704
+ function assertEnvironmentRule(key, value) {
705
+ if (!isPlainObject2(
706
+ value
707
+ )) {
708
+ throw new Error(
709
+ `BCP Framework: environment.${key} must be an object.`
710
+ );
711
+ }
712
+ const allowed = /* @__PURE__ */ new Set([
713
+ "type",
714
+ "required",
715
+ "secret",
716
+ "minLength",
717
+ "maxLength",
718
+ "min",
719
+ "max",
720
+ "default",
721
+ "description"
722
+ ]);
723
+ for (const property of Object.keys(
724
+ value
725
+ )) {
726
+ if (!allowed.has(
727
+ property
728
+ )) {
729
+ throw new Error(
730
+ `BCP Framework: unknown config option "environment.${key}.${property}".`
731
+ );
732
+ }
733
+ }
734
+ if (value.type !== "string" && value.type !== "number" && value.type !== "boolean" && value.type !== "url") {
735
+ throw new Error(
736
+ `BCP Framework: environment.${key}.type must be string, number, boolean, or url.`
737
+ );
738
+ }
739
+ assertOptionalBoolean2(
740
+ value.required,
741
+ `environment.${key}.required`
742
+ );
743
+ assertOptionalBoolean2(
744
+ value.secret,
745
+ `environment.${key}.secret`
746
+ );
747
+ assertOptionalNonNegativeInteger(
748
+ value.minLength,
749
+ `environment.${key}.minLength`
750
+ );
751
+ assertOptionalNonNegativeInteger(
752
+ value.maxLength,
753
+ `environment.${key}.maxLength`
754
+ );
755
+ assertOptionalFiniteNumber(
756
+ value.min,
757
+ `environment.${key}.min`
758
+ );
759
+ assertOptionalFiniteNumber(
760
+ value.max,
761
+ `environment.${key}.max`
762
+ );
763
+ if (value.description !== void 0 && (typeof value.description !== "string" || value.description.trim() === "")) {
764
+ throw new Error(
765
+ `BCP Framework: environment.${key}.description must be a non-empty string.`
766
+ );
767
+ }
768
+ if (value.minLength !== void 0 && value.maxLength !== void 0 && value.minLength > value.maxLength) {
769
+ throw new Error(
770
+ `BCP Framework: environment.${key}.minLength cannot be greater than maxLength.`
771
+ );
772
+ }
773
+ if (value.min !== void 0 && value.max !== void 0 && value.min > value.max) {
774
+ throw new Error(
775
+ `BCP Framework: environment.${key}.min cannot be greater than max.`
776
+ );
777
+ }
778
+ if (value.default !== void 0) {
779
+ const issues = [];
780
+ const parsed = parseEnvironmentRuleValue(
781
+ key,
782
+ value.default,
783
+ value,
784
+ issues
785
+ );
786
+ if (parsed === void 0 || issues.some(
787
+ (issue) => issue.severity === "error"
788
+ )) {
789
+ throw new Error(
790
+ `BCP Framework: environment.${key}.default does not satisfy its declared rule.`
791
+ );
792
+ }
793
+ }
794
+ }
795
+ function parseEnvironmentRuleValue(key, input, rule, issues) {
796
+ if (rule.type === "string" || rule.type === "url") {
797
+ if (typeof input !== "string") {
798
+ issues.push({
799
+ key,
800
+ severity: "error",
801
+ code: "invalid_type",
802
+ message: `${key} must be a ${rule.type}.`
803
+ });
804
+ return void 0;
805
+ }
806
+ if (rule.minLength !== void 0 && input.length < rule.minLength) {
807
+ issues.push({
808
+ key,
809
+ severity: "error",
810
+ code: "too_short",
811
+ message: `${key} must contain at least ${rule.minLength} character(s).`
812
+ });
813
+ }
814
+ if (rule.maxLength !== void 0 && input.length > rule.maxLength) {
815
+ issues.push({
816
+ key,
817
+ severity: "error",
818
+ code: "too_long",
819
+ message: `${key} must contain at most ${rule.maxLength} character(s).`
820
+ });
821
+ }
822
+ if (rule.type === "url") {
823
+ try {
824
+ const url = new URL(
825
+ input
826
+ );
827
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
828
+ throw new Error(
829
+ "unsupported protocol"
830
+ );
831
+ }
832
+ } catch {
833
+ issues.push({
834
+ key,
835
+ severity: "error",
836
+ code: "invalid_type",
837
+ message: `${key} must be an absolute http(s) URL.`
838
+ });
839
+ return void 0;
840
+ }
841
+ }
842
+ return input;
843
+ }
844
+ if (rule.type === "number") {
845
+ const parsed = typeof input === "number" ? input : typeof input === "string" && input.trim() !== "" ? Number(
846
+ input
847
+ ) : Number.NaN;
848
+ if (!Number.isFinite(
849
+ parsed
850
+ )) {
851
+ issues.push({
852
+ key,
853
+ severity: "error",
854
+ code: "invalid_type",
855
+ message: `${key} must be a finite number.`
856
+ });
857
+ return void 0;
858
+ }
859
+ if (rule.min !== void 0 && parsed < rule.min) {
860
+ issues.push({
861
+ key,
862
+ severity: "error",
863
+ code: "too_small",
864
+ message: `${key} must be greater than or equal to ${rule.min}.`
865
+ });
866
+ }
867
+ if (rule.max !== void 0 && parsed > rule.max) {
868
+ issues.push({
869
+ key,
870
+ severity: "error",
871
+ code: "too_large",
872
+ message: `${key} must be less than or equal to ${rule.max}.`
873
+ });
874
+ }
875
+ return parsed;
876
+ }
877
+ if (typeof input === "boolean") {
878
+ return input;
879
+ }
880
+ if (typeof input === "string") {
881
+ const normalized = input.trim().toLowerCase();
882
+ if ([
883
+ "1",
884
+ "true",
885
+ "yes",
886
+ "on"
887
+ ].includes(
888
+ normalized
889
+ )) {
890
+ return true;
891
+ }
892
+ if ([
893
+ "0",
894
+ "false",
895
+ "no",
896
+ "off"
897
+ ].includes(
898
+ normalized
899
+ )) {
900
+ return false;
901
+ }
902
+ }
903
+ issues.push({
904
+ key,
905
+ severity: "error",
906
+ code: "invalid_type",
907
+ message: `${key} must be a boolean value (true/false, 1/0, yes/no, on/off).`
908
+ });
909
+ return void 0;
910
+ }
911
+ function serializeEnvironmentValue(value) {
912
+ if (typeof value === "boolean") {
913
+ return value ? "true" : "false";
914
+ }
915
+ return String(
916
+ value
917
+ );
918
+ }
919
+ function assertOptionalBoolean2(value, label) {
920
+ if (value !== void 0 && typeof value !== "boolean") {
921
+ throw new Error(
922
+ `BCP Framework: ${label} must be boolean.`
923
+ );
924
+ }
925
+ }
926
+ function assertOptionalNonNegativeInteger(value, label) {
927
+ if (value !== void 0 && (!Number.isInteger(
928
+ value
929
+ ) || Number(value) < 0)) {
930
+ throw new Error(
931
+ `BCP Framework: ${label} must be a non-negative integer.`
932
+ );
933
+ }
934
+ }
935
+ function assertOptionalFiniteNumber(value, label) {
936
+ if (value !== void 0 && (typeof value !== "number" || !Number.isFinite(
937
+ value
938
+ ))) {
939
+ throw new Error(
940
+ `BCP Framework: ${label} must be a finite number.`
941
+ );
942
+ }
943
+ }
944
+ function isPlainObject2(value) {
945
+ return typeof value === "object" && value !== null && !Array.isArray(
946
+ value
947
+ );
948
+ }
949
+
950
+ // packages/config/src/environment-loader.ts
951
+ import fs2 from "node:fs";
952
+ import path2 from "node:path";
953
+ import {
954
+ pathToFileURL as pathToFileURL2
955
+ } from "node:url";
956
+ var ENVIRONMENT_SCHEMA_FILES = [
957
+ "bcp.environment.ts",
958
+ "bcp.environment.mts",
959
+ "bcp.environment.js",
960
+ "bcp.environment.mjs"
961
+ ];
962
+ function getEnvironmentSchemaFileNames() {
963
+ return [
964
+ ...ENVIRONMENT_SCHEMA_FILES
965
+ ];
966
+ }
967
+ async function loadBcpEnvironmentSchema(rootDirectory) {
968
+ const matches = ENVIRONMENT_SCHEMA_FILES.map(
969
+ (fileName) => path2.join(
970
+ rootDirectory,
971
+ fileName
972
+ )
973
+ ).filter(
974
+ (filePath) => fs2.existsSync(
975
+ filePath
976
+ )
977
+ );
978
+ if (matches.length > 1) {
979
+ throw new Error(
980
+ `BCP Framework: multiple environment schema files found: ${matches.map((file2) => path2.basename(file2)).join(", ")}. Keep only one bcp.environment file.`
981
+ );
982
+ }
983
+ if (matches.length === 0) {
984
+ return {
985
+ file: null,
986
+ schema: {}
987
+ };
988
+ }
989
+ const file = matches[0];
990
+ const url = pathToFileURL2(
991
+ file
992
+ );
993
+ url.searchParams.set(
994
+ "bcp-environment",
995
+ `${Date.now()}-${Math.random()}`
996
+ );
997
+ const module = await import(url.href);
998
+ const schema = module.default;
999
+ if (schema === void 0) {
1000
+ throw new Error(
1001
+ `BCP Framework: ${path2.basename(file)} must export a default environment schema.`
1002
+ );
1003
+ }
1004
+ assertEnvironmentSchema(
1005
+ schema
1006
+ );
1007
+ return {
1008
+ file,
1009
+ schema
1010
+ };
1011
+ }
1012
+
1013
+ // packages/config/src/diagnostics.ts
1014
+ import path3 from "node:path";
1015
+ async function diagnoseBcpConfiguration(options) {
1016
+ const environment = options.environment ?? process.env;
1017
+ const resolved = await resolveBcpConfig(
1018
+ options.rootDirectory,
1019
+ options.overrides ?? {},
1020
+ environment
1021
+ );
1022
+ const loadedSchema = await loadBcpEnvironmentSchema(
1023
+ options.rootDirectory
1024
+ );
1025
+ const environmentValidation = validateEnvironment(
1026
+ loadedSchema.schema,
1027
+ environment
1028
+ );
1029
+ const diagnostics = [];
1030
+ appendEnvironmentDiagnostics(
1031
+ diagnostics,
1032
+ environmentValidation
1033
+ );
1034
+ appendRuntimeDiagnostics(
1035
+ diagnostics,
1036
+ options.mode,
1037
+ resolved.config,
1038
+ environment
1039
+ );
1040
+ return {
1041
+ ok: !diagnostics.some(
1042
+ (diagnostic) => diagnostic.severity === "error"
1043
+ ),
1044
+ mode: options.mode,
1045
+ configFile: resolved.file ? path3.basename(
1046
+ resolved.file
1047
+ ) : null,
1048
+ environmentSchemaFile: loadedSchema.file ? path3.basename(
1049
+ loadedSchema.file
1050
+ ) : null,
1051
+ resolvedConfig: resolved.config,
1052
+ environment: {
1053
+ checked: environmentValidation.checked,
1054
+ present: environmentValidation.present,
1055
+ defaults: environmentValidation.defaults
1056
+ },
1057
+ diagnostics
1058
+ };
1059
+ }
1060
+ function assertConfigurationDiagnostics(report) {
1061
+ if (report.ok) {
1062
+ return;
1063
+ }
1064
+ const errors = report.diagnostics.filter(
1065
+ (diagnostic) => diagnostic.severity === "error"
1066
+ ).map(
1067
+ (diagnostic) => diagnostic.message
1068
+ );
1069
+ throw new Error(
1070
+ `BCP Configuration Error:
1071
+ ${errors.map((message) => `- ${message}`).join("\n")}`
1072
+ );
1073
+ }
1074
+ function appendEnvironmentDiagnostics(diagnostics, validation) {
1075
+ for (const issue of validation.issues) {
1076
+ diagnostics.push({
1077
+ severity: issue.severity,
1078
+ code: `environment.${issue.code}`,
1079
+ message: issue.message
1080
+ });
1081
+ }
1082
+ }
1083
+ function appendRuntimeDiagnostics(diagnostics, mode, config, environment) {
1084
+ if (mode !== "production") {
1085
+ return;
1086
+ }
1087
+ if (config.build.sourceMaps) {
1088
+ diagnostics.push({
1089
+ severity: "warning",
1090
+ code: "production.source_maps",
1091
+ message: "Production source maps are enabled. Confirm that exposing source information is intentional."
1092
+ });
1093
+ }
1094
+ if (config.security.poweredByHeader) {
1095
+ diagnostics.push({
1096
+ severity: "warning",
1097
+ code: "production.powered_by",
1098
+ message: "security.poweredByHeader is enabled in production."
1099
+ });
1100
+ }
1101
+ if (config.security.contentSecurityPolicy === false) {
1102
+ diagnostics.push({
1103
+ severity: "warning",
1104
+ code: "production.csp_disabled",
1105
+ message: "Content-Security-Policy is disabled. Configure a policy when the application can support one."
1106
+ });
1107
+ }
1108
+ const trustProxy = environment.BCP_TRUST_PROXY?.trim().toLowerCase();
1109
+ if (trustProxy === "true" || trustProxy === "1" || trustProxy === "yes" || trustProxy === "on") {
1110
+ diagnostics.push({
1111
+ severity: "warning",
1112
+ code: "production.trust_proxy",
1113
+ message: "BCP_TRUST_PROXY is enabled. Ensure untrusted clients cannot bypass the trusted reverse proxy/load balancer."
1114
+ });
1115
+ }
1116
+ }
1117
+ export {
1118
+ applyEnvironmentDefaults,
1119
+ applyResolvedBcpConfig,
1120
+ assertConfigurationDiagnostics,
1121
+ defaultBcpConfig,
1122
+ defineConfig,
1123
+ defineEnvironment,
1124
+ diagnoseBcpConfiguration,
1125
+ getConfigFileNames,
1126
+ getEnvironmentSchemaFileNames,
1127
+ loadBcpConfig,
1128
+ loadBcpEnvironmentSchema,
1129
+ readResolvedBcpConfig,
1130
+ resolveBcpConfig,
1131
+ validateEnvironment
1132
+ };