@zerotal/core 1.0.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.
Files changed (201) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +128 -0
  4. package/package.json +72 -0
  5. package/src/application/Application.ts +1671 -0
  6. package/src/application/BootDoctor.ts +108 -0
  7. package/src/application/DevErrorPage.ts +567 -0
  8. package/src/application/ExceptionHandler.ts +183 -0
  9. package/src/application/currentApp.ts +73 -0
  10. package/src/assets/assets.ts +79 -0
  11. package/src/assets/index.ts +16 -0
  12. package/src/auth/AuthenticatedUser.ts +18 -0
  13. package/src/build/PackageLinter.ts +146 -0
  14. package/src/build/PackageScaffold.ts +127 -0
  15. package/src/build/codemod.ts +64 -0
  16. package/src/build/index.ts +12 -0
  17. package/src/command/Command.ts +254 -0
  18. package/src/command/CommandRunner.ts +593 -0
  19. package/src/command/OutputWriter.ts +61 -0
  20. package/src/command/builtin/CompileCommand.ts +46 -0
  21. package/src/command/builtin/CssBuildCommand.ts +71 -0
  22. package/src/command/builtin/KeyGenerateCommand.ts +58 -0
  23. package/src/command/builtin/LintPackagesCommand.ts +72 -0
  24. package/src/command/builtin/MakeCommandCommand.ts +85 -0
  25. package/src/command/builtin/MakeControllerCommand.ts +95 -0
  26. package/src/command/builtin/MakeEventCommand.ts +85 -0
  27. package/src/command/builtin/MakeJobCommand.ts +53 -0
  28. package/src/command/builtin/MakeListenerCommand.ts +35 -0
  29. package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
  30. package/src/command/builtin/MakeNotificationCommand.ts +48 -0
  31. package/src/command/builtin/MakeObserverCommand.ts +78 -0
  32. package/src/command/builtin/MakePackageCommand.ts +45 -0
  33. package/src/command/builtin/MakePolicyCommand.ts +66 -0
  34. package/src/command/builtin/MakeProviderCommand.ts +75 -0
  35. package/src/command/builtin/MakeRequestCommand.ts +47 -0
  36. package/src/command/builtin/MakeResourceCommand.ts +61 -0
  37. package/src/command/builtin/MakeTestCommand.ts +120 -0
  38. package/src/command/builtin/ReloadCommand.ts +52 -0
  39. package/src/command/builtin/ReplCommand.ts +174 -0
  40. package/src/command/builtin/RouteListCommand.ts +188 -0
  41. package/src/command/builtin/ServeCommand.ts +321 -0
  42. package/src/command/builtin/StartCommand.ts +3 -0
  43. package/src/command/builtin/StatusCommand.ts +71 -0
  44. package/src/command/builtin/TestCommand.ts +172 -0
  45. package/src/command/builtin/WorkerCommand.ts +27 -0
  46. package/src/command/builtin/index.ts +53 -0
  47. package/src/command/scaffold/worker.ts.txt +12 -0
  48. package/src/command/scaffold/zerotal.ts.txt +26 -0
  49. package/src/command/startZerotal.ts +55 -0
  50. package/src/config/AppConfig.ts +253 -0
  51. package/src/config/ConfigLoader.ts +117 -0
  52. package/src/config/ConfigManager.ts +169 -0
  53. package/src/config/index.ts +46 -0
  54. package/src/config/registry.ts +59 -0
  55. package/src/config/validation.ts +117 -0
  56. package/src/container/Container.ts +606 -0
  57. package/src/container/ContextualBindingBuilder.ts +57 -0
  58. package/src/container/ScopedResolver.ts +117 -0
  59. package/src/container/index.ts +32 -0
  60. package/src/container/inject.ts +55 -0
  61. package/src/container/types.ts +71 -0
  62. package/src/context/RequestContext.ts +91 -0
  63. package/src/contracts/auth.ts +24 -0
  64. package/src/contracts/index.ts +23 -0
  65. package/src/contracts/session.ts +70 -0
  66. package/src/contracts/transaction.ts +26 -0
  67. package/src/conventions/ConventionLoader.ts +128 -0
  68. package/src/conventions/builtinConcerns.ts +131 -0
  69. package/src/crypt/Crypt.ts +141 -0
  70. package/src/crypt/URLSigner.ts +96 -0
  71. package/src/datetime/Carbon.ts +1396 -0
  72. package/src/datetime/CarbonInterval.ts +421 -0
  73. package/src/datetime/clock.ts +28 -0
  74. package/src/datetime/index.ts +23 -0
  75. package/src/datetime/temporal-shim.ts +1 -0
  76. package/src/dev/BuildOutput.ts +131 -0
  77. package/src/dev/CssPlugins.ts +184 -0
  78. package/src/dev/DevBuildHook.ts +74 -0
  79. package/src/dev/DevOrchestrator.ts +213 -0
  80. package/src/dev/DevReloadMiddleware.ts +101 -0
  81. package/src/dev/DevReloadServer.ts +85 -0
  82. package/src/dev/DevWsServer.ts +45 -0
  83. package/src/dev/index.ts +19 -0
  84. package/src/dev/reloadClient.ts +39 -0
  85. package/src/env/Def.ts +232 -0
  86. package/src/env/EnvSchema.ts +105 -0
  87. package/src/env/index.ts +34 -0
  88. package/src/env/t.ts +128 -0
  89. package/src/errors/ConfigError.ts +12 -0
  90. package/src/errors/ContainerErrors.ts +143 -0
  91. package/src/errors/HttpError.ts +127 -0
  92. package/src/errors/ValidationError.ts +19 -0
  93. package/src/errors/ZerotalError.ts +25 -0
  94. package/src/errors/index.ts +46 -0
  95. package/src/events/CallQueuedListener.ts +66 -0
  96. package/src/events/Emitter.ts +280 -0
  97. package/src/events/EventFake.ts +160 -0
  98. package/src/events/FrameworkEvents.ts +252 -0
  99. package/src/facade/Facade.ts +101 -0
  100. package/src/facade/facades/App.ts +155 -0
  101. package/src/facade/facades/Artisan.ts +63 -0
  102. package/src/facade/facades/Config.ts +21 -0
  103. package/src/facade/facades/Events.ts +19 -0
  104. package/src/facade/facades/index.ts +28 -0
  105. package/src/global.d.ts +9 -0
  106. package/src/hash/Hash.ts +60 -0
  107. package/src/health/Health.ts +221 -0
  108. package/src/health/index.ts +27 -0
  109. package/src/helpers/Collection.ts +435 -0
  110. package/src/helpers/config.ts +59 -0
  111. package/src/helpers/fluent.ts +52 -0
  112. package/src/helpers/html.ts +11 -0
  113. package/src/helpers/index.ts +266 -0
  114. package/src/helpers/make.ts +35 -0
  115. package/src/helpers/markdown.ts +73 -0
  116. package/src/helpers/pageElements.ts +27 -0
  117. package/src/helpers/request.ts +62 -0
  118. package/src/helpers/response.ts +411 -0
  119. package/src/helpers/str.ts +208 -0
  120. package/src/http/Http.ts +298 -0
  121. package/src/http/HttpClient.ts +289 -0
  122. package/src/http/Resource.ts +171 -0
  123. package/src/http/UploadedFile.ts +204 -0
  124. package/src/http/Uri.ts +490 -0
  125. package/src/http/index.ts +46 -0
  126. package/src/http/negotiate.ts +213 -0
  127. package/src/http/originGuard.ts +76 -0
  128. package/src/http/sniffContentType.ts +105 -0
  129. package/src/http/url.ts +204 -0
  130. package/src/http/withHeaders.ts +24 -0
  131. package/src/index.ts +250 -0
  132. package/src/lock/LockManager.ts +228 -0
  133. package/src/lock/config.ts +49 -0
  134. package/src/lock/drivers/LockDriver.ts +32 -0
  135. package/src/lock/drivers/MemoryLockDriver.ts +52 -0
  136. package/src/lock/drivers/RedisLockDriver.ts +58 -0
  137. package/src/lock/drivers/SqliteLockDriver.ts +85 -0
  138. package/src/lock/errors.ts +20 -0
  139. package/src/lock/facades/Lock.ts +114 -0
  140. package/src/lock/index.ts +53 -0
  141. package/src/logger/Log.ts +35 -0
  142. package/src/logger/LogManager.ts +430 -0
  143. package/src/logger/LoggerMiddleware.ts +125 -0
  144. package/src/logger/channels/ConsoleChannel.ts +139 -0
  145. package/src/logger/channels/DailyChannel.ts +74 -0
  146. package/src/logger/channels/NullChannel.ts +17 -0
  147. package/src/logger/channels/SingleChannel.ts +34 -0
  148. package/src/logger/channels/StackChannel.ts +29 -0
  149. package/src/logger/config.ts +90 -0
  150. package/src/logger/format.ts +96 -0
  151. package/src/logger/frameworkLog.ts +93 -0
  152. package/src/logger/index.ts +68 -0
  153. package/src/logger/renderTable.ts +111 -0
  154. package/src/logger/types.ts +212 -0
  155. package/src/macros/config.macro.ts +50 -0
  156. package/src/metrics/HttpMetrics.ts +114 -0
  157. package/src/metrics/index.ts +18 -0
  158. package/src/middleware/BaseMiddleware.ts +72 -0
  159. package/src/middleware/CorsMiddleware.ts +152 -0
  160. package/src/middleware/RateLimiter.ts +255 -0
  161. package/src/middleware/SecureHeadersMiddleware.ts +127 -0
  162. package/src/middleware/ThrottleMiddleware.ts +252 -0
  163. package/src/middleware/WebhookMiddleware.ts +204 -0
  164. package/src/pipeline/ContextRegistry.ts +42 -0
  165. package/src/pipeline/HttpContext.ts +865 -0
  166. package/src/pipeline/Pipeline.ts +150 -0
  167. package/src/pipeline/currentPage.ts +46 -0
  168. package/src/pipeline/types.ts +80 -0
  169. package/src/provider/LockProvider.ts +64 -0
  170. package/src/provider/LogProvider.ts +137 -0
  171. package/src/provider/ServiceProvider.ts +84 -0
  172. package/src/provider/StorageProvider.ts +45 -0
  173. package/src/router/FileRouter.ts +526 -0
  174. package/src/router/Route.ts +76 -0
  175. package/src/router/RouteHandler.ts +335 -0
  176. package/src/router/Router.ts +1247 -0
  177. package/src/router/domain.ts +65 -0
  178. package/src/security/index.ts +22 -0
  179. package/src/storage/FakeDisk.ts +233 -0
  180. package/src/storage/StorageFilesMiddleware.ts +150 -0
  181. package/src/storage/StorageManager.ts +173 -0
  182. package/src/storage/config.ts +47 -0
  183. package/src/storage/drivers/LocalDriver.ts +138 -0
  184. package/src/storage/drivers/S3Driver.ts +169 -0
  185. package/src/storage/errors.ts +135 -0
  186. package/src/storage/facades/Storage.ts +3 -0
  187. package/src/storage/global.d.ts +7 -0
  188. package/src/storage/index.ts +22 -0
  189. package/src/storage/root.ts +59 -0
  190. package/src/storage/types.ts +104 -0
  191. package/src/support/appKey.ts +38 -0
  192. package/src/support/cookie.ts +72 -0
  193. package/src/support/crypto.ts +52 -0
  194. package/src/support/deepMerge.ts +117 -0
  195. package/src/support/env.ts +71 -0
  196. package/src/support/network.ts +79 -0
  197. package/src/support/port.ts +197 -0
  198. package/src/support/str.ts +122 -0
  199. package/src/view/FileRouteResolver.ts +59 -0
  200. package/src/view/index.ts +144 -0
  201. package/src/view/jsx-runtime.ts +233 -0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Synchronous loader for a `config/` directory: discovers each config file,
