@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,213 @@
1
+ /**
2
+ * Content negotiation: detects whether a request is a browser, API, or CLI
3
+ * client and dispatches to the matching handler, giving each branch a context
4
+ * tailored to that channel (session helpers for web, ANSI output for CLI, …).
5
+ */
6
+ import type { HttpContext } from "../pipeline/HttpContext.ts";
7
+
8
+ // ── ANSI helpers ───────────────────────────────────────────────────────────────
9
+
10
+ const ANSI: Record<string, string> = {
11
+ reset: "\x1b[0m",
12
+ red: "\x1b[31m",
13
+ green: "\x1b[32m",
14
+ yellow: "\x1b[33m",
15
+ blue: "\x1b[34m",
16
+ cyan: "\x1b[36m",
17
+ dim: "\x1b[2m",
18
+ };
19
+
20
+ /** A supported ANSI text color for CLI output. */
21
+ export type AnsiColor = "red" | "green" | "yellow" | "blue" | "cyan" | "dim";
22
+
23
+ // ── Channel detection ──────────────────────────────────────────────────────────
24
+
25
+ /** The client channel a request arrived on. */
26
+ export type Channel = "web" | "json" | "cli";
27
+
28
+ /**
29
+ * Detect which channel the current request is coming from.
30
+ *
31
+ * Priority:
32
+ * 1. `X-Zerotal-Channel: cli` — set by the Zerotal CLI binary
33
+ * 2. CLI user-agent patterns: curl, wget, HTTPie
34
+ * 3. `Accept: application/json` — API / SPA client
35
+ * 4. Default: web browser
36
+ */
37
+ export function detectChannel(ctx: HttpContext): Channel {
38
+ if (ctx.header("X-Zerotal-Channel") === "cli") return "cli";
39
+
40
+ const userAgent = ctx.header("User-Agent") ?? "";
41
+ if (/^(curl|wget|httpie)\//i.test(userAgent)) return "cli";
42
+
43
+ if (ctx.wantsJson()) return "json";
44
+
45
+ return "web";
46
+ }
47
+
48
+ // ── Channel-specific context interfaces ───────────────────────────────────────
49
+
50
+ /**
51
+ * Context passed to the `web` branch of `negotiate()`.
52
+ *
53
+ * Exposes session-aware helpers (flash, redirect-back). Session access is only
54
+ * safe here because the web channel guarantees a Cookie-based session is present.
55
+ */
56
+ export interface WebContext {
57
+ readonly ctx: HttpContext;
58
+ redirect(url: string, status?: 301 | 302 | 303 | 307 | 308): void;
59
+ back(status?: 301 | 302 | 303 | 307 | 308): void;
60
+ view(markup: string | { toString(): string }, status?: number): void;
61
+ html(markup: string | { toString(): string }, status?: number): void;
62
+ json(data: unknown, status?: number): void;
63
+ flash(key: string, value: unknown): void;
64
+ flashed<T = unknown>(key: string): T | undefined;
65
+ }
66
+
67
+ /**
68
+ * Context passed to the `json` branch of `negotiate()`.
69
+ *
70
+ * Stateless — no session, no cookies. Caller is an API client or SPA.
71
+ */
72
+ export interface ApiContext {
73
+ readonly ctx: HttpContext;
74
+ json(data: unknown, status?: number): void;
75
+ bearerToken(): string | null;
76
+ }
77
+
78
+ /**
79
+ * Context passed to the `cli` branch of `negotiate()`.
80
+ *
81
+ * Terminal-oriented. Exposes ANSI helpers and plain-text HTTP responses.
82
+ * Session and redirect helpers are intentionally absent.
83
+ */
84
+ export interface CliContext {
85
+ readonly ctx: HttpContext;
86
+ /** Set a plain-text HTTP response with explicit status. */
87
+ text(content: string, status?: number): void;
88
+ /** Write to stdout without affecting the HTTP response. */
89
+ write(content: string): void;
90
+ writeln(content: string): void;
91
+ /** Write a colored line to stdout without affecting the HTTP response. */
92
+ line(content: string, color?: AnsiColor): void;
93
+ /** Set a 500 plain-text response with ANSI red message. */
94
+ error(message: string): void;
95
+ /** Set a 200 plain-text response with ANSI green message. */
96
+ success(message: string): void;
97
+ /** Set a 200 plain-text response with ANSI yellow message. */
98
+ warn(message: string): void;
99
+ /** Set a 200 plain-text response with ANSI blue message. */
100
+ info(message: string): void;
101
+ }
102
+
103
+ // ── Context factories ─────────────────────────────────────────────────────────
104
+
105
+ function makeWebContext(ctx: HttpContext): WebContext {
106
+ return {
107
+ ctx,
108
+ redirect: (url, status) => ctx.redirect(url, status),
109
+ back: (status) => ctx.back(status),
110
+ view: (markup, status) => ctx.view(markup, status),
111
+ html: (markup, status) => ctx.html(markup, status),
112
+ json: (data, status) => ctx.json(data, status),
113
+ flash: (key, value) => ctx.flash(key, value),
114
+ flashed: (key) => ctx.flashed(key),
115
+ };
116
+ }
117
+
118
+ function makeApiContext(ctx: HttpContext): ApiContext {
119
+ return {
120
+ ctx,
121
+ json: (data, status) => ctx.json(data, status),
122
+ bearerToken: () => ctx.bearerToken(),
123
+ };
124
+ }
125
+
126
+ function makeCliContext(ctx: HttpContext): CliContext {
127
+ const colorize = (text: string, color: AnsiColor): string =>
128
+ `${ANSI[color]}${text}${ANSI["reset"]}`;
129
+
130
+ const textResponse = (content: string, status = 200): void => {
131
+ ctx.response = new Response(content + "\n", {
132
+ status,
133
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
134
+ });
135
+ };
136
+
137
+ return {
138
+ ctx,
139
+ text: textResponse,
140
+ write: (content) => {
141
+ process.stdout.write(content);
142
+ },
143
+ writeln: (content) => {
144
+ process.stdout.write(content + "\n");
145
+ },
146
+ line: (content, color) => {
147
+ process.stdout.write((color ? colorize(content, color) : content) + "\n");
148
+ },
149
+ error: (message) => textResponse(colorize(message, "red"), 500),
150
+ success: (message) => textResponse(colorize(message, "green"), 200),
151
+ warn: (message) => textResponse(colorize(message, "yellow"), 200),
152
+ info: (message) => textResponse(colorize(message, "blue"), 200),
153
+ };
154
+ }
155
+
156
+ // ── negotiate() ───────────────────────────────────────────────────────────────
157
+
158
+ /** The per-channel handlers passed to `negotiate()`; `cli` falls back to `json` when omitted. */
159
+ export interface NegotiateMap<TWeb = void, TJson = void, TCli = void> {
160
+ web: (web: WebContext) => TWeb | Promise<TWeb>;
161
+ json: (api: ApiContext) => TJson | Promise<TJson>;
162
+ cli?: (cli: CliContext) => TCli | Promise<TCli>;
163
+ }
164
+
165
+ /**
166
+ * Polymorphic execution primitive — routes handling to the channel-appropriate
167
+ * closure based on how the request arrived.
168
+ *
169
+ * Works in controllers to return channel-appropriate responses:
170
+ * @example
171
+ * async store(ctx: HttpContext) {
172
+ * const post = await Post.create(await ctx.body());
173
+ * return negotiate(ctx)({
174
+ * web: (w) => { w.flash('success', 'Post created!'); w.redirect('/posts'); },
175
+ * json: (a) => a.json({ data: post }, 201),
176
+ * cli: (c) => c.success(`Post #${post.id} created.`),
177
+ * });
178
+ * }
179
+ *
180
+ * Works in middleware to block, redirect, or pass through per channel:
181
+ * @example
182
+ * async handle(ctx, next) {
183
+ * if (!ctx.user) {
184
+ * await negotiate(ctx)({
185
+ * web: (w) => w.redirect('/login'),
186
+ * json: (a) => a.json({ message: 'Unauthenticated.' }, 401),
187
+ * cli: (c) => c.error('Authentication required.'),
188
+ * });
189
+ * return ctx; // short-circuit — response is already set
190
+ * }
191
+ * return next(ctx);
192
+ * }
193
+ *
194
+ * When no `cli` handler is provided, CLI requests fall back to the `json` branch
195
+ * (curl / wget users expect structured output).
196
+ */
197
+ export function negotiate(ctx: HttpContext) {
198
+ const channel = detectChannel(ctx);
199
+
200
+ return async function <TWeb = void, TJson = void, TCli = void>(
201
+ map: NegotiateMap<TWeb, TJson, TCli>,
202
+ ): Promise<TWeb | TJson | TCli | void> {
203
+ if (channel === "cli" && map.cli) {
204
+ return map.cli(makeCliContext(ctx));
205
+ }
206
+
207
+ if (channel === "json" || channel === "cli") {
208
+ return map.json(makeApiContext(ctx));
209
+ }
210
+
211
+ return map.web(makeWebContext(ctx));
212
+ };
213
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Origin checking for endpoints that bypass the normal middleware pipeline.
3
+ *
4
+ * Two request shapes in the framework are not protected by `CsrfMiddleware`:
5
+ *
6
+ * - **WebSocket upgrades.** The handshake is exempt from the same-origin policy but still
7
+ * carries cookies, so `evil.com` opening `wss://app/...` gets a fully authenticated socket.
8
+ * That is cross-site WebSocket hijacking, and an `Origin` check is the standard defence —
9
+ * browsers always send the header on a WS handshake and script cannot forge it.
10
+ *
11
+ * - **Raw routes** registered via `Router.raw()`, which are stored outside the pipeline.
12
+ * Flow's `/__flow/http` action fallback is one: a cross-origin `fetch` with
13
+ * `credentials: "include"` and the default `text/plain` content type is a CORS-*simple*
14
+ * request, so there is no preflight to stop it and no CSRF middleware in its path.
15
+ *
16
+ * The check is deliberately conservative about non-browser clients: a request with no `Origin`
17
+ * header is allowed, because native/CLI clients do not send one and are not subject to
18
+ * cross-site request forgery in the first place. Anything that *does* declare an origin must
19
+ * declare one we recognise.
20
+ */
21
+
22
+ /**
23
+ * Whether a request's `Origin` header is acceptable for a credentialed, pipeline-bypassing
24
+ * endpoint.
25
+ *
26
+ * @param request - The incoming request. Its own URL supplies the server origin.
27
+ * @param allowedOrigins - Additional origins to accept, for deployments where the browser
28
+ * app is served from a different host than the API (e.g. an SPA on `app.example.com`
29
+ * talking to `api.example.com`). Compared exactly — no wildcards, no suffix matching,
30
+ * because `endsWith(".example.com")` also matches `evil-example.com`.
31
+ * @returns `true` when the request may proceed.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * if (!isAllowedOrigin(req, config('app.wsAllowedOrigins'))) {
36
+ * return new Response('Forbidden origin', { status: 403 });
37
+ * }
38
+ * ```
39
+ */
40
+ export function isAllowedOrigin(request: Request, allowedOrigins: string[] = []): boolean {
41
+ const origin = request.headers.get("origin");
42
+
43
+ // No Origin: not a browser-initiated cross-site request. Native clients, server-to-server
44
+ // callers and most CLI tooling omit it entirely.
45
+ if (origin === null || origin === "") return true;
46
+
47
+ // "null" is what a sandboxed iframe, a `data:` document or a redirected cross-origin request
48
+ // sends. It is never a legitimate first-party caller, so it is refused explicitly rather than
49
+ // falling through to the URL comparison below (where `new URL("null")` would throw).
50
+ if (origin === "null") return false;
51
+
52
+ let selfOrigin: string;
53
+ try {
54
+ selfOrigin = new URL(request.url).origin;
55
+ } catch {
56
+ return false;
57
+ }
58
+
59
+ if (origin === selfOrigin) return true;
60
+ return allowedOrigins.includes(origin);
61
+ }
62
+
63
+ /**
64
+ * Read the configured extra origins for pipeline-bypassing endpoints.
65
+ *
66
+ * Kept separate from {@link isAllowedOrigin} so the check itself stays pure and trivially
67
+ * testable. Returns an empty list when unset or malformed — i.e. same-origin only, which is
68
+ * the safe default.
69
+ *
70
+ * @param config - The resolved `app` config object, or anything shaped like it.
71
+ */
72
+ export function allowedOriginsFrom(config: unknown): string[] {
73
+ const value = (config as { allowedOrigins?: unknown } | null | undefined)?.allowedOrigins;
74
+ if (!Array.isArray(value)) return [];
75
+ return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
76
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Content-type detection from a file's own bytes.
3
+ *
4
+ * An upload arrives with two client-supplied claims about what it is — the filename's
5
+ * extension and the multipart part's `Content-Type` — and neither is evidence. Storing
6
+ * either verbatim is how `avatar` with filename `x.html` and `Content-Type: text/html`
7
+ * becomes stored XSS on the asset origin. These helpers read the leading bytes instead.
8
+ */
9
+
10
+ /** Signature table: leading bytes → the content type they identify. */
11
+ interface Signature {
12
+ /** Byte prefix, or `null` for positions that may hold anything. */
13
+ magic: (number | null)[];
14
+ /** Offset the prefix starts at. */
15
+ offset?: number;
16
+ type: string;
17
+ extension: string;
18
+ /** Extra check for formats whose prefix alone is ambiguous (RIFF, ISO-BMFF). */
19
+ verify?: (bytes: Uint8Array) => boolean;
20
+ }
21
+
22
+ const _ascii = (s: string): number[] => [...s].map((c) => c.charCodeAt(0));
23
+
24
+ const _at = (bytes: Uint8Array, offset: number, text: string): boolean =>
25
+ _ascii(text).every((code, i) => bytes[offset + i] === code);
26
+
27
+ /**
28
+ * Ordered most-specific first. Deliberately narrow: this covers the formats an app
29
+ * actually accepts as uploads, and everything else falls through to a type that browsers
30
+ * will not execute.
31
+ */
32
+ const SIGNATURES: Signature[] = [
33
+ { magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], type: "image/png", extension: "png" },
34
+ { magic: [0xff, 0xd8, 0xff], type: "image/jpeg", extension: "jpg" },
35
+ { magic: _ascii("GIF87a"), type: "image/gif", extension: "gif" },
36
+ { magic: _ascii("GIF89a"), type: "image/gif", extension: "gif" },
37
+ {
38
+ magic: _ascii("RIFF"),
39
+ type: "image/webp",
40
+ extension: "webp",
41
+ verify: (b) => _at(b, 8, "WEBP"),
42
+ },
43
+ { magic: [0x42, 0x4d], type: "image/bmp", extension: "bmp" },
44
+ { magic: [0x00, 0x00, 0x01, 0x00], type: "image/x-icon", extension: "ico" },
45
+ { magic: _ascii("%PDF-"), type: "application/pdf", extension: "pdf" },
46
+ { magic: [0x1f, 0x8b], type: "application/gzip", extension: "gz" },
47
+ // ISO base media format: the box size occupies bytes 0-3, the brand starts at 4.
48
+ { magic: _ascii("ftyp"), offset: 4, type: "video/mp4", extension: "mp4" },
49
+ { magic: _ascii("OggS"), type: "audio/ogg", extension: "ogg" },
50
+ { magic: [0x49, 0x44, 0x33], type: "audio/mpeg", extension: "mp3" },
51
+ {
52
+ magic: _ascii("RIFF"),
53
+ type: "audio/wav",
54
+ extension: "wav",
55
+ verify: (b) => _at(b, 8, "WAVE"),
56
+ },
57
+ // ZIP container. Also the envelope for docx/xlsx/pptx, which is why the extension it
58
+ // reports is the neutral one — distinguishing them needs the central directory.
59
+ { magic: [0x50, 0x4b, 0x03, 0x04], type: "application/zip", extension: "zip" },
60
+ ];
61
+
62
+ /** Type stored when the bytes match nothing known. Browsers never execute it. */
63
+ export const FALLBACK_CONTENT_TYPE = "application/octet-stream";
64
+
65
+ /** Extension stored when the bytes match nothing known. */
66
+ export const FALLBACK_EXTENSION = "bin";
67
+
68
+ /** What {@link sniffContentType} determined about a file. */
69
+ export interface SniffedType {
70
+ /** The detected media type, or {@link FALLBACK_CONTENT_TYPE} when unrecognised. */
71
+ contentType: string;
72
+ /** The canonical extension for that type, or {@link FALLBACK_EXTENSION}. */
73
+ extension: string;
74
+ /** Whether the bytes actually matched a signature, as opposed to falling back. */
75
+ recognised: boolean;
76
+ }
77
+
78
+ /**
79
+ * Identify a file from its leading bytes.
80
+ *
81
+ * Recognises the common image, document, archive and media formats. Anything else — a
82
+ * text file, a format not in the table, or a crafted polyglot — reports
83
+ * {@link FALLBACK_CONTENT_TYPE}, which is the safe answer: an unrecognised upload served
84
+ * as `application/octet-stream` downloads, it does not execute.
85
+ *
86
+ * @param bytes - The file's contents (only the first 16 bytes are read).
87
+ * @returns The detected type, extension, and whether detection actually succeeded.
88
+ *
89
+ * @example
90
+ * const { contentType, extension } = sniffContentType(await file.bytes());
91
+ */
92
+ export function sniffContentType(bytes: Uint8Array): SniffedType {
93
+ for (const sig of SIGNATURES) {
94
+ const offset = sig.offset ?? 0;
95
+ const matches = sig.magic.every((byte, i) => byte === null || bytes[offset + i] === byte);
96
+ if (!matches) continue;
97
+ if (sig.verify && !sig.verify(bytes)) continue;
98
+ return { contentType: sig.type, extension: sig.extension, recognised: true };
99
+ }
100
+ return {
101
+ contentType: FALLBACK_CONTENT_TYPE,
102
+ extension: FALLBACK_EXTENSION,
103
+ recognised: false,
104
+ };
105
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The application URL service — generation **and** signing in one place.
3
+ *
4
+ * There are two URL constructs in Zerotal; this is the high-level one:
5
+ *
6
+ * - {@link Uri} (`uri()`) — a low-level, immutable URI *value object*: parse a URL and
7
+ * fluently rewrite its scheme/host/path/query/fragment. Use it to manipulate any URL.
8
+ * - **`Url` / `url()`** (this file) — the app-aware URL *service*: build fully-qualified
9
+ * URLs for paths, named routes, the current/previous request, the `intended` URL, AND
10
+ * generate/verify HMAC-signed, time-limited links.
11
+ *
12
+ * `Url` and `url()` are the same object — `url()` is the function form, `Url` the facade.
13
+ *
14
+ * @example
15
+ * url("user/profile"); // "https://app.test/user/profile"
16
+ * url("user/profile", [1]); // "https://app.test/user/profile/1" (extra path segments)
17
+ *
18
+ * Url.current(); // current URL, without the query string
19
+ * Url.full(); // current URL, with the query string
20
+ * Url.to("posts", [42]); // "https://app.test/posts/42"
21
+ * Url.route("posts.show", { id: 42 });
22
+ * Url.intended("/dashboard"); // the post-login target, with open-redirect guard
23
+ *
24
+ * // Signed, time-limited links (uses APP_KEY unless a secret is passed):
25
+ * const link = Url.sign("https://app.test/verify", { email }, 15); // expires in 15 min
26
+ * if (Url.verify(link)) { ... } // false if tampered/expired
27
+ */
28
+ import { RequestContext } from "../context/RequestContext.ts";
29
+ import { route } from "../router/Router.ts";
30
+ import { ZerotalError } from "../errors/ZerotalError.ts";
31
+ import { URLSigner } from "../crypt/URLSigner.ts";
32
+ import { Uri, appBaseUrl, type QueryInput } from "./Uri.ts";
33
+
34
+ const SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
35
+
36
+ // ── Signing ──────────────────────────────────────────────────────────────────
37
+ // HMAC-SHA256 signed URLs for email verification, password resets, invite/one-time
38
+ // links. Signs with APP_KEY by default (the same key as `Crypt`), or an explicit secret.
39
+
40
+ /** Raised when signing is used without an `APP_KEY` configured and no explicit secret. */
41
+ export class UrlKeyMissingError extends ZerotalError {
42
+ constructor() {
43
+ super(
44
+ "[Zerotal] URL signing requires APP_KEY. Generate one with `zerotal key:generate`.",
45
+ "E_URL_NO_KEY",
46
+ 500,
47
+ );
48
+ }
49
+ }
50
+
51
+ let _secret: string | null = null;
52
+ let _signer: URLSigner | null = null;
53
+
54
+ function _signerFor(secret?: string): URLSigner {
55
+ if (secret) return new URLSigner(secret);
56
+ if (_signer) return _signer;
57
+ const key = _secret ?? Bun.env["APP_KEY"];
58
+ if (!key) throw new UrlKeyMissingError();
59
+ _signer = new URLSigner(key);
60
+ return _signer;
61
+ }
62
+
63
+ /** Resolve the base origin: configured app URL, else the current request's origin, else "". */
64
+ function base(): string {
65
+ const configured = appBaseUrl();
66
+ if (configured) return configured;
67
+ return RequestContext.tryGet()?.url.origin ?? "";
68
+ }
69
+
70
+ /** Join a path with extra segments: ("user/profile", [1]) → "/user/profile/1". */
71
+ function joinPath(path: string, extra: (string | number)[] = []): string {
72
+ if (SCHEME_RE.test(path)) {
73
+ const tail = extra.map((e) => encodeURIComponent(String(e))).join("/");
74
+ return tail ? `${path.replace(/\/+$/, "")}/${tail}` : path;
75
+ }
76
+ const head = path.replace(/^\/+|\/+$/g, "");
77
+ const segs = extra.map((e) => encodeURIComponent(String(e)));
78
+ return "/" + [head, ...segs].filter((s) => s !== "").join("/");
79
+ }
80
+
81
+ /** Build a fully-qualified URL string for a path (+ optional extra segments). */
82
+ function toUrl(path: string, extra: (string | number)[] = [], secure = false): string {
83
+ const joined = joinPath(path, extra);
84
+ if (SCHEME_RE.test(joined)) {
85
+ return secure ? Uri.of(joined).withScheme("https").value() : joined;
86
+ }
87
+ const origin = base();
88
+ const abs = origin ? `${origin}${joined}` : joined;
89
+ return secure ? Uri.of(abs).withScheme("https").value() : abs;
90
+ }
91
+
92
+ /** Returned by `url()` (no arguments) — the URL generator surface. */
93
+ export interface UrlGenerator {
94
+ /** Checks if the current URL matches a given path. */
95
+ is(path: string): boolean;
96
+ /** The current request URL, WITHOUT the query string. */
97
+ current(): string;
98
+ /** The current request URL, WITH the query string. */
99
+ full(): string;
100
+ /** The previous URL (the `Referer` header), falling back to `fallback`. */
101
+ previous(fallback?: string): string;
102
+ /** A fully-qualified URL for a path (+ optional extra path segments). */
103
+ to(path: string, extra?: (string | number)[], secure?: boolean): string;
104
+ /** A fully-qualified HTTPS URL. */
105
+ secure(path: string, extra?: (string | number)[]): string;
106
+ /** A fully-qualified URL with a query string appended. */
107
+ query(path: string, query: QueryInput, extra?: (string | number)[]): string;
108
+ /** A fully-qualified URL for a named route. */
109
+ route(name: string, params?: Record<string, string | number>): string;
110
+ /**
111
+ * The URL the user was heading to before authentication (the session's `intended_url`),
112
+ * falling back to `fallback`. Cross-origin stored URLs are rejected (open-redirect guard).
113
+ * Returns a plain string.
114
+ *
115
+ * @example
116
+ * url().intended("/dashboard");
117
+ */
118
+ intended(fallback?: string): string;
119
+
120
+ // ── Signed URLs ──────────────────────────────────────────────────────────────
121
+
122
+ /**
123
+ * Build an HMAC-signed, time-limited URL. Signs with `APP_KEY` unless `secret` is given.
124
+ *
125
+ * @param base The base URL (scheme + host + path).
126
+ * @param params Extra query parameters to include (encoded into the signature).
127
+ * @param expiresInMinutes Minutes until the link expires. Default 60.
128
+ * @param secret Sign with this key instead of `APP_KEY`.
129
+ */
130
+ sign(
131
+ base: string,
132
+ params?: Record<string, string>,
133
+ expiresInMinutes?: number,
134
+ secret?: string,
135
+ ): string;
136
+ /** Verify a signed URL — `false` if tampered, expired, or unsigned. */
137
+ verify(signedUrl: string, secret?: string): boolean;
138
+ /** Override the default signing secret (otherwise derived from `APP_KEY`). */
139
+ setSecret(secret: string): void;
140
+ }
141
+
142
+ const generator: UrlGenerator = {
143
+ is(path) {
144
+ if (path.endsWith("*")) {
145
+ const prefix = path.slice(0, -1);
146
+ return Uri.current().value().startsWith(prefix);
147
+ }
148
+ return Uri.current().value() === toUrl(path);
149
+ },
150
+ current() {
151
+ return Uri.current().replaceQuery({}).withFragment(undefined).value();
152
+ },
153
+ full() {
154
+ return RequestContext.get().fullUrl();
155
+ },
156
+ previous(fallback = "/") {
157
+ const ctx = RequestContext.tryGet();
158
+ const referer = ctx?.request.headers.get("referer") ?? ctx?.request.headers.get("referrer");
159
+ return referer ?? toUrl(fallback);
160
+ },
161
+ to(path, extra = [], secure = false) {
162
+ return toUrl(path, extra, secure);
163
+ },
164
+ secure(path, extra = []) {
165
+ return toUrl(path, extra, true);
166
+ },
167
+ query(path, query, extra = []) {
168
+ return Uri.of(toUrl(path, extra)).withQuery(query).value();
169
+ },
170
+ route(name, params = {}) {
171
+ return toUrl(route(name, params));
172
+ },
173
+ intended(fallback = "/") {
174
+ // Uri.intended reads (and clears) the session's intended_url with an open-redirect guard.
175
+ return Uri.of("/").intended(fallback).value();
176
+ },
177
+ sign(base, params = {}, expiresInMinutes = 60, secret) {
178
+ return _signerFor(secret).sign(base, params, expiresInMinutes);
179
+ },
180
+ verify(signedUrl, secret) {
181
+ return _signerFor(secret).verify(signedUrl);
182
+ },
183
+ setSecret(secret) {
184
+ _secret = secret;
185
+ _signer = null;
186
+ },
187
+ };
188
+
189
+ /**
190
+ * The application URL facade — the same object {@link url} returns, exported for direct
191
+ * `Url.to(...)` / `Url.route(...)` / `Url.sign(...)` calls. Generation + signing in one place.
192
+ */
193
+ export const Url: UrlGenerator = generator;
194
+
195
+ export function url(): UrlGenerator;
196
+ export function url(path: string, extra?: (string | number)[], secure?: boolean): string;
197
+ export function url(
198
+ path?: string,
199
+ extra: (string | number)[] = [],
200
+ secure = false,
201
+ ): string | UrlGenerator {
202
+ if (path === undefined) return generator;
203
+ return toUrl(path, extra, secure);
204
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Return a copy of `res` with the given headers added/overwritten.
3
+ *
4
+ * A `Response` produced by `Response.redirect()` (or `Response.error()`) has an
5
+ * **immutable** headers guard — calling `res.headers.set(...)` on it throws.
6
+ * Middleware that decorates the downstream response (CORS, security headers,
7
+ * rate-limit info, …) must therefore reconstruct rather than mutate in place.
8
+ * `new Response(body, ...)` always yields a mutable guard, so the copy is safe.
9
+ *
10
+ * @example
11
+ * async handle(_, next) {
12
+ * const res = await next();
13
+ * return res ? withHeaders(res, { "X-Frame-Options": "DENY" }) : res;
14
+ * }
15
+ */
16
+ export function withHeaders(res: Response, headers: Record<string, string>): Response {
17
+ const merged = new Headers(res.headers);
18
+ for (const [name, value] of Object.entries(headers)) merged.set(name, value);
19
+ return new Response(res.body, {
20
+ status: res.status,
21
+ statusText: res.statusText,
22
+ headers: merged,
23
+ });
24
+ }