@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,131 @@
1
+ /**
2
+ * The convention concerns that core itself owns (events, listeners, jobs,
3
+ * validators). ORM and auth contribute their own descriptors via
4
+ * `app.registerConcern(...)` from their providers.
5
+ */
6
+ import type { ConcernDescriptor } from "./ConventionLoader.ts";
7
+ import type { Emitter } from "../events/Emitter.ts";
8
+
9
+ /**
10
+ * `app/events/` — event classes are plain classes that need no registration; importing them
11
+ * makes them available and bundled (and lets a generated manifest reference them). Runs early
12
+ * so any module-level side effects are in place before listeners bind.
13
+ */
14
+ export const eventsConcern: ConcernDescriptor = {
15
+ name: "events",
16
+ order: 5,
17
+ dir: "app/events",
18
+ register() {
19
+ /* importing the file is the whole effect */
20
+ },
21
+ };
22
+
23
+ /**
24
+ * `app/services/` — service classes auto-register with the container so
25
+ * `App.make(MyService)` resolves them with the declared lifetime.
26
+ *
27
+ * A class opts into a non-default lifetime via a `static lifetime` flag:
28
+ *
29
+ * @example
30
+ * @inject(Auth)
31
+ * export class UsersService {
32
+ * static lifetime = "singleton"; // "singleton" | "scoped" | "transient"
33
+ * constructor(private auth: AuthManager) {}
34
+ * }
35
+ *
36
+ * - `singleton` / `scoped` → bound with a factory that auto-wires a fresh
37
+ * instance via `container.build()`.
38
+ * - `transient` or no flag → nothing is registered; the container already
39
+ * auto-wires unregistered classes on demand (so resolution still works, it is
40
+ * just a new instance each time). Only classes with an explicit lifetime are
41
+ * touched, so non-service exports (types, helpers) are ignored.
42
+ *
43
+ * Runs early (before listeners) so listeners and other concerns can depend on
44
+ * services.
45
+ */
46
+ export const servicesConcern: ConcernDescriptor = {
47
+ name: "services",
48
+ order: 10,
49
+ dir: "app/services",
50
+ register(serviceModule, ctx) {
51
+ const container = ctx.app.container;
52
+ for (const exported of Object.values(serviceModule)) {
53
+ if (typeof exported !== "function") continue;
54
+ const lifetime = (exported as { lifetime?: unknown }).lifetime;
55
+ const ctor = exported as new (...args: unknown[]) => unknown;
56
+ if (lifetime === "singleton") {
57
+ container.singleton(ctor, (resolver) => resolver.build(ctor));
58
+ } else if (lifetime === "scoped") {
59
+ container.scoped(ctor, (resolver) => resolver.build(ctor));
60
+ }
61
+ // "transient" / undefined: rely on the container's on-demand auto-wiring.
62
+ }
63
+ },
64
+ };
65
+
66
+ /**
67
+ * `app/listeners/` — a listener declares the event(s) it handles via `static listens`
68
+ * (a single event class or an array). The loader binds it on the app emitter.
69
+ *
70
+ * @example
71
+ * export class SendWelcomeEmail {
72
+ * static listens = UserRegistered;
73
+ * async handle(e: UserRegistered) { ... }
74
+ * }
75
+ */
76
+ export const listenersConcern: ConcernDescriptor = {
77
+ name: "listeners",
78
+ order: 40,
79
+ dir: "app/listeners",
80
+ register(listenerModule, ctx) {
81
+ const emitter = ctx.resolve<Emitter>("events");
82
+ if (!emitter) return;
83
+ for (const exported of Object.values(listenerModule)) {
84
+ if (typeof exported !== "function") continue;
85
+ const listens = (exported as { listens?: unknown }).listens;
86
+ if (!listens) continue;
87
+ const events = Array.isArray(listens) ? listens : [listens];
88
+ for (const event of events) {
89
+ if (typeof event === "function") {
90
+ emitter.on(event as never, exported as never);
91
+ }
92
+ }
93
+ }
94
+ },
95
+ };
96
+
97
+ /**
98
+ * `app/jobs/` — queue job classes self-register via `JobRegistry.register()` at the bottom of
99
+ * each file, so importing them is the whole effect (mirrors the generated jobs barrel, but for
100
+ * the web/test/console runtimes too — not just the worker).
101
+ */
102
+ export const jobsConcern: ConcernDescriptor = {
103
+ name: "jobs",
104
+ order: 50,
105
+ dir: "app/jobs",
106
+ register() {
107
+ /* importing the file triggers its JobRegistry.register() side effect */
108
+ },
109
+ };
110
+
111
+ /**
112
+ * `app/validators/` — custom validation rules / FormRequest helpers. These register themselves
113
+ * at module load (e.g. `Validator.extend(...)`), so importing the file is the effect.
114
+ */
115
+ export const validatorsConcern: ConcernDescriptor = {
116
+ name: "validators",
117
+ order: 60,
118
+ dir: "app/validators",
119
+ register() {
120
+ /* importing the file runs its module-level registration */
121
+ },
122
+ };
123
+
124
+ /** The full set of core-owned convention concerns, in registration order. */
125
+ export const builtinConcerns: ConcernDescriptor[] = [
126
+ eventsConcern,
127
+ servicesConcern,
128
+ listenersConcern,
129
+ jobsConcern,
130
+ validatorsConcern,
131
+ ];
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Symmetric encryption keyed by `APP_KEY`.
3
+ *
4
+ * Uses AES-256-GCM (authenticated encryption): tampered or truncated payloads
5
+ * fail to decrypt with a `DecryptionError` rather than returning garbage. The
6
+ * key is derived from `APP_KEY` (raw or `base64:` prefixed) via SHA-256, so any
7
+ * key length works; generate one with `zerotal key:generate`.
8
+ *
9
+ * @example
10
+ * import { Crypt } from '@zerotal/core';
11
+ * const token = Crypt.encryptString('secret'); // opaque base64
12
+ * Crypt.decryptString(token); // 'secret'
13
+ * const blob = Crypt.encrypt({ userId: 7 }); // any JSON-serializable value
14
+ * Crypt.decrypt<{ userId: number }>(blob).userId; // 7
15
+ */
16
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
17
+ import { ZerotalError } from "../errors/ZerotalError.ts";
18
+
19
+ /** Raised when `Crypt` is used without an `APP_KEY` configured. */
20
+ export class CryptKeyMissingError extends ZerotalError {
21
+ constructor() {
22
+ super(
23
+ "[Zerotal] Crypt requires APP_KEY. Generate one with `zerotal key:generate`.",
24
+ "E_CRYPT_NO_KEY",
25
+ 500,
26
+ );
27
+ }
28
+ }
29
+
30
+ /** Raised when a payload cannot be decrypted — wrong key or tampered data. */
31
+ export class DecryptionError extends ZerotalError {
32
+ constructor() {
33
+ super(
34
+ "[Zerotal] Could not decrypt the payload — wrong key or tampered data.",
35
+ "E_DECRYPT",
36
+ 500,
37
+ );
38
+ }
39
+ }
40
+
41
+ const IV_BYTES = 12;
42
+ const TAG_BYTES = 16;
43
+
44
+ class CryptManager {
45
+ private _key: Buffer | null = null;
46
+
47
+ /** Override the key explicitly (otherwise derived from `APP_KEY`). */
48
+ setKey(key: string): void {
49
+ this._key = this._derive(key);
50
+ }
51
+
52
+ private _derive(key: string): Buffer {
53
+ const raw = key.startsWith("base64:")
54
+ ? Buffer.from(key.slice(7), "base64")
55
+ : Buffer.from(key, "utf8");
56
+ return new Bun.CryptoHasher("sha256").update(raw).digest(); // exactly 32 bytes
57
+ }
58
+
59
+ private _resolveKey(): Buffer {
60
+ if (this._key) return this._key;
61
+ const appKey = Bun.env["APP_KEY"];
62
+ if (!appKey) throw new CryptKeyMissingError();
63
+ this._key = this._derive(appKey);
64
+ return this._key;
65
+ }
66
+
67
+ /**
68
+ * Encrypt a UTF-8 string. Returns an opaque base64 payload (iv+tag+ciphertext).
69
+ *
70
+ * @throws {CryptKeyMissingError} When no key is set and `APP_KEY` is absent.
71
+ */
72
+ encryptString(plain: string): string {
73
+ const iv = randomBytes(IV_BYTES);
74
+ const cipher = createCipheriv("aes-256-gcm", this._resolveKey(), iv);
75
+ const ciphertext = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
76
+ return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64");
77
+ }
78
+
79
+ /**
80
+ * Decrypt a payload produced by {@link encryptString}.
81
+ *
82
+ * @throws {DecryptionError} When the payload is malformed, truncated, tampered
83
+ * with, or decrypted with the wrong key.
84
+ * @throws {CryptKeyMissingError} When no key is set and `APP_KEY` is absent.
85
+ */
86
+ decryptString(payload: string): string {
87
+ let data: Buffer;
88
+ try {
89
+ data = Buffer.from(payload, "base64");
90
+ } catch {
91
+ throw new DecryptionError();
92
+ }
93
+ if (data.length < IV_BYTES + TAG_BYTES) throw new DecryptionError();
94
+ const iv = data.subarray(0, IV_BYTES);
95
+ const tag = data.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
96
+ const ciphertext = data.subarray(IV_BYTES + TAG_BYTES);
97
+ try {
98
+ const decipher = createDecipheriv("aes-256-gcm", this._resolveKey(), iv);
99
+ decipher.setAuthTag(tag);
100
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
101
+ } catch {
102
+ throw new DecryptionError();
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Encrypt any JSON-serializable value (serialized with `JSON.stringify`).
108
+ *
109
+ * @throws {CryptKeyMissingError} When no key is set and `APP_KEY` is absent.
110
+ */
111
+ encrypt(value: unknown): string {
112
+ return this.encryptString(JSON.stringify(value));
113
+ }
114
+
115
+ /**
116
+ * Decrypt a value produced by {@link encrypt}, parsing it back with `JSON.parse`.
117
+ *
118
+ * @typeParam T - Expected shape of the decrypted value.
119
+ * @throws {DecryptionError} When the payload cannot be decrypted.
120
+ * @throws {CryptKeyMissingError} When no key is set and `APP_KEY` is absent.
121
+ */
122
+ decrypt<T = unknown>(payload: string): T {
123
+ return JSON.parse(this.decryptString(payload)) as T;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * App-key symmetric encryption facade (AES-256-GCM).
129
+ *
130
+ * @example
131
+ * ```ts
132
+ * import { Crypt } from "@zerotal/core/security";
133
+ *
134
+ * const token = Crypt.encryptString("secret");
135
+ * Crypt.decryptString(token); // "secret"
136
+ *
137
+ * const blob = Crypt.encrypt({ userId: 7 });
138
+ * Crypt.decrypt<{ userId: number }>(blob).userId; // 7
139
+ * ```
140
+ */
141
+ export const Crypt = new CryptManager();
@@ -0,0 +1,96 @@
1
+ import { safeEqual, hmacHex } from "../support/crypto.ts";
2
+
3
+ /**
4
+ * URLSigner — generate and verify HMAC-signed URLs.
5
+ *
6
+ * Signed URLs carry a `signature` and `expires` query parameter. The
7
+ * signature is HMAC-SHA256(secret, canonicalPayload) where the payload is
8
+ * the full URL (without the signature param) sorted by key so that the
9
+ * order of other query parameters doesn't matter.
10
+ *
11
+ * For app-key-keyed signing without managing a secret, prefer the {@link Url}
12
+ * facade (`Url.sign` / `Url.verify`), which derives the secret from `APP_KEY`.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * const signer = new URLSigner(process.env.APP_KEY!);
17
+ *
18
+ * // Generate a link that expires in 15 minutes:
19
+ * const url = signer.sign("https://app.example.com/auth/verify", {
20
+ * email: "user@example.com",
21
+ * }, 15);
22
+ *
23
+ * // Verify later:
24
+ * const ok = signer.verify(url); // true while unexpired and untampered
25
+ * ```
26
+ */
27
+ export class URLSigner {
28
+ /**
29
+ * @param secret - HMAC signing secret (e.g. `APP_KEY`); must be non-empty.
30
+ * @throws {Error} When `secret` is empty.
31
+ */
32
+ constructor(private readonly secret: string) {
33
+ if (!secret) throw new Error("[URLSigner] secret must be a non-empty string");
34
+ }
35
+
36
+ /**
37
+ * Build a signed URL.
38
+ *
39
+ * @param base The base URL (scheme + host + path).
40
+ * @param params Extra query parameters to include (will be encoded).
41
+ * @param expiresInMinutes Minutes until the link expires. Default 60.
42
+ * @returns The URL with `expires` and `signature` query parameters appended.
43
+ */
44
+ sign(base: string, params: Record<string, string> = {}, expiresInMinutes: number = 60): string {
45
+ const expiresAt = Math.floor(Date.now() / 1000) + expiresInMinutes * 60;
46
+
47
+ const url = new URL(base);
48
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
49
+ url.searchParams.set("expires", String(expiresAt));
50
+
51
+ const signature = this._hmac(this._canonical(url));
52
+ url.searchParams.set("signature", signature);
53
+ return url.toString();
54
+ }
55
+
56
+ /**
57
+ * Verify a signed URL.
58
+ * Returns `true` if the signature is valid and the link has not expired.
59
+ */
60
+ verify(signedUrl: string): boolean {
61
+ try {
62
+ const url = new URL(signedUrl);
63
+
64
+ const signature = url.searchParams.get("signature");
65
+ const expires = url.searchParams.get("expires");
66
+ if (!signature || !expires) return false;
67
+
68
+ // Check expiry first (cheap)
69
+ if (Math.floor(Date.now() / 1000) > Number(expires)) return false;
70
+
71
+ // Rebuild the URL without the signature to get the canonical payload
72
+ const clone = new URL(signedUrl);
73
+ clone.searchParams.delete("signature");
74
+ const expected = this._hmac(this._canonical(clone));
75
+
76
+ return safeEqual(signature, expected);
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Canonical form: sort all params alphabetically so order doesn't matter.
84
+ */
85
+ private _canonical(url: URL): string {
86
+ const clone = new URL(url.toString());
87
+ const sorted = [...clone.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b));
88
+ clone.search = "";
89
+ for (const [k, v] of sorted) clone.searchParams.append(k, v);
90
+ return clone.toString();
91
+ }
92
+
93
+ private _hmac(payload: string): string {
94
+ return hmacHex(payload, this.secret);
95
+ }
96
+ }