3
+ * exposes its values as a namespaced map, and collects optional per-file
4
+ * validators so configuration can be checked before the app boots.
5
+ */
6
+ import path from "node:path";
7
+ import { createRequire } from "node:module";
8
+ import { frameworkLog } from "../logger/frameworkLog.ts";
9
+
10
+ const requireModule = createRequire(import.meta.url);
11
+
12
+ /** Namespace → config object map (filename without extension is the namespace). */
13
+ export type ConfigMap = Record<string, Record<string, unknown>>;
14
+ type Validator = (value: Record<string, unknown>) => void;
15
+
16
+ /**
17
+ * A loaded set of config files, keyed by filename (`config/app.ts` → `app`). Pass `all()` (or the
18
+ * loader itself) to `Application.create({ config })` / `app.useConfig(...)`.
19
+ */
20
+ export class ConfigLoader {
21
+ constructor(
22
+ private readonly _map: ConfigMap,
23
+ private readonly _validators: ReadonlyArray<{ ns: string; fn: Validator }> = [],
24
+ ) {}
25
+
26
+ /** The full namespace → config map. */
27
+ all(): ConfigMap {
28
+ return this._map;
29
+ }
30
+
31
+ /**
32
+ * Dot-path read, e.g. `get("database.url")`.
33
+ *
34
+ * @param key Dot-notation path across the loaded namespaces.
35
+ * @param fallback Value returned when the path is missing.
36
+ * @returns The stored value, or `fallback` when unset.
37
+ */
38
+ get<T = unknown>(key: string, fallback?: T): T {
39
+ let current: unknown = this._map;
40
+ for (const part of key.split(".")) {
41
+ if (current && typeof current === "object" && part in (current as Record<string, unknown>)) {
42
+ current = (current as Record<string, unknown>)[part];
43
+ } else {
44
+ return fallback as T;
45
+ }
46
+ }
47
+ return (current ?? fallback) as T;
48
+ }
49
+
50
+ /** Whether a value exists at the given dot-path. */
51
+ has(key: string): boolean {
52
+ return this.get(key, undefined as unknown) !== undefined;
53
+ }
54
+
55
+ /**
56
+ * Run each config file's optional `validate(config)` named export against its value, throwing on
57
+ * the first failure. Returns `this` for chaining. (Config files that read env at module load are
58
+ * already validated by the act of loading.)
59
+ *
60
+ * @returns `this`, so the call chains into `Application.create({ config })`.
61
+ * @throws Whatever a config file's `validate(config)` export throws on the first failure.
62
+ */
63
+ validate(): this {
64
+ for (const { ns: namespace, fn: validate } of this._validators) {
65
+ validate(this._map[namespace] ?? {});
66
+ }
67
+ return this;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Synchronously load every `*.ts` / `*.js` file in `dir` (default `./config`) into a namespace map
73
+ * keyed by filename. Safe to call at the top level of an entry script (`zerotal.ts`) — no `await`.
74
+ *
75
+ * Each file's **default export** is the config object; an optional named **`validate(config)`**
76
+ * export is collected and run by `ConfigLoader.validate()`.
77
+ *
78
+ * @param dir Directory to scan, relative to `process.cwd()`. Defaults to `./config`.
79
+ * @returns A {@link ConfigLoader} over the discovered namespaces. A missing
80
+ * directory yields an empty loader rather than throwing.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * const config = configLoader("./config");
85
+ * config.validate();
86
+ * const app = Application.create({ config }); // or app.useConfig(config)
87
+ * ```
88
+ */
89
+ export function configLoader(dir = "./config"): ConfigLoader {
90
+ const absoluteDirectory = path.resolve(process.cwd(), dir);
91
+ const map: ConfigMap = {};
92
+ const validators: Array<{ ns: string; fn: Validator }> = [];
93
+ try {
94
+ const glob = new Bun.Glob("*.{ts,js}");
95
+ for (const file of glob.scanSync({ cwd: absoluteDirectory, onlyFiles: true })) {
96
+ const namespace = file.replace(/\.(ts|js)$/, "");
97
+ try {
98
+ const loadedModule = requireModule(path.join(absoluteDirectory, file)) as Record<
99
+ string,
100
+ unknown
101
+ >;
102
+ const value = loadedModule["default"] ?? loadedModule;
103
+ if (value && typeof value === "object" && !Array.isArray(value)) {
104
+ map[namespace] = value as Record<string, unknown>;
105
+ }
106
+ if (typeof loadedModule["validate"] === "function") {
107
+ validators.push({ ns: namespace, fn: loadedModule["validate"] as Validator });
108
+ }
109
+ } catch (error) {
110
+ frameworkLog("config").error(`Config "${file}" failed to load`, { file }, error);
111
+ }
112
+ }
113
+ } catch {
114
+ // A missing config/ directory is fine — minimal apps may not have one.
115
+ }
116
+ return new ConfigLoader(map, validators);
117
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The runtime configuration store: holds the merged config namespaces and
3
+ * serves dot-path reads/writes behind the `Config` facade and `config()` helper.
4
+ */
5
+ import { ConfigError } from "../errors/ConfigError.ts";
6
+
7
+ /**
8
+ * The runtime configuration store — holds every loaded config namespace and
9
+ * serves dot-path reads and writes.
10
+ *
11
+ * Populated from the `config/*.ts` files during `onBooting()` by a
12
+ * ServiceProvider, then bound in the container under `config`. Application code
13
+ * rarely touches this class directly; it reads through the `Config` facade or
14
+ * the `config()` helper, both of which delegate here.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { config } from "@zerotal/core";
19
+ * import { Config } from "@zerotal/core/facades";
20
+ *
21
+ * // Typed dot-path access (via the config() helper):
22
+ * const name = config("app.name"); // string
23
+ * const port = config("app.port"); // number
24
+ *
25
+ * // Or through the facade:
26
+ * const url = Config.get("app.url", "http://localhost:3000");
27
+ * ```
28
+ */
29
+ export class ConfigManager {
30
+ private _data: Record<string, unknown>;
31
+ constructor() {
32
+ this._data = {};
33
+ }
34
+
35
+ // ── Loading ───────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Merge a config namespace into the store, replacing any existing value under
39
+ * the same key. Called by ConfigProvider during `onBooting()`.
40
+ *
41
+ * @param namespace Top-level namespace key (the config filename, e.g. `app`).
42
+ * @param values The namespace's config object.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * config.load('app', { name: 'Zerotal', env: 'production' });
47
+ * config.load('database', { url: 'postgres://localhost/mydb' });
48
+ * ```
49
+ */
50
+ load(namespace: string, values: Record<string, unknown>): void {
51
+ this._data[namespace] = values;
52
+ }
53
+
54
+ // ── Access ────────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Get a config value by dot-notation path, returning `fallback` when the path
58
+ * resolves to `null`/`undefined` or does not exist.
59
+ *
60
+ * @param path Dot-notation path, e.g. `app.name`.
61
+ * @param fallback Value returned when the path is missing.
62
+ * @returns The stored value, or `fallback` when unset.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * config.get('app.name') // 'Zerotal'
67
+ * config.get('database.connections.postgres') // { url: '...' }
68
+ * config.get('app.missing', 'default') // 'default'
69
+ * ```
70
+ */
71
+ get<T = unknown>(path: string, fallback?: T): T {
72
+ const keys = path.split(".");
73
+ let current: unknown = this._data;
74
+
75
+ for (const key of keys) {
76
+ if (current == null || typeof current !== "object") {
77
+ return (fallback ?? undefined) as T;
78
+ }
79
+ current = (current as Record<string, unknown>)[key];
80
+ }
81
+
82
+ return (current ?? fallback ?? undefined) as T;
83
+ }
84
+
85
+ /**
86
+ * Get a config value, throwing when it is `null` or `undefined`. Use for
87
+ * required config that the app cannot start without.
88
+ *
89
+ * @param path Dot-notation path, e.g. `app.key`.
90
+ * @returns The stored value (guaranteed present).
91
+ * @throws {ConfigError} When the path resolves to `undefined` or `null`.
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * config.require('app.key') // throws if APP_KEY is not set
96
+ * ```
97
+ */
98
+ require<T = unknown>(path: string): T {
99
+ const value = this.get<T>(path);
100
+ if (value === undefined || value === null) {
101
+ throw new ConfigError(
102
+ `Required config value "${path}" is not set. ` +
103
+ `Check your .env file and config/${path.split(".")[0]}.ts.`,
104
+ );
105
+ }
106
+ return value;
107
+ }
108
+
109
+ /**
110
+ * Set a config value at a dot-path at runtime, creating intermediate objects
111
+ * as needed. Intended for tests or dynamic overrides; in production prefer
112
+ * loading config files via {@link ConfigManager.load}.
113
+ *
114
+ * @param path Dot-notation path to write, e.g. `app.debug`.
115
+ * @param value The value to store.
116
+ */
117
+ set(path: string, value: unknown): void {
118
+ const keys = path.split(".");
119
+ // `config.set("__proto__.isAdmin", true)` used to walk onto Object.prototype and pollute
120
+ // every object in the process. deepMerge already guards exactly these keys
121
+ // (support/deepMerge.ts _UNSAFE_KEYS); ConfigManager did not.
122
+ if (keys.some((key) => _UNSAFE_CONFIG_KEYS.has(key))) {
123
+ throw new ConfigError(
124
+ `Refusing to set config path "${path}": it walks through a prototype key ` +
125
+ `(__proto__, constructor, prototype), which would mutate Object.prototype.`,
126
+ );
127
+ }
128
+ let current = this._data;
129
+
130
+ for (let index = 0; index < keys.length - 1; index++) {
131
+ const key = keys[index]!;
132
+ if (typeof current[key] !== "object" || current[key] == null) {
133
+ current[key] = {};
134
+ }
135
+ current = current[key] as Record<string, unknown>;
136
+ }
137
+
138
+ current[keys[keys.length - 1]!] = value;
139
+ }
140
+
141
+ /**
142
+ * Check whether a config path has a value set (i.e. `get(path)` is not
143
+ * `undefined`).
144
+ *
145
+ * @param path Dot-notation path to test.
146
+ * @returns `true` when a value exists at the path.
147
+ */
148
+ has(path: string): boolean {
149
+ return this.get(path) !== undefined;
150
+ }
151
+
152
+ /**
153
+ * Return a shallow copy of all loaded config namespaces. Useful for
154
+ * debugging.
155
+ *
156
+ * @returns A new object mapping each namespace to its config value. The copy
157
+ * is shallow — nested namespace objects are shared, not cloned.
158
+ */
159
+ all(): Record<string, unknown> {
160
+ return { ...this._data };
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Keys that must never appear in a `set()` path. Writing through any of them reaches
166
+ * `Object.prototype` and pollutes every object in the process. Mirrors the guard in
167
+ * `support/deepMerge.ts`.
168
+ */
169
+ const _UNSAFE_CONFIG_KEYS = new Set(["__proto__", "constructor", "prototype"]);
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The `@zerotal/core/config` subpath — the runtime configuration system.
3
+ *
4
+ * Config lives in a set of `config/*.ts` files, each default-exporting one
5
+ * namespace object (`config/app.ts` → the `app` namespace). {@link configLoader}
6
+ * discovers and loads them synchronously into a {@link ConfigLoader}, which the
7
+ * application hands to a {@link ConfigManager} — the store that serves
8
+ * dot-path reads/writes behind the `Config` facade and the `config()` helper.
9
+ * {@link AppConfig} builds the framework's own `app` namespace with defaults,
10
+ * and the {@link ConfigRegistry} interface makes dot-path access type-checked.
11
+ *
12
+ * The everyday `config()` helper lives on the kernel barrel (`@zerotal/core`);
13
+ * the symbols here are the lower-level classes and types used when defining or
14
+ * wiring configuration.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { configLoader } from "@zerotal/core/config";
19
+ * import { Application } from "@zerotal/core";
20
+ *
21
+ * // In the entry script — load and validate config, then boot the app.
22
+ * const config = configLoader("./config").validate();
23
+ * const app = Application.create({ config });
24
+ * ```
25
+ *
26
+ * @packageDocumentation
27
+ */
28
+ export { ConfigManager } from "./ConfigManager.ts";
29
+ export { configLoader, ConfigLoader } from "./ConfigLoader.ts";
30
+ export type { ConfigMap } from "./ConfigLoader.ts";
31
+ export { AppConfig } from "./AppConfig.ts";
32
+ export type {
33
+ AppConfigShape,
34
+ AppTlsConfig,
35
+ ConventionsConfig,
36
+ AppAssetsConfig,
37
+ } from "./AppConfig.ts";
38
+ export type { ConfigRegistry, ConfigPath, ConfigValue } from "./registry.ts";
39
+ export { ConfigValidationError } from "./validation.ts";
40
+ export type {
41
+ ConfigIssue,
42
+ ConfigIssueLevel,
43
+ ConfigValidator,
44
+ ConfigValidationContext,
45
+ RegisteredConfigValidator,
46
+ } from "./validation.ts";
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The typed config registry and the dot-path types built from it. `ConfigRegistry`
3
+ * is the config analogue of `ContainerBindings`: every config-owning package
4
+ * augments it via declaration merging so `config("app.name")` is type-checked and
5
+ * auto-completed by namespace. Core owns the `app` and `health` namespaces.
6
+ */
7
+
8
+ // Augmenting packages extend the registry like so:
9
+ //
10
+ // declare module "@zerotal/core" {
11
+ // interface ConfigRegistry { cache: CacheConfigShape }
12
+ // }
13
+
14
+ import type { AppConfigShape } from "./AppConfig.ts";
15
+ import type { HealthConfigShape } from "../health/Health.ts";
16
+
17
+ /**
18
+ * Maps each config namespace to its shape. Augment it from any package that owns a
19
+ * `config/<namespace>.ts` file. Unaugmented namespaces fall back to the untyped
20
+ * `config(path: string)` overload, so dynamic access still compiles.
21
+ */
22
+ export interface ConfigRegistry {
23
+ app: AppConfigShape;
24
+ health: HealthConfigShape;
25
+ lock: import("../lock/config.ts").LockConfigShape;
26
+ logging: import("../logger/config.ts").LoggingConfigShape;
27
+ }
28
+
29
+ // ── Dot-path machinery ────────────────────────────────────────────────────────
30
+
31
+ /** True only for plain nested objects — not arrays or functions (which shouldn't expand). */
32
+ type IsNested<T> = T extends readonly unknown[]
33
+ ? false
34
+ : // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ T extends (...args: any[]) => unknown
36
+ ? false
37
+ : T extends object
38
+ ? true
39
+ : false;
40
+
41
+ /** Union of every dot-path through `T` (e.g. `'app' | 'app.name' | 'cache.ttl'`). */
42
+ type Leaves<T> = {
43
+ [K in keyof T & string]: IsNested<T[K]> extends true ? `${K}` | `${K}.${Leaves<T[K]>}` : `${K}`;
44
+ }[keyof T & string];
45
+
46
+ /** The value type at dot-path `P` within `T`. Unknown paths resolve to `unknown`. */
47
+ type ValueAt<T, P extends string> = P extends `${infer Head}.${infer Rest}`
48
+ ? Head extends keyof T
49
+ ? ValueAt<T[Head], Rest>
50
+ : unknown
51
+ : P extends keyof T
52
+ ? T[P]
53
+ : unknown;
54
+
55
+ /** Every valid dot-path string across the registered config namespaces. */
56
+ export type ConfigPath = Leaves<ConfigRegistry>;
57
+
58
+ /** The value type stored at a given config dot-path. */
59
+ export type ConfigValue<P extends string> = ValueAt<ConfigRegistry, P>;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Boot-time config validation with production-refusal semantics.
3
+ *
4
+ * Each config namespace already has a typed shape in {@link ConfigRegistry}; a
5
+ * package can attach a validator to its namespace (via
6
+ * `app.registerConfigValidator(namespace, fn)`) that inspects the loaded value
7
+ * and reports problems. The boot sequence runs every validator once, after
8
+ * providers register and before they boot:
9
+ *
10
+ * - in a **production-like** deployment, any `error`-level issue **refuses boot**
11
+ * — aggregated into a {@link ConfigValidationError} that names each culprit;
12
+ * - everywhere else, issues are logged as warnings and boot continues.
13
+ *
14
+ * `warning`-level issues are always advisory (logged, never fatal). This is the
15
+ * "delight in development, refusal in production" posture: a placeholder secret
16
+ * or an insecure cookie flag is a nuisance locally and a hard stop in prod.
17
+ */
18
+ import type { ConfigManager } from "./ConfigManager.ts";
19
+ import { ZerotalError } from "../errors/ZerotalError.ts";
20
+
21
+ /** Severity of a config problem. `error` refuses boot in production; `warning` only ever warns. */
22
+ export type ConfigIssueLevel = "error" | "warning";
23
+
24
+ /** A single problem a config validator reports. */
25
+ export interface ConfigIssue {
26
+ /** `error` — fatal in production. `warning` — always advisory. */
27
+ level: ConfigIssueLevel;
28
+ /** Human-readable description plus the remedy. */
29
+ message: string;
30
+ }
31
+
32
+ /** Context handed to a config validator. */
33
+ export interface ConfigValidationContext {
34
+ /** The namespace being validated (e.g. `"session"`). */
35
+ namespace: string;
36
+ /** True in a production-like deployment — the only case where `error` issues are fatal. */
37
+ isProduction: boolean;
38
+ /** The full config store, for cross-namespace reads (e.g. checking `app.key`). */
39
+ config: ConfigManager;
40
+ }
41
+
42
+ /**
43
+ * Validates one config namespace's loaded value and returns any problems found.
44
+ *
45
+ * The value arrives as `unknown` — config is a dynamic dot-path store, so a
46
+ * validator narrows to its namespace's shape itself (usually with a single cast
47
+ * to the package's `*ConfigShape`). Return an empty array (or nothing) when the
48
+ * namespace is well-formed.
49
+ */
50
+ export type ConfigValidator = (
51
+ value: unknown,
52
+ ctx: ConfigValidationContext,
53
+ ) => ConfigIssue[] | void;
54
+
55
+ /** A validator paired with the namespace it guards, as held on the application. */
56
+ export interface RegisteredConfigValidator {
57
+ namespace: string;
58
+ validate: ConfigValidator;
59
+ }
60
+
61
+ /**
62
+ * Thrown when config validation refuses a production boot. Lists every fatal
63
+ * issue by namespace, so a startup failure names exactly what to fix.
64
+ *
65
+ * @category Errors
66
+ */
67
+ export class ConfigValidationError extends ZerotalError {
68
+ constructor(public readonly issues: Array<{ namespace: string; message: string }>) {
69
+ super(ConfigValidationError._format(issues), "E_CONFIG_VALIDATION_FAILED", 500, { issues });
70
+ }
71
+
72
+ private static _format(issues: Array<{ namespace: string; message: string }>): string {
73
+ const lines = issues.map((i) => ` • config(${i.namespace}): ${i.message}`);
74
+ const plural = issues.length === 1 ? "" : "s";
75
+ return (
76
+ `[Zerotal] Refusing to boot: ${issues.length} insecure or invalid config value${plural} ` +
77
+ `in this production deployment:\n${lines.join("\n")}`
78
+ );
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Run every registered validator against the loaded config.
84
+ *
85
+ * In a production-like deployment, each `error`-level issue is collected and, if
86
+ * any exist, an aggregated {@link ConfigValidationError} is thrown (boot
87
+ * refused). Otherwise — and for every `warning`-level issue in any environment —
88
+ * the issue is logged via `warn` and boot continues.
89
+ *
90
+ * @param validators - The namespace/validator pairs to run.
91
+ * @param config - The loaded configuration store.
92
+ * @param isProduction - Whether this is a production-like deployment.
93
+ * @param warn - Sink for advisory messages (defaults to `console.warn`).
94
+ * @throws {ConfigValidationError} in production when one or more `error` issues are found.
95
+ */
96
+ export function runConfigValidators(
97
+ validators: readonly RegisteredConfigValidator[],
98
+ config: ConfigManager,
99
+ isProduction: boolean,
100
+ warn: (message: string) => void = console.warn,
101
+ ): void {
102
+ const fatal: Array<{ namespace: string; message: string }> = [];
103
+
104
+ for (const { namespace, validate } of validators) {
105
+ const value = config.get(namespace);
106
+ const issues = validate(value, { namespace, isProduction, config }) ?? [];
107
+ for (const issue of issues) {
108
+ if (issue.level === "error" && isProduction) {
109
+ fatal.push({ namespace, message: issue.message });
110
+ } else {
111
+ warn(`[Zerotal] config(${namespace}): ${issue.message}`);
112
+ }
113
+ }
114
+ }
115
+
116
+ if (fatal.length > 0) throw new ConfigValidationError(fatal);
117
+ }