@warlock.js/core 4.16.0 → 5.0.1

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 (180) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/bin/warlock.js +154 -1
  3. package/esm/application/application.d.mts +65 -1
  4. package/esm/application/application.d.mts.map +1 -1
  5. package/esm/application/application.mjs +62 -0
  6. package/esm/application/application.mjs.map +1 -1
  7. package/esm/application/index.d.mts +1 -1
  8. package/esm/cli/cli-commands.manager.mjs +60 -10
  9. package/esm/cli/cli-commands.manager.mjs.map +1 -1
  10. package/esm/cli/commands/dev-server.command.mjs +1 -0
  11. package/esm/cli/commands/dev-server.command.mjs.map +1 -1
  12. package/esm/cli/commands/doctor/checks/handler-signature.check.mjs +40 -0
  13. package/esm/cli/commands/doctor/checks/handler-signature.check.mjs.map +1 -0
  14. package/esm/cli/commands/doctor/checks/index.mjs +4 -2
  15. package/esm/cli/commands/doctor/checks/index.mjs.map +1 -1
  16. package/esm/commands/cli-command.d.mts +1 -1
  17. package/esm/commands/cli-command.d.mts.map +1 -1
  18. package/esm/commands/cli-command.mjs.map +1 -1
  19. package/esm/commands/types.d.mts +10 -2
  20. package/esm/commands/types.d.mts.map +1 -1
  21. package/esm/config/config-handlers.d.mts +1 -2
  22. package/esm/config/config-handlers.d.mts.map +1 -1
  23. package/esm/config/config-handlers.mjs +3 -2
  24. package/esm/config/config-handlers.mjs.map +1 -1
  25. package/esm/config/locale-configuration.mjs +29 -0
  26. package/esm/config/locale-configuration.mjs.map +1 -0
  27. package/esm/connectors/assert-no-reserved-connector-names.d.mts +7 -0
  28. package/esm/connectors/assert-no-reserved-connector-names.d.mts.map +1 -0
  29. package/esm/connectors/assert-no-reserved-connector-names.mjs +44 -0
  30. package/esm/connectors/assert-no-reserved-connector-names.mjs.map +1 -0
  31. package/esm/connectors/assert-unique-connector-names.d.mts +7 -0
  32. package/esm/connectors/assert-unique-connector-names.d.mts.map +1 -0
  33. package/esm/connectors/assert-unique-connector-names.mjs +20 -0
  34. package/esm/connectors/assert-unique-connector-names.mjs.map +1 -0
  35. package/esm/connectors/connectors-manager.d.mts +38 -0
  36. package/esm/connectors/connectors-manager.d.mts.map +1 -1
  37. package/esm/connectors/connectors-manager.mjs +45 -12
  38. package/esm/connectors/connectors-manager.mjs.map +1 -1
  39. package/esm/connectors/describe-server-address.mjs +75 -0
  40. package/esm/connectors/describe-server-address.mjs.map +1 -0
  41. package/esm/connectors/http-connector.d.mts.map +1 -1
  42. package/esm/connectors/http-connector.mjs +6 -3
  43. package/esm/connectors/http-connector.mjs.map +1 -1
  44. package/esm/connectors/index.d.mts +4 -1
  45. package/esm/connectors/index.mjs +4 -1
  46. package/esm/connectors/register-configured-connectors.d.mts +14 -0
  47. package/esm/connectors/register-configured-connectors.d.mts.map +1 -0
  48. package/esm/connectors/register-configured-connectors.mjs +99 -0
  49. package/esm/connectors/register-configured-connectors.mjs.map +1 -0
  50. package/esm/connectors/socket-connector.d.mts.map +1 -1
  51. package/esm/connectors/socket-connector.mjs +4 -1
  52. package/esm/connectors/socket-connector.mjs.map +1 -1
  53. package/esm/connectors/types.d.mts +76 -1
  54. package/esm/connectors/types.d.mts.map +1 -1
  55. package/esm/connectors/types.mjs.map +1 -1
  56. package/esm/database/models/database-log/database-log.d.mts +50 -2
  57. package/esm/database/models/database-log/database-log.d.mts.map +1 -1
  58. package/esm/database/utils.d.mts +2 -2
  59. package/esm/database/utils.d.mts.map +1 -1
  60. package/esm/dev-server/development-server.mjs +1 -0
  61. package/esm/dev-server/development-server.mjs.map +1 -1
  62. package/esm/dev-server/layer-executor.mjs +2 -1
  63. package/esm/dev-server/layer-executor.mjs.map +1 -1
  64. package/esm/dev-server/read-config-ast.mjs +48 -0
  65. package/esm/dev-server/read-config-ast.mjs.map +1 -0
  66. package/esm/dev-server/run-typings-generation.mjs +18 -0
  67. package/esm/dev-server/run-typings-generation.mjs.map +1 -0
  68. package/esm/dev-server/type-generator.mjs +30 -39
  69. package/esm/dev-server/type-generator.mjs.map +1 -1
  70. package/esm/generations/add-command.action.mjs +169 -1
  71. package/esm/generations/add-command.action.mjs.map +1 -1
  72. package/esm/generations/stubs.mjs +93 -1
  73. package/esm/generations/stubs.mjs.map +1 -1
  74. package/esm/http/context/request-context.d.mts +14 -3
  75. package/esm/http/context/request-context.d.mts.map +1 -1
  76. package/esm/http/context/request-context.mjs +11 -1
  77. package/esm/http/context/request-context.mjs.map +1 -1
  78. package/esm/http/context/request-memo.d.mts +21 -0
  79. package/esm/http/context/request-memo.d.mts.map +1 -0
  80. package/esm/http/context/request-memo.mjs +48 -0
  81. package/esm/http/context/request-memo.mjs.map +1 -0
  82. package/esm/http/database/RequestLog.d.mts +164 -2
  83. package/esm/http/database/RequestLog.d.mts.map +1 -1
  84. package/esm/http/index.d.mts +6 -5
  85. package/esm/http/index.mjs +2 -1
  86. package/esm/http/middleware/cache-response-middleware.d.mts +2 -2
  87. package/esm/http/middleware/cache-response-middleware.d.mts.map +1 -1
  88. package/esm/http/middleware/cache-response-middleware.mjs +1 -1
  89. package/esm/http/middleware/cache-response-middleware.mjs.map +1 -1
  90. package/esm/http/middleware/concurrency-limit.middleware.mjs +1 -1
  91. package/esm/http/middleware/concurrency-limit.middleware.mjs.map +1 -1
  92. package/esm/http/middleware/idempotency.middleware.mjs +1 -1
  93. package/esm/http/middleware/idempotency.middleware.mjs.map +1 -1
  94. package/esm/http/middleware/index.d.mts +1 -1
  95. package/esm/http/middleware/index.mjs +1 -1
  96. package/esm/http/middleware/inject-request-context.d.mts +1 -8
  97. package/esm/http/middleware/inject-request-context.d.mts.map +1 -1
  98. package/esm/http/middleware/inject-request-context.mjs +9 -22
  99. package/esm/http/middleware/inject-request-context.mjs.map +1 -1
  100. package/esm/http/middleware/ip-filter.middleware.mjs +1 -1
  101. package/esm/http/middleware/ip-filter.middleware.mjs.map +1 -1
  102. package/esm/http/middleware/maintenance.middleware.mjs +1 -1
  103. package/esm/http/middleware/maintenance.middleware.mjs.map +1 -1
  104. package/esm/http/middleware/max-body-size.middleware.mjs +1 -1
  105. package/esm/http/middleware/max-body-size.middleware.mjs.map +1 -1
  106. package/esm/http/middleware/rate-limit.middleware.mjs +1 -1
  107. package/esm/http/middleware/rate-limit.middleware.mjs.map +1 -1
  108. package/esm/http/middleware/utils/idempotency-key.d.mts +0 -14
  109. package/esm/http/middleware/utils/idempotency-key.d.mts.map +1 -1
  110. package/esm/http/middleware/utils/idempotency-key.mjs +19 -1
  111. package/esm/http/middleware/utils/idempotency-key.mjs.map +1 -1
  112. package/esm/http/request.d.mts +119 -30
  113. package/esm/http/request.d.mts.map +1 -1
  114. package/esm/http/request.mjs +98 -25
  115. package/esm/http/request.mjs.map +1 -1
  116. package/esm/http/response.mjs +1 -1
  117. package/esm/http/response.mjs.map +1 -1
  118. package/esm/http/types.d.mts +36 -1
  119. package/esm/http/types.d.mts.map +1 -1
  120. package/esm/http/uploaded-file.d.mts +51 -19
  121. package/esm/http/uploaded-file.d.mts.map +1 -1
  122. package/esm/http/uploaded-file.mjs +36 -8
  123. package/esm/http/uploaded-file.mjs.map +1 -1
  124. package/esm/index.d.mts +17 -9
  125. package/esm/index.mjs +10 -4
  126. package/esm/production/build-contributions.mjs +115 -0
  127. package/esm/production/build-contributions.mjs.map +1 -0
  128. package/esm/production/production-builder.mjs +97 -2
  129. package/esm/production/production-builder.mjs.map +1 -1
  130. package/esm/production/resolve-build-config.d.mts +25 -0
  131. package/esm/production/resolve-build-config.d.mts.map +1 -0
  132. package/esm/production/resolve-build-config.mjs +43 -1
  133. package/esm/production/resolve-build-config.mjs.map +1 -1
  134. package/esm/repositories/adapters/cascade/cascade-query-builder.d.mts +1 -1
  135. package/esm/repositories/adapters/cascade/filter-applicator.mjs +20 -9
  136. package/esm/repositories/adapters/cascade/filter-applicator.mjs.map +1 -1
  137. package/esm/restful/restful.d.mts +29 -8
  138. package/esm/restful/restful.d.mts.map +1 -1
  139. package/esm/restful/restful.mjs +31 -13
  140. package/esm/restful/restful.mjs.map +1 -1
  141. package/esm/router/describe-route-for-log.mjs +29 -0
  142. package/esm/router/describe-route-for-log.mjs.map +1 -0
  143. package/esm/router/index.d.mts +3 -1
  144. package/esm/router/index.mjs +2 -0
  145. package/esm/router/log-request-lifecycle.mjs +38 -0
  146. package/esm/router/log-request-lifecycle.mjs.map +1 -0
  147. package/esm/router/positional-handler-diagnostics.d.mts +90 -0
  148. package/esm/router/positional-handler-diagnostics.d.mts.map +1 -0
  149. package/esm/router/positional-handler-diagnostics.mjs +247 -0
  150. package/esm/router/positional-handler-diagnostics.mjs.map +1 -0
  151. package/esm/router/route-registry.d.mts +34 -0
  152. package/esm/router/route-registry.d.mts.map +1 -0
  153. package/esm/router/router.d.mts.map +1 -1
  154. package/esm/router/router.mjs +87 -63
  155. package/esm/router/router.mjs.map +1 -1
  156. package/esm/router/types.d.mts +23 -5
  157. package/esm/router/types.d.mts.map +1 -1
  158. package/esm/storage/storage.d.mts.map +1 -1
  159. package/esm/storage/storage.mjs +1 -1
  160. package/esm/storage/storage.mjs.map +1 -1
  161. package/esm/storage/utils/safe-fetch.d.mts +11 -1
  162. package/esm/storage/utils/safe-fetch.d.mts.map +1 -1
  163. package/esm/storage/utils/safe-fetch.mjs +51 -4
  164. package/esm/storage/utils/safe-fetch.mjs.map +1 -1
  165. package/esm/tests/vitest-setup.mjs +1 -1
  166. package/esm/utils/load-environment.mjs +33 -2
  167. package/esm/utils/load-environment.mjs.map +1 -1
  168. package/esm/utils/types.d.mts +4 -0
  169. package/esm/utils/types.d.mts.map +1 -1
  170. package/esm/validation/validateAll.mjs +4 -1
  171. package/esm/validation/validateAll.mjs.map +1 -1
  172. package/esm/validation/validators/file-validator.d.mts +25 -1
  173. package/esm/validation/validators/file-validator.d.mts.map +1 -1
  174. package/esm/warlock-config/index.mjs +1 -1
  175. package/esm/warlock-config/normalize-build-config.mjs.map +1 -1
  176. package/esm/warlock-config/types.d.mts +36 -2
  177. package/esm/warlock-config/types.d.mts.map +1 -1
  178. package/package.json +15 -12
  179. package/skills/request-memo/SKILL.md +142 -0
  180. package/skills/use-request-locals/SKILL.md +144 -0
@@ -1,9 +1,8 @@
1
- import { ClosableServer, FastifyInstance, closeServerWithTimeout, getHttpServer, startHttpServer } from "./server.mjs";
2
1
  import { FileNamingStrategy, ImageTransformCallback, ImageTransformConfig, PrefixConfig, PrefixOptions, SaveAsOptions, SaveOptions, UploadedFileImageOptions, UploadsConfigurations } from "./uploads-types.mjs";
3
- import { FileValidationOptions, UploadedFile } from "./uploaded-file.mjs";
2
+ import { FileValidationOptions, UploadedFile, UploadedFileJson } from "./uploaded-file.mjs";
4
3
  import { Request } from "./request.mjs";
5
4
  import { CookieOptions, Response, ResponseStatus, SendBufferOptions, SendFileOptions } from "./response.mjs";
6
- import { HttpConfigurations, PartialMiddleware, RequestEvent, ResponseEvent, ResponseSSEController, ResponseStreamController, ReturnedResponse } from "./types.mjs";
5
+ import { HttpConfigurations, PartialMiddleware, RequestEvent, RequestLocals, RequestUser, ResponseEvent, ResponseSSEController, ResponseStreamController, ReturnedResponse } from "./types.mjs";
7
6
  import { defaultHttpConfigurations, httpConfig } from "./config.mjs";
8
7
  import { createHttpApplication, stopHttpApplication } from "./createHttpApplication.mjs";
9
8
  import { RequestLog } from "./database/RequestLog.mjs";
@@ -20,10 +19,12 @@ import { IpFilterOptions } from "./middleware/ip-filter.middleware.mjs";
20
19
  import { MaintenanceOptions } from "./middleware/maintenance.middleware.mjs";
21
20
  import { RateLimitOptions } from "./middleware/rate-limit.middleware.mjs";
22
21
  import { middleware } from "./middleware/middleware-list.mjs";
23
- import { createRequestStore, fromRequest, t } from "./middleware/inject-request-context.mjs";
22
+ import { createRequestStore, t } from "./middleware/inject-request-context.mjs";
24
23
  import { anyMatch, ipMatches } from "./middleware/utils/cidr-match.mjs";
25
24
  import { buildIdempotencyCacheKey, hashBody, isValidIdempotencyKey } from "./middleware/utils/idempotency-key.mjs";
26
25
  import { parseSize } from "./middleware/utils/parse-size.mjs";
26
+ import { ClosableServer, FastifyInstance, closeServerWithTimeout, getHttpServer, startHttpServer } from "./server.mjs";
27
27
  import { registerHttpPlugins } from "./plugins.mjs";
28
28
  import { PortInUseError, assertPortIsAvailable, isPortAvailable } from "./port-preflight.mjs";
29
- import { RequestContextStore, requestContext, useCurrentUser, useRequest, useRequestStore } from "./context/request-context.mjs";
29
+ import { RequestContextStore, requestContext, useCurrentUser, useRequest, useRequestStore } from "./context/request-context.mjs";
30
+ import { requestMemo } from "./context/request-memo.mjs";
@@ -1,6 +1,6 @@
1
1
  import { requestContext, useCurrentUser, useRequest, useRequestStore } from "./context/request-context.mjs";
2
2
  import { BadRequestError, ConflictError, ForbiddenError, HttpError, NotAcceptableError, NotAllowedError, ResourceNotFoundError, ServerError, UnAuthorizedError } from "./errors/errors.mjs";
3
- import { createRequestStore, fromRequest, t } from "./middleware/inject-request-context.mjs";
3
+ import { createRequestStore, t } from "./middleware/inject-request-context.mjs";
4
4
  import { defaultHttpConfigurations, httpConfig } from "./config.mjs";
5
5
  import { Response, ResponseStatus } from "./response.mjs";
6
6
  import { UPLOADS_DEFAULTS, uploadsConfig } from "./uploads-config.mjs";
@@ -20,5 +20,6 @@ import { parseSize } from "./middleware/utils/parse-size.mjs";
20
20
  import { middleware } from "./middleware/middleware-list.mjs";
21
21
  import "./middleware/index.mjs";
22
22
  import { PortInUseError, assertPortIsAvailable, isPortAvailable } from "./port-preflight.mjs";
23
+ import { requestMemo } from "./context/request-memo.mjs";
23
24
 
24
25
  export { };
@@ -1,5 +1,5 @@
1
1
  import { Request } from "../request.mjs";
2
- import { Response } from "../response.mjs";
2
+ import { Middleware } from "../../router/types.mjs";
3
3
 
4
4
  //#region ../core/src/http/middleware/cache-response-middleware.d.ts
5
5
  type CacheMiddlewareOptions = {
@@ -32,7 +32,7 @@ type CacheMiddlewareOptions = {
32
32
  */
33
33
  driver?: string;
34
34
  };
35
- declare function cacheMiddleware(responseCacheOptions: CacheMiddlewareOptions | string): (request: Request, response: Response) => Promise<Response | undefined>;
35
+ declare function cacheMiddleware(responseCacheOptions: CacheMiddlewareOptions | string): Middleware;
36
36
  //#endregion
37
37
  export { CacheMiddlewareOptions, cacheMiddleware };
38
38
  //# sourceMappingURL=cache-response-middleware.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cache-response-middleware.d.mts","names":[],"sources":["../../../../../../../../core/src/http/middleware/cache-response-middleware.ts"],"mappings":";;;;KAqBY,sBAAA;;AAAZ;;EAIE,QAAA,aAEM,OAAA,EAAS,OAAA,iBACT,OAAA,EAAS,OAAA,KAAY,OAAA;EADZ;;;;;;EAQf,UAAA;EARM;;;;;EAcN,IAAA;EAIA;;;EAAA,GAAA;EAkDc;;;;;;EA3Cd,MAAA;AAAA;AAAA,iBA2Cc,eAAA,CACd,oBAAA,EAAsB,sBAAA,aAEC,OAAA,EAAS,OAAA,EAAS,QAAA,EAAU,QAAA,KAAQ,OAAA,CAAA,QAAA"}
1
+ {"version":3,"file":"cache-response-middleware.d.mts","names":[],"sources":["../../../../../../../../core/src/http/middleware/cache-response-middleware.ts"],"mappings":";;;;KAsBY,sBAAA;;AAAZ;;EAIE,QAAA,aAEM,OAAA,EAAS,OAAA,iBACT,OAAA,EAAS,OAAA,KAAY,OAAA;EADZ;;;;;;EAQf,UAAA;EARM;;;;;EAcN,IAAA;EAIA;;;EAAA,GAAA;EAkDc;;;;;;EA3Cd,MAAA;AAAA;AAAA,iBA2Cc,eAAA,CACd,oBAAA,EAAsB,sBAAA,YACrB,UAAU"}
@@ -19,7 +19,7 @@ async function parseCacheOptions(cacheOptions, request) {
19
19
  return finalCacheOptions;
20
20
  }
21
21
  function cacheMiddleware(responseCacheOptions) {
22
- return async function(request, response) {
22
+ return async function({ request, response }) {
23
23
  const { ttl, omit, cacheKey, driver } = await parseCacheOptions(responseCacheOptions, request);
24
24
  const cacheDriver = driver ? await cache.use(driver) : cache;
25
25
  const content = await cacheDriver.get(cacheKey);
@@ -1 +1 @@
1
- {"version":3,"file":"cache-response-middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/cache-response-middleware.ts"],"sourcesContent":["import { except } from \"@mongez/reinforcements\";\nimport { cache } from \"@warlock.js/cache\";\nimport { log } from \"@warlock.js/logger\";\nimport type { Request } from \"./../request\";\nimport type { Response } from \"./../response\";\n\n/**\n * Shape persisted to the cache for a cached response. Stores the status and\n * content-type alongside the body so the HIT path can replay the response\n * faithfully via {@link Response.replay} instead of re-entering `send()`.\n */\ntype CachedResponsePayload = {\n status: number;\n data: unknown;\n contentType?: string;\n};\n\n// TODO: Add option to determine whether to cache the response or not\n// TODO: add option to determine what to be cached from the response\n// TODO: add cache middleware config options for example to set the default driver, ttl, etc\n\nexport type CacheMiddlewareOptions = {\n /**\n * Cache key\n */\n cacheKey:\n | string\n | ((request: Request) => string)\n | ((request: Request) => Promise<string>);\n /**\n * If true, then the response will be cached based on the current locale code\n * This is useful when you have a multi-language website, and you want to cache the response based on the current locale\n *\n * @default true\n */\n withLocale?: boolean;\n /**\n * List of keys from the response object to omit from the cached response\n *\n * @default ['user']\n */\n omit?: string[];\n /**\n * Expires after number of seconds\n */\n ttl?: number;\n /**\n * Cache driver\n *\n * @see config/cache.ts: drivers object\n * @default cache manager\n */\n driver?: string;\n};\n\nconst defaultCacheOptions: Partial<CacheMiddlewareOptions> = {\n withLocale: true,\n};\n\ntype ParsedCacheOptions = Required<CacheMiddlewareOptions> & {\n cacheKey: string;\n};\n\nasync function parseCacheOptions(\n cacheOptions: CacheMiddlewareOptions | string,\n request: Request,\n) {\n if (typeof cacheOptions === \"string\") {\n cacheOptions = {\n cacheKey: cacheOptions,\n };\n }\n\n if (typeof cacheOptions.cacheKey === \"function\") {\n cacheOptions.cacheKey = await cacheOptions.cacheKey(request);\n }\n\n const finalCacheOptions = {\n ...defaultCacheOptions,\n ...cacheOptions,\n } as ParsedCacheOptions;\n\n if (finalCacheOptions.withLocale) {\n const locale = request.getLocaleCode();\n\n finalCacheOptions.cacheKey = `${finalCacheOptions.cacheKey}:${locale}`;\n }\n\n if (!finalCacheOptions.omit) {\n finalCacheOptions.omit = [\"user\", \"settings\"];\n }\n\n return finalCacheOptions;\n}\n\nexport function cacheMiddleware(\n responseCacheOptions: CacheMiddlewareOptions | string,\n) {\n return async function (request: Request, response: Response) {\n const { ttl, omit, cacheKey, driver } = await parseCacheOptions(\n responseCacheOptions,\n request,\n );\n const cacheDriver = driver ? await cache.use(driver) : cache;\n\n const content = (await cacheDriver.get(cacheKey)) as CachedResponsePayload | null;\n\n if (content) {\n // Replay through the standard pipeline (status + content-type preserved)\n // instead of `baseResponse.send()`, which would re-enter Response.send()\n // on an already-sent reply, trip the double-send guard, and drop the\n // status / content-type.\n return response.replay({\n status: content.status ?? 200,\n body: content.data,\n contentType: content.contentType,\n });\n }\n\n response.onSent((response: Response) => {\n if (!response.isOk || response.request.path !== request.path) {\n return;\n }\n\n const sentContentType = response.contentType;\n\n const content: CachedResponsePayload = {\n status: response.statusCode,\n data: except(response.parsedBody, omit),\n contentType: typeof sentContentType === \"string\" ? sentContentType : undefined,\n };\n\n // `set` is fire-and-forget inside `onSent`; without a `.catch` a rejected\n // write (e.g. Redis down) would surface as an unhandledRejection.\n cacheDriver.set(cacheKey, content, ttl).catch((error: unknown) => {\n log.error(\"cache-middleware\", \"set\", error);\n });\n });\n };\n}\n"],"mappings":";;;;;AAuDA,MAAM,sBAAuD,EAC3D,YAAY,KACd;AAMA,eAAe,kBACb,cACA,SACA;CACA,IAAI,OAAO,iBAAiB,UAC1B,eAAe,EACb,UAAU,aACZ;CAGF,IAAI,OAAO,aAAa,aAAa,YACnC,aAAa,WAAW,MAAM,aAAa,SAAS,OAAO;CAG7D,MAAM,oBAAoB;EACxB,GAAG;EACH,GAAG;CACL;CAEA,IAAI,kBAAkB,YAAY;EAChC,MAAM,SAAS,QAAQ,cAAc;EAErC,kBAAkB,WAAW,GAAG,kBAAkB,SAAS,GAAG;CAChE;CAEA,IAAI,CAAC,kBAAkB,MACrB,kBAAkB,OAAO,CAAC,QAAQ,UAAU;CAG9C,OAAO;AACT;AAEA,SAAgB,gBACd,sBACA;CACA,OAAO,eAAgB,SAAkB,UAAoB;EAC3D,MAAM,EAAE,KAAK,MAAM,UAAU,WAAW,MAAM,kBAC5C,sBACA,OACF;EACA,MAAM,cAAc,SAAS,MAAM,MAAM,IAAI,MAAM,IAAI;EAEvD,MAAM,UAAW,MAAM,YAAY,IAAI,QAAQ;EAE/C,IAAI,SAKF,OAAO,SAAS,OAAO;GACrB,QAAQ,QAAQ,UAAU;GAC1B,MAAM,QAAQ;GACd,aAAa,QAAQ;EACvB,CAAC;EAGH,SAAS,QAAQ,aAAuB;GACtC,IAAI,CAAC,SAAS,QAAQ,SAAS,QAAQ,SAAS,QAAQ,MACtD;GAGF,MAAM,kBAAkB,SAAS;GAEjC,MAAM,UAAiC;IACrC,QAAQ,SAAS;IACjB,MAAM,OAAO,SAAS,YAAY,IAAI;IACtC,aAAa,OAAO,oBAAoB,WAAW,kBAAkB;GACvE;GAIA,YAAY,IAAI,UAAU,SAAS,GAAG,CAAC,CAAC,OAAO,UAAmB;IAChE,IAAI,MAAM,oBAAoB,OAAO,KAAK;GAC5C,CAAC;EACH,CAAC;CACH;AACF"}
1
+ {"version":3,"file":"cache-response-middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/cache-response-middleware.ts"],"sourcesContent":["import { except } from \"@mongez/reinforcements\";\nimport { cache } from \"@warlock.js/cache\";\nimport { log } from \"@warlock.js/logger\";\nimport type { Middleware } from \"../../router/types\";\nimport type { Request } from \"./../request\";\nimport type { Response } from \"./../response\";\n\n/**\n * Shape persisted to the cache for a cached response. Stores the status and\n * content-type alongside the body so the HIT path can replay the response\n * faithfully via {@link Response.replay} instead of re-entering `send()`.\n */\ntype CachedResponsePayload = {\n status: number;\n data: unknown;\n contentType?: string;\n};\n\n// TODO: Add option to determine whether to cache the response or not\n// TODO: add option to determine what to be cached from the response\n// TODO: add cache middleware config options for example to set the default driver, ttl, etc\n\nexport type CacheMiddlewareOptions = {\n /**\n * Cache key\n */\n cacheKey:\n | string\n | ((request: Request) => string)\n | ((request: Request) => Promise<string>);\n /**\n * If true, then the response will be cached based on the current locale code\n * This is useful when you have a multi-language website, and you want to cache the response based on the current locale\n *\n * @default true\n */\n withLocale?: boolean;\n /**\n * List of keys from the response object to omit from the cached response\n *\n * @default ['user']\n */\n omit?: string[];\n /**\n * Expires after number of seconds\n */\n ttl?: number;\n /**\n * Cache driver\n *\n * @see config/cache.ts: drivers object\n * @default cache manager\n */\n driver?: string;\n};\n\nconst defaultCacheOptions: Partial<CacheMiddlewareOptions> = {\n withLocale: true,\n};\n\ntype ParsedCacheOptions = Required<CacheMiddlewareOptions> & {\n cacheKey: string;\n};\n\nasync function parseCacheOptions(\n cacheOptions: CacheMiddlewareOptions | string,\n request: Request,\n) {\n if (typeof cacheOptions === \"string\") {\n cacheOptions = {\n cacheKey: cacheOptions,\n };\n }\n\n if (typeof cacheOptions.cacheKey === \"function\") {\n cacheOptions.cacheKey = await cacheOptions.cacheKey(request);\n }\n\n const finalCacheOptions = {\n ...defaultCacheOptions,\n ...cacheOptions,\n } as ParsedCacheOptions;\n\n if (finalCacheOptions.withLocale) {\n const locale = request.getLocaleCode();\n\n finalCacheOptions.cacheKey = `${finalCacheOptions.cacheKey}:${locale}`;\n }\n\n if (!finalCacheOptions.omit) {\n finalCacheOptions.omit = [\"user\", \"settings\"];\n }\n\n return finalCacheOptions;\n}\n\nexport function cacheMiddleware(\n responseCacheOptions: CacheMiddlewareOptions | string,\n): Middleware {\n // The `Middleware` return annotation is load-bearing: without it, tsc never\n // checks this factory's calling convention, which is how the positional v4\n // shape survived an earlier refactor unnoticed.\n return async function ({ request, response }) {\n const { ttl, omit, cacheKey, driver } = await parseCacheOptions(\n responseCacheOptions,\n request,\n );\n const cacheDriver = driver ? await cache.use(driver) : cache;\n\n const content = (await cacheDriver.get(cacheKey)) as CachedResponsePayload | null;\n\n if (content) {\n // Replay through the standard pipeline (status + content-type preserved)\n // instead of `baseResponse.send()`, which would re-enter Response.send()\n // on an already-sent reply, trip the double-send guard, and drop the\n // status / content-type.\n return response.replay({\n status: content.status ?? 200,\n body: content.data,\n contentType: content.contentType,\n });\n }\n\n response.onSent((response: Response) => {\n if (!response.isOk || response.request.path !== request.path) {\n return;\n }\n\n const sentContentType = response.contentType;\n\n const content: CachedResponsePayload = {\n status: response.statusCode,\n data: except(response.parsedBody, omit),\n contentType: typeof sentContentType === \"string\" ? sentContentType : undefined,\n };\n\n // `set` is fire-and-forget inside `onSent`; without a `.catch` a rejected\n // write (e.g. Redis down) would surface as an unhandledRejection.\n cacheDriver.set(cacheKey, content, ttl).catch((error: unknown) => {\n log.error(\"cache-middleware\", \"set\", error);\n });\n });\n };\n}\n"],"mappings":";;;;;AAwDA,MAAM,sBAAuD,EAC3D,YAAY,KACd;AAMA,eAAe,kBACb,cACA,SACA;CACA,IAAI,OAAO,iBAAiB,UAC1B,eAAe,EACb,UAAU,aACZ;CAGF,IAAI,OAAO,aAAa,aAAa,YACnC,aAAa,WAAW,MAAM,aAAa,SAAS,OAAO;CAG7D,MAAM,oBAAoB;EACxB,GAAG;EACH,GAAG;CACL;CAEA,IAAI,kBAAkB,YAAY;EAChC,MAAM,SAAS,QAAQ,cAAc;EAErC,kBAAkB,WAAW,GAAG,kBAAkB,SAAS,GAAG;CAChE;CAEA,IAAI,CAAC,kBAAkB,MACrB,kBAAkB,OAAO,CAAC,QAAQ,UAAU;CAG9C,OAAO;AACT;AAEA,SAAgB,gBACd,sBACY;CAIZ,OAAO,eAAgB,EAAE,SAAS,YAAY;EAC5C,MAAM,EAAE,KAAK,MAAM,UAAU,WAAW,MAAM,kBAC5C,sBACA,OACF;EACA,MAAM,cAAc,SAAS,MAAM,MAAM,IAAI,MAAM,IAAI;EAEvD,MAAM,UAAW,MAAM,YAAY,IAAI,QAAQ;EAE/C,IAAI,SAKF,OAAO,SAAS,OAAO;GACrB,QAAQ,QAAQ,UAAU;GAC1B,MAAM,QAAQ;GACd,aAAa,QAAQ;EACvB,CAAC;EAGH,SAAS,QAAQ,aAAuB;GACtC,IAAI,CAAC,SAAS,QAAQ,SAAS,QAAQ,SAAS,QAAQ,MACtD;GAGF,MAAM,kBAAkB,SAAS;GAEjC,MAAM,UAAiC;IACrC,QAAQ,SAAS;IACjB,MAAM,OAAO,SAAS,YAAY,IAAI;IACtC,aAAa,OAAO,oBAAoB,WAAW,kBAAkB;GACvE;GAIA,YAAY,IAAI,UAAU,SAAS,GAAG,CAAC,CAAC,OAAO,UAAmB;IAChE,IAAI,MAAM,oBAAoB,OAAO,KAAK;GAC5C,CAAC;EACH,CAAC;CACH;AACF"}
@@ -40,7 +40,7 @@ function release(key) {
40
40
  * });
41
41
  */
42
42
  function concurrencyLimitMiddleware(max, options = {}) {
43
- return (request, response) => {
43
+ return ({ request, response }) => {
44
44
  const key = options.keyGenerator?.(request) || request.route.path;
45
45
  const current = counters.get(key) || 0;
46
46
  if (current >= max) {
@@ -1 +1 @@
1
- {"version":3,"file":"concurrency-limit.middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/concurrency-limit.middleware.ts"],"sourcesContent":["import type { Middleware } from \"../../router\";\nimport { HttpErrorCodes } from \"../error-codes\";\nimport type { Request } from \"../request\";\nimport type { Response } from \"../response\";\nimport { t } from \"./inject-request-context\";\n\n/**\n * Options for the concurrency limit middleware.\n */\nexport type ConcurrencyLimitOptions = {\n /**\n * Group key generator. Defaults to `request.route.path` — i.e. the cap\n * applies across all callers of that route. Override to scope per-user\n * or per-tenant.\n *\n * @example\n * keyGenerator: (request) => `${request.route.path}:${request.user?.id ?? request.ip}`,\n */\n keyGenerator?: (request: Request) => string;\n /**\n * Override the default error message.\n */\n errorMessage?: string;\n};\n\nconst counters = new Map<string, number>();\n\nfunction release(key: string) {\n const after = (counters.get(key) || 1) - 1;\n\n if (after <= 0) {\n counters.delete(key);\n\n return;\n }\n\n counters.set(key, after);\n}\n\n/**\n * Cap the number of in-flight requests against a route. Above the cap, new\n * requests get a fast 429 + `Retry-After: 1` — no queue, no timeout.\n *\n * Use for endpoints whose cost is unbounded per-request: report generation,\n * AI completions, image processing, expensive aggregations. Different from\n * `rateLimit()` — rate-limit caps requests-per-time, concurrency caps\n * in-flight requests at any instant.\n *\n * The counter is process-local. With `N` replicas the effective cap is\n * `N × max`. Document this in the route's behavior; if you need shared\n * concurrency across replicas, reach for a `@warlock.js/cache` lock instead.\n *\n * @example\n * import { middleware } from \"@warlock.js/core\";\n *\n * router.post(\"/reports/generate\", reportController, {\n * middleware: [middleware.concurrencyLimit(3)],\n * });\n *\n * router.post(\"/ai/summarize\", summarizeController, {\n * middleware: [\n * middleware.concurrencyLimit(10, {\n * keyGenerator: (request) => `ai:${request.user?.id ?? request.ip}`,\n * }),\n * ],\n * });\n */\nexport function concurrencyLimitMiddleware(\n max: number,\n options: ConcurrencyLimitOptions = {},\n): Middleware {\n return (request, response) => {\n const key = options.keyGenerator?.(request) || request.route.path;\n const current = counters.get(key) || 0;\n\n if (current >= max) {\n response.header(\"Retry-After\", 1);\n\n return response.tooManyRequests({\n error: options.errorMessage || t(\"http.concurrencyLimitReached\"),\n errorCode: HttpErrorCodes.ConcurrencyLimitReached,\n limit: max,\n },\n );\n }\n\n counters.set(key, current + 1);\n\n let released = false;\n\n const releaseOnce = () => {\n if (released) {\n return;\n }\n\n released = true;\n release(key);\n };\n\n // `onSent` only fires from Response.send()/stream().end()/sse().end().\n // Bare-reply paths (noContent, redirect, sendFile, download, sendBuffer,\n // raw send) never trigger it, so the slot would leak and the route would\n // permanently 429 after `max` such requests. Bind to the raw socket\n // lifecycle as well so the slot frees regardless of response path —\n // `releaseOnce` is idempotent, so double-firing is harmless.\n response.onSent((_sentResponse: Response) => releaseOnce());\n response.baseResponse.raw.once(\"finish\", releaseOnce);\n response.baseResponse.raw.once(\"close\", releaseOnce);\n };\n}\n"],"mappings":";;;;AAyBA,MAAM,2BAAW,IAAI,IAAoB;AAEzC,SAAS,QAAQ,KAAa;CAC5B,MAAM,SAAS,SAAS,IAAI,GAAG,KAAK,KAAK;CAEzC,IAAI,SAAS,GAAG;EACd,SAAS,OAAO,GAAG;EAEnB;CACF;CAEA,SAAS,IAAI,KAAK,KAAK;AACzB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAgB,2BACd,KACA,UAAmC,CAAC,GACxB;CACZ,QAAQ,SAAS,aAAa;EAC5B,MAAM,MAAM,QAAQ,eAAe,OAAO,KAAK,QAAQ,MAAM;EAC7D,MAAM,UAAU,SAAS,IAAI,GAAG,KAAK;EAErC,IAAI,WAAW,KAAK;GAClB,SAAS,OAAO,eAAe,CAAC;GAEhC,OAAO,SAAS,gBAAgB;IAC5B,OAAO,QAAQ,gBAAgB,EAAE,8BAA8B;IAC/D;IACA,OAAO;GACT,CACF;EACF;EAEA,SAAS,IAAI,KAAK,UAAU,CAAC;EAE7B,IAAI,WAAW;EAEf,MAAM,oBAAoB;GACxB,IAAI,UACF;GAGF,WAAW;GACX,QAAQ,GAAG;EACb;EAQA,SAAS,QAAQ,kBAA4B,YAAY,CAAC;EAC1D,SAAS,aAAa,IAAI,KAAK,UAAU,WAAW;EACpD,SAAS,aAAa,IAAI,KAAK,SAAS,WAAW;CACrD;AACF"}
1
+ {"version":3,"file":"concurrency-limit.middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/concurrency-limit.middleware.ts"],"sourcesContent":["import type { Middleware } from \"../../router\";\nimport { HttpErrorCodes } from \"../error-codes\";\nimport type { Request } from \"../request\";\nimport type { Response } from \"../response\";\nimport { t } from \"./inject-request-context\";\n\n/**\n * Options for the concurrency limit middleware.\n */\nexport type ConcurrencyLimitOptions = {\n /**\n * Group key generator. Defaults to `request.route.path` — i.e. the cap\n * applies across all callers of that route. Override to scope per-user\n * or per-tenant.\n *\n * @example\n * keyGenerator: (request) => `${request.route.path}:${request.user?.id ?? request.ip}`,\n */\n keyGenerator?: (request: Request) => string;\n /**\n * Override the default error message.\n */\n errorMessage?: string;\n};\n\nconst counters = new Map<string, number>();\n\nfunction release(key: string) {\n const after = (counters.get(key) || 1) - 1;\n\n if (after <= 0) {\n counters.delete(key);\n\n return;\n }\n\n counters.set(key, after);\n}\n\n/**\n * Cap the number of in-flight requests against a route. Above the cap, new\n * requests get a fast 429 + `Retry-After: 1` — no queue, no timeout.\n *\n * Use for endpoints whose cost is unbounded per-request: report generation,\n * AI completions, image processing, expensive aggregations. Different from\n * `rateLimit()` — rate-limit caps requests-per-time, concurrency caps\n * in-flight requests at any instant.\n *\n * The counter is process-local. With `N` replicas the effective cap is\n * `N × max`. Document this in the route's behavior; if you need shared\n * concurrency across replicas, reach for a `@warlock.js/cache` lock instead.\n *\n * @example\n * import { middleware } from \"@warlock.js/core\";\n *\n * router.post(\"/reports/generate\", reportController, {\n * middleware: [middleware.concurrencyLimit(3)],\n * });\n *\n * router.post(\"/ai/summarize\", summarizeController, {\n * middleware: [\n * middleware.concurrencyLimit(10, {\n * keyGenerator: (request) => `ai:${request.user?.id ?? request.ip}`,\n * }),\n * ],\n * });\n */\nexport function concurrencyLimitMiddleware(\n max: number,\n options: ConcurrencyLimitOptions = {},\n): Middleware {\n return ({ request, response }) => {\n const key = options.keyGenerator?.(request) || request.route.path;\n const current = counters.get(key) || 0;\n\n if (current >= max) {\n response.header(\"Retry-After\", 1);\n\n return response.tooManyRequests({\n error: options.errorMessage || t(\"http.concurrencyLimitReached\"),\n errorCode: HttpErrorCodes.ConcurrencyLimitReached,\n limit: max,\n },\n );\n }\n\n counters.set(key, current + 1);\n\n let released = false;\n\n const releaseOnce = () => {\n if (released) {\n return;\n }\n\n released = true;\n release(key);\n };\n\n // `onSent` only fires from Response.send()/stream().end()/sse().end().\n // Bare-reply paths (noContent, redirect, sendFile, download, sendBuffer,\n // raw send) never trigger it, so the slot would leak and the route would\n // permanently 429 after `max` such requests. Bind to the raw socket\n // lifecycle as well so the slot frees regardless of response path —\n // `releaseOnce` is idempotent, so double-firing is harmless.\n response.onSent((_sentResponse: Response) => releaseOnce());\n response.baseResponse.raw.once(\"finish\", releaseOnce);\n response.baseResponse.raw.once(\"close\", releaseOnce);\n };\n}\n"],"mappings":";;;;AAyBA,MAAM,2BAAW,IAAI,IAAoB;AAEzC,SAAS,QAAQ,KAAa;CAC5B,MAAM,SAAS,SAAS,IAAI,GAAG,KAAK,KAAK;CAEzC,IAAI,SAAS,GAAG;EACd,SAAS,OAAO,GAAG;EAEnB;CACF;CAEA,SAAS,IAAI,KAAK,KAAK;AACzB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAgB,2BACd,KACA,UAAmC,CAAC,GACxB;CACZ,QAAQ,EAAE,SAAS,eAAe;EAChC,MAAM,MAAM,QAAQ,eAAe,OAAO,KAAK,QAAQ,MAAM;EAC7D,MAAM,UAAU,SAAS,IAAI,GAAG,KAAK;EAErC,IAAI,WAAW,KAAK;GAClB,SAAS,OAAO,eAAe,CAAC;GAEhC,OAAO,SAAS,gBAAgB;IAC5B,OAAO,QAAQ,gBAAgB,EAAE,8BAA8B;IAC/D;IACA,OAAO;GACT,CACF;EACF;EAEA,SAAS,IAAI,KAAK,UAAU,CAAC;EAE7B,IAAI,WAAW;EAEf,MAAM,oBAAoB;GACxB,IAAI,UACF;GAGF,WAAW;GACX,QAAQ,GAAG;EACb;EAQA,SAAS,QAAQ,kBAA4B,YAAY,CAAC;EAC1D,SAAS,aAAa,IAAI,KAAK,UAAU,WAAW;EACpD,SAAS,aAAa,IAAI,KAAK,SAAS,WAAW;CACrD;AACF"}
@@ -43,7 +43,7 @@ const DEFAULT_METHODS = [
43
43
  * });
44
44
  */
45
45
  function idempotencyMiddleware(options = {}) {
46
- return async (request, response) => {
46
+ return async ({ request, response }) => {
47
47
  const headerName = options.headerName || config.get("http.idempotency.headerName", "Idempotency-Key");
48
48
  const methods = options.methods || config.get("http.idempotency.methods", DEFAULT_METHODS);
49
49
  const ttl = options.ttl || config.get("http.idempotency.ttl", 86400);
@@ -1 +1 @@
1
- {"version":3,"file":"idempotency.middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/idempotency.middleware.ts"],"sourcesContent":["import config from \"@mongez/config\";\nimport { cache } from \"@warlock.js/cache\";\nimport { log } from \"@warlock.js/logger\";\nimport type { Middleware } from \"../../router\";\nimport { HttpErrorCodes } from \"../error-codes\";\nimport type { Response } from \"../response\";\nimport { t } from \"./inject-request-context\";\nimport {\n buildIdempotencyCacheKey,\n hashBody,\n isValidIdempotencyKey,\n} from \"./utils/idempotency-key\";\n\n/**\n * Options for the idempotency middleware.\n */\nexport type IdempotencyOptions = {\n /**\n * Cache TTL in seconds. Falls back to `http.idempotency.ttl`, then `86400` (24h).\n */\n ttl?: number;\n /**\n * Header name carrying the client's key. Falls back to\n * `http.idempotency.headerName`, then `\"Idempotency-Key\"`.\n */\n headerName?: string;\n /**\n * HTTP methods eligible for idempotency. Falls back to\n * `http.idempotency.methods`, then `[\"POST\",\"PUT\",\"PATCH\",\"DELETE\"]`.\n * Safe methods (GET/HEAD) are skipped regardless.\n */\n methods?: string[];\n /**\n * Cache driver name. Falls back to `http.idempotency.driver`, then the\n * default driver of the cache manager.\n */\n driver?: string;\n};\n\ntype CachedResponse = {\n status: number;\n body: unknown;\n bodyHash: string;\n contentType?: string;\n};\n\nconst DEFAULT_METHODS = [\"POST\", \"PUT\", \"PATCH\", \"DELETE\"];\n\n/**\n * Dedupe non-idempotent writes by an `Idempotency-Key` header — same key,\n * same body, within TTL → cached replay; same key, different body → 422\n * `IdempotencyKeyConflict`.\n *\n * **Must run after `authMiddleware`** — the cache key is scoped per-user\n * (`idem:{userType}:{userId}:{key}`) so user A can't replay user B's key.\n * Anonymous requests fall back to IP scope.\n *\n * The replay sets `Idempotent-Replay: true` on the response for easy\n * client-side / observability detection.\n *\n * Eligible methods default to POST/PUT/PATCH/DELETE. GET/HEAD pass through\n * even with the header set (RFC: safe methods are already idempotent).\n *\n * @example\n * import { authMiddleware } from \"@warlock.js/auth\";\n * import { middleware } from \"@warlock.js/core\";\n *\n * router.post(\"/orders\", createOrderController, {\n * middleware: [authMiddleware(\"client\"), middleware.idempotency()],\n * });\n *\n * router.post(\"/ai/summarize\", summarizeController, {\n * middleware: [\n * authMiddleware(\"client\"),\n * middleware.idempotency({ ttl: 60 * 60 }), // 1h is enough for client retries\n * ],\n * });\n */\nexport function idempotencyMiddleware(options: IdempotencyOptions = {}): Middleware {\n return async (request, response) => {\n const headerName =\n options.headerName ||\n config.get(\"http.idempotency.headerName\", \"Idempotency-Key\");\n const methods =\n options.methods || config.get(\"http.idempotency.methods\", DEFAULT_METHODS);\n const ttl = options.ttl || config.get(\"http.idempotency.ttl\", 86400);\n const driverName = options.driver || config.get(\"http.idempotency.driver\");\n\n if (!methods.includes(request.method.toUpperCase())) return;\n\n const idempotencyKey = request.header(headerName.toLowerCase());\n\n if (!idempotencyKey) return;\n\n if (!isValidIdempotencyKey(idempotencyKey)) {\n return response.badRequest({\n error: t(\"http.idempotencyKeyInvalid\"),\n errorCode: HttpErrorCodes.IdempotencyKeyInvalid,\n });\n }\n\n const cacheDriver = driverName ? await cache.use(driverName) : cache;\n const cacheKey = buildIdempotencyCacheKey(request, idempotencyKey);\n const bodyHash = hashBody(request.body);\n\n const cached = (await cacheDriver.get(cacheKey)) as CachedResponse | null;\n\n if (cached) {\n if (cached.bodyHash !== bodyHash) {\n return response.unprocessableEntity({\n error: t(\"http.idempotencyKeyConflict\"),\n errorCode: HttpErrorCodes.IdempotencyKeyConflict,\n });\n }\n\n response.header(\"Idempotent-Replay\", \"true\");\n\n return response.replay({\n status: cached.status,\n body: cached.body,\n contentType: cached.contentType,\n });\n }\n\n response.onSent((sentResponse: Response) => {\n // Don't cache server errors — clients should be able to retry past a 5xx.\n // 4xx are deterministic outcomes of the request, so caching is fine.\n if (sentResponse.statusCode >= 500) return;\n\n const sentContentType = sentResponse.contentType;\n\n // `set` is fire-and-forget inside `onSent`; without a `.catch` a rejected\n // write (e.g. Redis down) would surface as an unhandledRejection.\n cacheDriver\n .set(\n cacheKey,\n {\n status: sentResponse.statusCode,\n body: sentResponse.parsedBody,\n bodyHash,\n contentType: typeof sentContentType === \"string\" ? sentContentType : undefined,\n },\n ttl,\n )\n .catch((error: unknown) => {\n log.error(\"idempotency-middleware\", \"set\", error);\n });\n });\n };\n}\n"],"mappings":";;;;;;;;AA8CA,MAAM,kBAAkB;CAAC;CAAQ;CAAO;CAAS;AAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCzD,SAAgB,sBAAsB,UAA8B,CAAC,GAAe;CAClF,OAAO,OAAO,SAAS,aAAa;EAClC,MAAM,aACJ,QAAQ,cACR,OAAO,IAAI,+BAA+B,iBAAiB;EAC7D,MAAM,UACJ,QAAQ,WAAW,OAAO,IAAI,4BAA4B,eAAe;EAC3E,MAAM,MAAM,QAAQ,OAAO,OAAO,IAAI,wBAAwB,KAAK;EACnE,MAAM,aAAa,QAAQ,UAAU,OAAO,IAAI,yBAAyB;EAEzE,IAAI,CAAC,QAAQ,SAAS,QAAQ,OAAO,YAAY,CAAC,GAAG;EAErD,MAAM,iBAAiB,QAAQ,OAAO,WAAW,YAAY,CAAC;EAE9D,IAAI,CAAC,gBAAgB;EAErB,IAAI,CAAC,sBAAsB,cAAc,GACvC,OAAO,SAAS,WAAW;GACzB,OAAO,EAAE,4BAA4B;GACrC;EACF,CAAC;EAGH,MAAM,cAAc,aAAa,MAAM,MAAM,IAAI,UAAU,IAAI;EAC/D,MAAM,WAAW,yBAAyB,SAAS,cAAc;EACjE,MAAM,WAAW,SAAS,QAAQ,IAAI;EAEtC,MAAM,SAAU,MAAM,YAAY,IAAI,QAAQ;EAE9C,IAAI,QAAQ;GACV,IAAI,OAAO,aAAa,UACtB,OAAO,SAAS,oBAAoB;IAClC,OAAO,EAAE,6BAA6B;IACtC;GACF,CAAC;GAGH,SAAS,OAAO,qBAAqB,MAAM;GAE3C,OAAO,SAAS,OAAO;IACrB,QAAQ,OAAO;IACf,MAAM,OAAO;IACb,aAAa,OAAO;GACtB,CAAC;EACH;EAEA,SAAS,QAAQ,iBAA2B;GAG1C,IAAI,aAAa,cAAc,KAAK;GAEpC,MAAM,kBAAkB,aAAa;GAIrC,YACG,IACC,UACA;IACE,QAAQ,aAAa;IACrB,MAAM,aAAa;IACnB;IACA,aAAa,OAAO,oBAAoB,WAAW,kBAAkB;GACvE,GACA,GACF,CAAC,CACA,OAAO,UAAmB;IACzB,IAAI,MAAM,0BAA0B,OAAO,KAAK;GAClD,CAAC;EACL,CAAC;CACH;AACF"}
1
+ {"version":3,"file":"idempotency.middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/idempotency.middleware.ts"],"sourcesContent":["import config from \"@mongez/config\";\nimport { cache } from \"@warlock.js/cache\";\nimport { log } from \"@warlock.js/logger\";\nimport type { Middleware } from \"../../router\";\nimport { HttpErrorCodes } from \"../error-codes\";\nimport type { Response } from \"../response\";\nimport { t } from \"./inject-request-context\";\nimport {\n buildIdempotencyCacheKey,\n hashBody,\n isValidIdempotencyKey,\n} from \"./utils/idempotency-key\";\n\n/**\n * Options for the idempotency middleware.\n */\nexport type IdempotencyOptions = {\n /**\n * Cache TTL in seconds. Falls back to `http.idempotency.ttl`, then `86400` (24h).\n */\n ttl?: number;\n /**\n * Header name carrying the client's key. Falls back to\n * `http.idempotency.headerName`, then `\"Idempotency-Key\"`.\n */\n headerName?: string;\n /**\n * HTTP methods eligible for idempotency. Falls back to\n * `http.idempotency.methods`, then `[\"POST\",\"PUT\",\"PATCH\",\"DELETE\"]`.\n * Safe methods (GET/HEAD) are skipped regardless.\n */\n methods?: string[];\n /**\n * Cache driver name. Falls back to `http.idempotency.driver`, then the\n * default driver of the cache manager.\n */\n driver?: string;\n};\n\ntype CachedResponse = {\n status: number;\n body: unknown;\n bodyHash: string;\n contentType?: string;\n};\n\nconst DEFAULT_METHODS = [\"POST\", \"PUT\", \"PATCH\", \"DELETE\"];\n\n/**\n * Dedupe non-idempotent writes by an `Idempotency-Key` header — same key,\n * same body, within TTL → cached replay; same key, different body → 422\n * `IdempotencyKeyConflict`.\n *\n * **Must run after `authMiddleware`** — the cache key is scoped per-user\n * (`idem:{userType}:{userId}:{key}`) so user A can't replay user B's key.\n * Anonymous requests fall back to IP scope.\n *\n * The replay sets `Idempotent-Replay: true` on the response for easy\n * client-side / observability detection.\n *\n * Eligible methods default to POST/PUT/PATCH/DELETE. GET/HEAD pass through\n * even with the header set (RFC: safe methods are already idempotent).\n *\n * @example\n * import { authMiddleware } from \"@warlock.js/auth\";\n * import { middleware } from \"@warlock.js/core\";\n *\n * router.post(\"/orders\", createOrderController, {\n * middleware: [authMiddleware(\"client\"), middleware.idempotency()],\n * });\n *\n * router.post(\"/ai/summarize\", summarizeController, {\n * middleware: [\n * authMiddleware(\"client\"),\n * middleware.idempotency({ ttl: 60 * 60 }), // 1h is enough for client retries\n * ],\n * });\n */\nexport function idempotencyMiddleware(options: IdempotencyOptions = {}): Middleware {\n return async ({ request, response }) => {\n const headerName =\n options.headerName ||\n config.get(\"http.idempotency.headerName\", \"Idempotency-Key\");\n const methods =\n options.methods || config.get(\"http.idempotency.methods\", DEFAULT_METHODS);\n const ttl = options.ttl || config.get(\"http.idempotency.ttl\", 86400);\n const driverName = options.driver || config.get(\"http.idempotency.driver\");\n\n if (!methods.includes(request.method.toUpperCase())) return;\n\n const idempotencyKey = request.header(headerName.toLowerCase());\n\n if (!idempotencyKey) return;\n\n if (!isValidIdempotencyKey(idempotencyKey)) {\n return response.badRequest({\n error: t(\"http.idempotencyKeyInvalid\"),\n errorCode: HttpErrorCodes.IdempotencyKeyInvalid,\n });\n }\n\n const cacheDriver = driverName ? await cache.use(driverName) : cache;\n const cacheKey = buildIdempotencyCacheKey(request, idempotencyKey);\n const bodyHash = hashBody(request.body);\n\n const cached = (await cacheDriver.get(cacheKey)) as CachedResponse | null;\n\n if (cached) {\n if (cached.bodyHash !== bodyHash) {\n return response.unprocessableEntity({\n error: t(\"http.idempotencyKeyConflict\"),\n errorCode: HttpErrorCodes.IdempotencyKeyConflict,\n });\n }\n\n response.header(\"Idempotent-Replay\", \"true\");\n\n return response.replay({\n status: cached.status,\n body: cached.body,\n contentType: cached.contentType,\n });\n }\n\n response.onSent((sentResponse: Response) => {\n // Don't cache server errors — clients should be able to retry past a 5xx.\n // 4xx are deterministic outcomes of the request, so caching is fine.\n if (sentResponse.statusCode >= 500) return;\n\n const sentContentType = sentResponse.contentType;\n\n // `set` is fire-and-forget inside `onSent`; without a `.catch` a rejected\n // write (e.g. Redis down) would surface as an unhandledRejection.\n cacheDriver\n .set(\n cacheKey,\n {\n status: sentResponse.statusCode,\n body: sentResponse.parsedBody,\n bodyHash,\n contentType: typeof sentContentType === \"string\" ? sentContentType : undefined,\n },\n ttl,\n )\n .catch((error: unknown) => {\n log.error(\"idempotency-middleware\", \"set\", error);\n });\n });\n };\n}\n"],"mappings":";;;;;;;;AA8CA,MAAM,kBAAkB;CAAC;CAAQ;CAAO;CAAS;AAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCzD,SAAgB,sBAAsB,UAA8B,CAAC,GAAe;CAClF,OAAO,OAAO,EAAE,SAAS,eAAe;EACtC,MAAM,aACJ,QAAQ,cACR,OAAO,IAAI,+BAA+B,iBAAiB;EAC7D,MAAM,UACJ,QAAQ,WAAW,OAAO,IAAI,4BAA4B,eAAe;EAC3E,MAAM,MAAM,QAAQ,OAAO,OAAO,IAAI,wBAAwB,KAAK;EACnE,MAAM,aAAa,QAAQ,UAAU,OAAO,IAAI,yBAAyB;EAEzE,IAAI,CAAC,QAAQ,SAAS,QAAQ,OAAO,YAAY,CAAC,GAAG;EAErD,MAAM,iBAAiB,QAAQ,OAAO,WAAW,YAAY,CAAC;EAE9D,IAAI,CAAC,gBAAgB;EAErB,IAAI,CAAC,sBAAsB,cAAc,GACvC,OAAO,SAAS,WAAW;GACzB,OAAO,EAAE,4BAA4B;GACrC;EACF,CAAC;EAGH,MAAM,cAAc,aAAa,MAAM,MAAM,IAAI,UAAU,IAAI;EAC/D,MAAM,WAAW,yBAAyB,SAAS,cAAc;EACjE,MAAM,WAAW,SAAS,QAAQ,IAAI;EAEtC,MAAM,SAAU,MAAM,YAAY,IAAI,QAAQ;EAE9C,IAAI,QAAQ;GACV,IAAI,OAAO,aAAa,UACtB,OAAO,SAAS,oBAAoB;IAClC,OAAO,EAAE,6BAA6B;IACtC;GACF,CAAC;GAGH,SAAS,OAAO,qBAAqB,MAAM;GAE3C,OAAO,SAAS,OAAO;IACrB,QAAQ,OAAO;IACf,MAAM,OAAO;IACb,aAAa,OAAO;GACtB,CAAC;EACH;EAEA,SAAS,QAAQ,iBAA2B;GAG1C,IAAI,aAAa,cAAc,KAAK;GAEpC,MAAM,kBAAkB,aAAa;GAIrC,YACG,IACC,UACA;IACE,QAAQ,aAAa;IACrB,MAAM,aAAa;IACnB;IACA,aAAa,OAAO,oBAAoB,WAAW,kBAAkB;GACvE,GACA,GACF,CAAC,CACA,OAAO,UAAmB;IACzB,IAAI,MAAM,0BAA0B,OAAO,KAAK;GAClD,CAAC;EACL,CAAC;CACH;AACF"}
@@ -5,7 +5,7 @@ import { IpFilterOptions } from "./ip-filter.middleware.mjs";
5
5
  import { MaintenanceOptions } from "./maintenance.middleware.mjs";
6
6
  import { RateLimitOptions } from "./rate-limit.middleware.mjs";
7
7
  import { middleware } from "./middleware-list.mjs";
8
- import { createRequestStore, fromRequest, t } from "./inject-request-context.mjs";
8
+ import { createRequestStore, t } from "./inject-request-context.mjs";
9
9
  import { anyMatch, ipMatches } from "./utils/cidr-match.mjs";
10
10
  import { buildIdempotencyCacheKey, hashBody, isValidIdempotencyKey } from "./utils/idempotency-key.mjs";
11
11
  import { parseSize } from "./utils/parse-size.mjs";
@@ -1,4 +1,4 @@
1
- import { createRequestStore, fromRequest, t } from "./inject-request-context.mjs";
1
+ import { createRequestStore, t } from "./inject-request-context.mjs";
2
2
  import { buildIdempotencyCacheKey, hashBody, isValidIdempotencyKey } from "./utils/idempotency-key.mjs";
3
3
  import { anyMatch, ipMatches } from "./utils/cidr-match.mjs";
4
4
  import { parseSize } from "./utils/parse-size.mjs";
@@ -13,13 +13,6 @@ declare function createRequestStore(request: Request<any>, response: Response):
13
13
  * Translate a keyword (uses request context for locale)
14
14
  */
15
15
  declare function t(keyword: string, placeholders?: any): any;
16
- /**
17
- * Get or compute a value from the request cache
18
- *
19
- * If the value exists in request, return it.
20
- * Otherwise, execute callback, store result in request, and return it.
21
- */
22
- declare function fromRequest<T>(key: string, callback: (request?: Request) => Promise<T>): Promise<T>;
23
16
  //#endregion
24
- export { createRequestStore, fromRequest, t };
17
+ export { createRequestStore, t };
25
18
  //# sourceMappingURL=inject-request-context.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"inject-request-context.d.mts","names":[],"sources":["../../../../../../../../core/src/http/middleware/inject-request-context.ts"],"mappings":";;;;;;;AAoDA;;;iBAAgB,kBAAA,CACd,OAAA,EAAS,OAAA,OACT,QAAA,EAAU,QAAA,GACT,OAAA,CAAQ,gBAAA;;;;iBA8GK,CAAA,CAAE,OAAA,UAAiB,YAAkB;;;;;;;iBAa/B,WAAA,IACpB,GAAA,UACA,QAAA,GAAW,OAAA,GAAU,OAAA,KAAY,OAAA,CAAQ,CAAA,IACxC,OAAA,CAAQ,CAAA"}
1
+ {"version":3,"file":"inject-request-context.d.mts","names":[],"sources":["../../../../../../../../core/src/http/middleware/inject-request-context.ts"],"mappings":";;;;;;;AAiDA;;;iBAAgB,kBAAA,CACd,OAAA,EAAS,OAAA,OACT,QAAA,EAAU,QAAA,GACT,OAAA,CAAQ,gBAAA;;;;iBAkHK,CAAA,CAAE,OAAA,UAAiB,YAAkB"}
@@ -1,5 +1,5 @@
1
1
  import { environment } from "../../utils/environment.mjs";
2
- import { requestContext, useRequestStore } from "../context/request-context.mjs";
2
+ import { requestContext } from "../context/request-context.mjs";
3
3
  import "../../utils/index.mjs";
4
4
  import { BadRequestError, ForbiddenError, HttpError, ResourceNotFoundError, ServerError, UnAuthorizedError } from "../errors/errors.mjs";
5
5
  import config from "@mongez/config";
@@ -45,12 +45,15 @@ function createRequestStore(request, response) {
45
45
  request.trigger("executingAction", request.route);
46
46
  const handler = request.getHandler();
47
47
  request.log("Executing Handler", "info");
48
- const output = await handler(request, response);
48
+ const output = await handler({
49
+ request,
50
+ response
51
+ });
49
52
  request.log("Handler Executed Successfully", "success");
50
53
  request.trigger("executedAction", request.route);
51
54
  return output;
52
55
  } catch (error) {
53
- request.log(`${error.constructor.name}: Request failed: ${error.message}`, "error");
56
+ request.log(error, "error");
54
57
  return handleRequestError(error, response);
55
58
  }
56
59
  });
@@ -87,11 +90,8 @@ function handleRequestError(error, response) {
87
90
  error: error.message,
88
91
  ...error.payload
89
92
  });
90
- console.log(error);
91
- return response.badRequest({
92
- error: error.message,
93
- ...error.payload
94
- });
93
+ console.error("[warlock] unhandled request error:", error);
94
+ return response.serverError({ error: "Internal server error." });
95
95
  }
96
96
  /**
97
97
  * Translate a keyword (uses request context for locale)
@@ -99,20 +99,7 @@ function handleRequestError(error, response) {
99
99
  function t(keyword, placeholders) {
100
100
  return requestContext.getRequest()?.trans(keyword, placeholders) || trans(keyword, placeholders);
101
101
  }
102
- /**
103
- * Get or compute a value from the request cache
104
- *
105
- * If the value exists in request, return it.
106
- * Otherwise, execute callback, store result in request, and return it.
107
- */
108
- async function fromRequest(key, callback) {
109
- const { request } = useRequestStore();
110
- if (!request) return await callback();
111
- if (request[key]) return request[key];
112
- request[key] = await callback(request);
113
- return request[key];
114
- }
115
102
 
116
103
  //#endregion
117
- export { createRequestStore, fromRequest, t };
104
+ export { createRequestStore, t };
118
105
  //# sourceMappingURL=inject-request-context.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"inject-request-context.mjs","names":["requestContextInstance"],"sources":["../../../../../../../../core/src/http/middleware/inject-request-context.ts"],"sourcesContent":["/**\r\n * Request Context Middleware\r\n *\r\n * Creates a unified context for each request using the ContextManager.\r\n * All framework contexts (request, storage, database) are available throughout the request lifecycle.\r\n */\r\nimport { trans } from \"@mongez/localization\";\r\nimport { GenericObject } from \"@mongez/reinforcements\";\r\nimport { DatabaseWriterValidationError } from \"@warlock.js/cascade\";\r\nimport { contextManager } from \"@warlock.js/context\";\r\nimport config from \"@mongez/config\";\r\nimport { environment } from \"../../utils\";\r\nimport {\r\n requestContext as requestContextInstance,\r\n useRequestStore,\r\n} from \"../context/request-context\";\r\nimport {\r\n BadRequestError,\r\n ForbiddenError,\r\n HttpError,\r\n ResourceNotFoundError,\r\n ServerError,\r\n UnAuthorizedError,\r\n} from \"../errors\";\r\nimport { type Request } from \"../request\";\r\nimport { type Response } from \"../response\";\r\nimport { type ReturnedResponse } from \"./../types\";\r\n\r\n// Contexts are now registered in core/context/init-contexts.ts via initializeContexts()\r\n\r\n/**\r\n * Echo `request.id` back as a response header so the FE / proxies / log\r\n * aggregators can correlate by the same value the server logs against.\r\n *\r\n * Reads the header name from `http.requestId.header` (default `X-Request-Id`).\r\n * Skip when `http.requestId.enabled` is explicitly false.\r\n */\r\nfunction stampRequestIdHeader(request: Request, response: Response) {\r\n const requestIdConfig = config.get(\"http.requestId\", {} as Record<string, any>);\r\n\r\n if (requestIdConfig.enabled === false) return;\r\n\r\n const headerName = requestIdConfig.header || \"X-Request-Id\";\r\n\r\n response.header(headerName, request.id);\r\n}\r\n\r\n/**\r\n * Create request store and execute middleware + handler\r\n *\r\n * Runs all registered contexts together using ContextManager.\r\n */\r\nexport function createRequestStore(\r\n request: Request<any>,\r\n response: Response,\r\n): Promise<ReturnedResponse> {\r\n stampRequestIdHeader(request, response);\r\n\r\n // Build all context stores using the immutable API\r\n // Each context defines its own store initialization via buildStore()\r\n const httpContextStore = contextManager.buildStores({ request, response });\r\n\r\n // Run all contexts together!\r\n return contextManager.runAll(httpContextStore, async () => {\r\n try {\r\n // Run middleware chain\r\n const result = await request.runMiddleware();\r\n\r\n if (result) {\r\n return result as ReturnedResponse;\r\n }\r\n\r\n // Execute route handler\r\n request.trigger(\"executingAction\", request.route);\r\n\r\n const handler = request.getHandler();\r\n\r\n request.log(\"Executing Handler\", \"info\");\r\n\r\n const output = await handler(request, response);\r\n\r\n request.log(\"Handler Executed Successfully\", \"success\");\r\n\r\n request.trigger(\"executedAction\", request.route);\r\n\r\n return output as ReturnedResponse;\r\n } catch (error: any) {\r\n request.log(`${error.constructor.name}: Request failed: ${error.message}`, \"error\");\r\n return handleRequestError(error, response);\r\n }\r\n });\r\n}\r\n\r\n/**\r\n * Handle request errors\r\n * @internal\r\n */\r\nfunction handleRequestError(error: any, response: Response): ReturnedResponse {\r\n if (error instanceof HttpError) {\r\n const payload: GenericObject = {\r\n error: error.message,\r\n };\r\n if (error.payload) {\r\n payload.payload = error.payload;\r\n }\r\n\r\n if (environment() === \"development\") {\r\n payload.stack = error.stack;\r\n }\r\n\r\n return response.setStatusCode(error.status).send(payload);\r\n }\r\n\r\n if (error instanceof ResourceNotFoundError) {\r\n return response.notFound({\r\n error: error.message,\r\n ...error.payload,\r\n });\r\n }\r\n\r\n if (error instanceof UnAuthorizedError) {\r\n return response.unauthorized({\r\n error: error.message,\r\n ...error.payload,\r\n });\r\n }\r\n\r\n if (error instanceof ForbiddenError) {\r\n return response.forbidden({\r\n error: error.message,\r\n ...error.payload,\r\n });\r\n }\r\n\r\n if (error instanceof BadRequestError) {\r\n return response.badRequest({\r\n error: error.message,\r\n ...error.payload,\r\n });\r\n }\r\n\r\n if (error instanceof DatabaseWriterValidationError) {\r\n return response.badRequest({\r\n errors: error.errors,\r\n });\r\n }\r\n\r\n if (error instanceof ServerError) {\r\n return response.serverError({\r\n error: error.message,\r\n ...error.payload,\r\n });\r\n }\r\n\r\n console.log(error);\r\n\r\n return response.badRequest({\r\n error: error.message,\r\n ...error.payload,\r\n });\r\n}\r\n\r\n/**\r\n * Translate a keyword (uses request context for locale)\r\n */\r\nexport function t(keyword: string, placeholders?: any) {\r\n return (\r\n requestContextInstance.getRequest()?.trans(keyword, placeholders) ||\r\n trans(keyword, placeholders)\r\n );\r\n}\r\n\r\n/**\r\n * Get or compute a value from the request cache\r\n *\r\n * If the value exists in request, return it.\r\n * Otherwise, execute callback, store result in request, and return it.\r\n */\r\nexport async function fromRequest<T>(\r\n key: string,\r\n callback: (request?: Request) => Promise<T>,\r\n): Promise<T> {\r\n const { request } = useRequestStore();\r\n\r\n if (!request) return await callback();\r\n\r\n if (request[key]) return request[key];\r\n\r\n request[key] = await callback(request);\r\n\r\n return request[key];\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAqCA,SAAS,qBAAqB,SAAkB,UAAoB;CAClE,MAAM,kBAAkB,OAAO,IAAI,kBAAkB,CAAC,CAAwB;CAE9E,IAAI,gBAAgB,YAAY,OAAO;CAEvC,MAAM,aAAa,gBAAgB,UAAU;CAE7C,SAAS,OAAO,YAAY,QAAQ,EAAE;AACxC;;;;;;AAOA,SAAgB,mBACd,SACA,UAC2B;CAC3B,qBAAqB,SAAS,QAAQ;CAItC,MAAM,mBAAmB,eAAe,YAAY;EAAE;EAAS;CAAS,CAAC;CAGzE,OAAO,eAAe,OAAO,kBAAkB,YAAY;EACzD,IAAI;GAEF,MAAM,SAAS,MAAM,QAAQ,cAAc;GAE3C,IAAI,QACF,OAAO;GAIT,QAAQ,QAAQ,mBAAmB,QAAQ,KAAK;GAEhD,MAAM,UAAU,QAAQ,WAAW;GAEnC,QAAQ,IAAI,qBAAqB,MAAM;GAEvC,MAAM,SAAS,MAAM,QAAQ,SAAS,QAAQ;GAE9C,QAAQ,IAAI,iCAAiC,SAAS;GAEtD,QAAQ,QAAQ,kBAAkB,QAAQ,KAAK;GAE/C,OAAO;EACT,SAAS,OAAY;GACnB,QAAQ,IAAI,GAAG,MAAM,YAAY,KAAK,oBAAoB,MAAM,WAAW,OAAO;GAClF,OAAO,mBAAmB,OAAO,QAAQ;EAC3C;CACF,CAAC;AACH;;;;;AAMA,SAAS,mBAAmB,OAAY,UAAsC;CAC5E,IAAI,iBAAiB,WAAW;EAC9B,MAAM,UAAyB,EAC7B,OAAO,MAAM,QACf;EACA,IAAI,MAAM,SACR,QAAQ,UAAU,MAAM;EAG1B,IAAI,YAAY,MAAM,eACpB,QAAQ,QAAQ,MAAM;EAGxB,OAAO,SAAS,cAAc,MAAM,MAAM,CAAC,CAAC,KAAK,OAAO;CAC1D;CAEA,IAAI,iBAAiB,uBACnB,OAAO,SAAS,SAAS;EACvB,OAAO,MAAM;EACb,GAAG,MAAM;CACX,CAAC;CAGH,IAAI,iBAAiB,mBACnB,OAAO,SAAS,aAAa;EAC3B,OAAO,MAAM;EACb,GAAG,MAAM;CACX,CAAC;CAGH,IAAI,iBAAiB,gBACnB,OAAO,SAAS,UAAU;EACxB,OAAO,MAAM;EACb,GAAG,MAAM;CACX,CAAC;CAGH,IAAI,iBAAiB,iBACnB,OAAO,SAAS,WAAW;EACzB,OAAO,MAAM;EACb,GAAG,MAAM;CACX,CAAC;CAGH,IAAI,iBAAiB,+BACnB,OAAO,SAAS,WAAW,EACzB,QAAQ,MAAM,OAChB,CAAC;CAGH,IAAI,iBAAiB,aACnB,OAAO,SAAS,YAAY;EAC1B,OAAO,MAAM;EACb,GAAG,MAAM;CACX,CAAC;CAGH,QAAQ,IAAI,KAAK;CAEjB,OAAO,SAAS,WAAW;EACzB,OAAO,MAAM;EACb,GAAG,MAAM;CACX,CAAC;AACH;;;;AAKA,SAAgB,EAAE,SAAiB,cAAoB;CACrD,OACEA,eAAuB,WAAW,CAAC,EAAE,MAAM,SAAS,YAAY,KAChE,MAAM,SAAS,YAAY;AAE/B;;;;;;;AAQA,eAAsB,YACpB,KACA,UACY;CACZ,MAAM,EAAE,YAAY,gBAAgB;CAEpC,IAAI,CAAC,SAAS,OAAO,MAAM,SAAS;CAEpC,IAAI,QAAQ,MAAM,OAAO,QAAQ;CAEjC,QAAQ,OAAO,MAAM,SAAS,OAAO;CAErC,OAAO,QAAQ;AACjB"}
1
+ {"version":3,"file":"inject-request-context.mjs","names":["requestContextInstance"],"sources":["../../../../../../../../core/src/http/middleware/inject-request-context.ts"],"sourcesContent":["/**\r\n * Request Context Middleware\r\n *\r\n * Creates a unified context for each request using the ContextManager.\r\n * All framework contexts (request, storage, database) are available throughout the request lifecycle.\r\n */\r\nimport { trans } from \"@mongez/localization\";\r\nimport { GenericObject } from \"@mongez/reinforcements\";\r\nimport { DatabaseWriterValidationError } from \"@warlock.js/cascade\";\r\nimport { contextManager } from \"@warlock.js/context\";\r\nimport config from \"@mongez/config\";\r\nimport { environment } from \"../../utils\";\r\nimport { requestContext as requestContextInstance } from \"../context/request-context\";\r\nimport {\r\n BadRequestError,\r\n ForbiddenError,\r\n HttpError,\r\n ResourceNotFoundError,\r\n ServerError,\r\n UnAuthorizedError,\r\n} from \"../errors\";\r\nimport { type Request } from \"../request\";\r\nimport { type Response } from \"../response\";\r\nimport { type ReturnedResponse } from \"./../types\";\r\n\r\n// Contexts are now registered in core/context/init-contexts.ts via initializeContexts()\r\n\r\n/**\r\n * Echo `request.id` back as a response header so the FE / proxies / log\r\n * aggregators can correlate by the same value the server logs against.\r\n *\r\n * Reads the header name from `http.requestId.header` (default `X-Request-Id`).\r\n * Skip when `http.requestId.enabled` is explicitly false.\r\n */\r\nfunction stampRequestIdHeader(request: Request, response: Response) {\r\n const requestIdConfig = config.get(\"http.requestId\", {} as Record<string, any>);\r\n\r\n if (requestIdConfig.enabled === false) return;\r\n\r\n const headerName = requestIdConfig.header || \"X-Request-Id\";\r\n\r\n response.header(headerName, request.id);\r\n}\r\n\r\n/**\r\n * Create request store and execute middleware + handler\r\n *\r\n * Runs all registered contexts together using ContextManager.\r\n */\r\nexport function createRequestStore(\r\n request: Request<any>,\r\n response: Response,\r\n): Promise<ReturnedResponse> {\r\n stampRequestIdHeader(request, response);\r\n\r\n // Build all context stores using the immutable API\r\n // Each context defines its own store initialization via buildStore()\r\n const httpContextStore = contextManager.buildStores({ request, response });\r\n\r\n // Run all contexts together!\r\n return contextManager.runAll(httpContextStore, async () => {\r\n try {\r\n // Run middleware chain\r\n const result = await request.runMiddleware();\r\n\r\n if (result) {\r\n return result as ReturnedResponse;\r\n }\r\n\r\n // Execute route handler\r\n request.trigger(\"executingAction\", request.route);\r\n\r\n const handler = request.getHandler();\r\n\r\n request.log(\"Executing Handler\", \"info\");\r\n\r\n const output = await handler({ request, response });\r\n\r\n request.log(\"Handler Executed Successfully\", \"success\");\r\n\r\n request.trigger(\"executedAction\", request.route);\r\n\r\n return output as ReturnedResponse;\r\n } catch (error) {\r\n request.log(error, \"error\");\r\n return handleRequestError(error, response);\r\n }\r\n });\r\n}\r\n\r\n/**\r\n * Handle request errors\r\n * @internal\r\n */\r\nfunction handleRequestError(error: unknown, response: Response): ReturnedResponse {\r\n if (error instanceof HttpError) {\r\n const payload: GenericObject = {\r\n error: error.message,\r\n };\r\n if (error.payload) {\r\n payload.payload = error.payload;\r\n }\r\n\r\n if (environment() === \"development\") {\r\n payload.stack = error.stack;\r\n }\r\n\r\n return response.setStatusCode(error.status).send(payload);\r\n }\r\n\r\n if (error instanceof ResourceNotFoundError) {\r\n return response.notFound({\r\n error: error.message,\r\n ...error.payload,\r\n });\r\n }\r\n\r\n if (error instanceof UnAuthorizedError) {\r\n return response.unauthorized({\r\n error: error.message,\r\n ...error.payload,\r\n });\r\n }\r\n\r\n if (error instanceof ForbiddenError) {\r\n return response.forbidden({\r\n error: error.message,\r\n ...error.payload,\r\n });\r\n }\r\n\r\n if (error instanceof BadRequestError) {\r\n return response.badRequest({\r\n error: error.message,\r\n ...error.payload,\r\n });\r\n }\r\n\r\n if (error instanceof DatabaseWriterValidationError) {\r\n return response.badRequest({\r\n errors: error.errors,\r\n });\r\n }\r\n\r\n if (error instanceof ServerError) {\r\n return response.serverError({\r\n error: error.message,\r\n ...error.payload,\r\n });\r\n }\r\n\r\n // Last resort: the error matched none of the known shapes above, so the\r\n // client gets a deliberately opaque message. Without this line the error\r\n // itself is discarded here — no stack, no message, nothing in any log — and\r\n // an unrecognised failure becomes indistinguishable from a working server\r\n // returning 500. Never swallow the only copy of an error (`65e476ee`).\r\n console.error(\"[warlock] unhandled request error:\", error);\r\n\r\n return response.serverError({\r\n error: \"Internal server error.\",\r\n });\r\n}\r\n\r\n/**\r\n * Translate a keyword (uses request context for locale)\r\n */\r\nexport function t(keyword: string, placeholders?: any) {\r\n return (\r\n requestContextInstance.getRequest()?.trans(keyword, placeholders) ||\r\n trans(keyword, placeholders)\r\n );\r\n}\r\n\r\n// `fromRequest` was removed in v5. It cached computed values as dynamic\r\n// properties on the Request instance, which only compiled because of the\r\n// `[key: string]: any` index signature that v5 deletes (eed20184). Use\r\n// `requestMemo(key, fn)` from `../context/request-memo` instead same\r\n// per-request lifetime, single-flight, and it never touches the Request object.\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAkCA,SAAS,qBAAqB,SAAkB,UAAoB;CAClE,MAAM,kBAAkB,OAAO,IAAI,kBAAkB,CAAC,CAAwB;CAE9E,IAAI,gBAAgB,YAAY,OAAO;CAEvC,MAAM,aAAa,gBAAgB,UAAU;CAE7C,SAAS,OAAO,YAAY,QAAQ,EAAE;AACxC;;;;;;AAOA,SAAgB,mBACd,SACA,UAC2B;CAC3B,qBAAqB,SAAS,QAAQ;CAItC,MAAM,mBAAmB,eAAe,YAAY;EAAE;EAAS;CAAS,CAAC;CAGzE,OAAO,eAAe,OAAO,kBAAkB,YAAY;EACzD,IAAI;GAEF,MAAM,SAAS,MAAM,QAAQ,cAAc;GAE3C,IAAI,QACF,OAAO;GAIT,QAAQ,QAAQ,mBAAmB,QAAQ,KAAK;GAEhD,MAAM,UAAU,QAAQ,WAAW;GAEnC,QAAQ,IAAI,qBAAqB,MAAM;GAEvC,MAAM,SAAS,MAAM,QAAQ;IAAE;IAAS;GAAS,CAAC;GAElD,QAAQ,IAAI,iCAAiC,SAAS;GAEtD,QAAQ,QAAQ,kBAAkB,QAAQ,KAAK;GAE/C,OAAO;EACT,SAAS,OAAO;GACd,QAAQ,IAAI,OAAO,OAAO;GAC1B,OAAO,mBAAmB,OAAO,QAAQ;EAC3C;CACF,CAAC;AACH;;;;;AAMA,SAAS,mBAAmB,OAAgB,UAAsC;CAChF,IAAI,iBAAiB,WAAW;EAC9B,MAAM,UAAyB,EAC7B,OAAO,MAAM,QACf;EACA,IAAI,MAAM,SACR,QAAQ,UAAU,MAAM;EAG1B,IAAI,YAAY,MAAM,eACpB,QAAQ,QAAQ,MAAM;EAGxB,OAAO,SAAS,cAAc,MAAM,MAAM,CAAC,CAAC,KAAK,OAAO;CAC1D;CAEA,IAAI,iBAAiB,uBACnB,OAAO,SAAS,SAAS;EACvB,OAAO,MAAM;EACb,GAAG,MAAM;CACX,CAAC;CAGH,IAAI,iBAAiB,mBACnB,OAAO,SAAS,aAAa;EAC3B,OAAO,MAAM;EACb,GAAG,MAAM;CACX,CAAC;CAGH,IAAI,iBAAiB,gBACnB,OAAO,SAAS,UAAU;EACxB,OAAO,MAAM;EACb,GAAG,MAAM;CACX,CAAC;CAGH,IAAI,iBAAiB,iBACnB,OAAO,SAAS,WAAW;EACzB,OAAO,MAAM;EACb,GAAG,MAAM;CACX,CAAC;CAGH,IAAI,iBAAiB,+BACnB,OAAO,SAAS,WAAW,EACzB,QAAQ,MAAM,OAChB,CAAC;CAGH,IAAI,iBAAiB,aACnB,OAAO,SAAS,YAAY;EAC1B,OAAO,MAAM;EACb,GAAG,MAAM;CACX,CAAC;CAQH,QAAQ,MAAM,sCAAsC,KAAK;CAEzD,OAAO,SAAS,YAAY,EAC1B,OAAO,yBACT,CAAC;AACH;;;;AAKA,SAAgB,EAAE,SAAiB,cAAoB;CACrD,OACEA,eAAuB,WAAW,CAAC,EAAE,MAAM,SAAS,YAAY,KAChE,MAAM,SAAS,YAAY;AAE/B"}
@@ -32,7 +32,7 @@ import { anyMatch } from "./utils/cidr-match.mjs";
32
32
  * });
33
33
  */
34
34
  function ipFilterMiddleware(options) {
35
- return (request, response) => {
35
+ return ({ request, response }) => {
36
36
  const ip = request.detectIp();
37
37
  if (!ip || typeof ip !== "string") return response.forbidden({
38
38
  error: options.errorMessage || t("http.ipForbidden"),
@@ -1 +1 @@
1
- {"version":3,"file":"ip-filter.middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/ip-filter.middleware.ts"],"sourcesContent":["import type { Middleware } from \"../../router\";\nimport { HttpErrorCodes } from \"../error-codes\";\nimport { t } from \"./inject-request-context\";\nimport { anyMatch } from \"./utils/cidr-match\";\n\n/**\n * Options for the IP filter middleware. Use at least one of `allow` / `deny`.\n *\n * Precedence: `deny` wins. If a request matches both lists it is denied.\n */\nexport type IpFilterOptions = {\n /**\n * Allowlist of exact IPv4 / IPv6 strings or IPv4 CIDR blocks. When present,\n * only IPs matching the list pass through.\n */\n allow?: string[];\n /**\n * Denylist of exact IPv4 / IPv6 strings or IPv4 CIDR blocks. Matched IPs\n * are rejected regardless of the allowlist.\n */\n deny?: string[];\n /**\n * Override the default error message.\n */\n errorMessage?: string;\n};\n\n/**\n * Allow / deny requests by client IP. Fail-closed: if the IP can't be read,\n * the request is rejected with 403.\n *\n * Reads the client IP via `request.detectIp()`, which honors the forwarding\n * headers only when `http.trustProxy` is set — otherwise the socket peer\n * address is used, since both headers are client-settable. When opting in,\n * prefer a bounded `http.trustProxy` (hop count or CIDR list of your proxies)\n * over `true`: with `true`, any client that can reach the process directly\n * picks its own IP and this allowlist is decorative.\n *\n * @example\n * import { middleware } from \"@warlock.js/core\";\n *\n * router.group(\n * {\n * prefix: \"/admin\",\n * middleware: [middleware.ipFilter({ allow: [\"10.0.0.0/8\", \"203.0.113.42\"] })],\n * },\n * () => {\n * router.get(\"/dashboard\", dashboardController);\n * },\n * );\n *\n * router.post(\"/webhooks/provider\", webhookController, {\n * middleware: [middleware.ipFilter({ allow: [\"198.51.100.0/24\"] })],\n * });\n */\nexport function ipFilterMiddleware(options: IpFilterOptions): Middleware {\n return (request, response) => {\n const ip = request.detectIp();\n\n if (!ip || typeof ip !== \"string\") {\n return response.forbidden({\n error: options.errorMessage || t(\"http.ipForbidden\"),\n errorCode: HttpErrorCodes.IpForbidden,\n });\n }\n\n if (options.deny && anyMatch(ip, options.deny)) {\n return response.forbidden({\n error: options.errorMessage || t(\"http.ipForbidden\"),\n errorCode: HttpErrorCodes.IpForbidden,\n });\n }\n\n if (options.allow && !anyMatch(ip, options.allow)) {\n return response.forbidden({\n error: options.errorMessage || t(\"http.ipForbidden\"),\n errorCode: HttpErrorCodes.IpForbidden,\n });\n }\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuDA,SAAgB,mBAAmB,SAAsC;CACvE,QAAQ,SAAS,aAAa;EAC5B,MAAM,KAAK,QAAQ,SAAS;EAE5B,IAAI,CAAC,MAAM,OAAO,OAAO,UACvB,OAAO,SAAS,UAAU;GACxB,OAAO,QAAQ,gBAAgB,EAAE,kBAAkB;GACnD;EACF,CAAC;EAGH,IAAI,QAAQ,QAAQ,SAAS,IAAI,QAAQ,IAAI,GAC3C,OAAO,SAAS,UAAU;GACxB,OAAO,QAAQ,gBAAgB,EAAE,kBAAkB;GACnD;EACF,CAAC;EAGH,IAAI,QAAQ,SAAS,CAAC,SAAS,IAAI,QAAQ,KAAK,GAC9C,OAAO,SAAS,UAAU;GACxB,OAAO,QAAQ,gBAAgB,EAAE,kBAAkB;GACnD;EACF,CAAC;CAEL;AACF"}
1
+ {"version":3,"file":"ip-filter.middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/ip-filter.middleware.ts"],"sourcesContent":["import type { Middleware } from \"../../router\";\nimport { HttpErrorCodes } from \"../error-codes\";\nimport { t } from \"./inject-request-context\";\nimport { anyMatch } from \"./utils/cidr-match\";\n\n/**\n * Options for the IP filter middleware. Use at least one of `allow` / `deny`.\n *\n * Precedence: `deny` wins. If a request matches both lists it is denied.\n */\nexport type IpFilterOptions = {\n /**\n * Allowlist of exact IPv4 / IPv6 strings or IPv4 CIDR blocks. When present,\n * only IPs matching the list pass through.\n */\n allow?: string[];\n /**\n * Denylist of exact IPv4 / IPv6 strings or IPv4 CIDR blocks. Matched IPs\n * are rejected regardless of the allowlist.\n */\n deny?: string[];\n /**\n * Override the default error message.\n */\n errorMessage?: string;\n};\n\n/**\n * Allow / deny requests by client IP. Fail-closed: if the IP can't be read,\n * the request is rejected with 403.\n *\n * Reads the client IP via `request.detectIp()`, which honors the forwarding\n * headers only when `http.trustProxy` is set — otherwise the socket peer\n * address is used, since both headers are client-settable. When opting in,\n * prefer a bounded `http.trustProxy` (hop count or CIDR list of your proxies)\n * over `true`: with `true`, any client that can reach the process directly\n * picks its own IP and this allowlist is decorative.\n *\n * @example\n * import { middleware } from \"@warlock.js/core\";\n *\n * router.group(\n * {\n * prefix: \"/admin\",\n * middleware: [middleware.ipFilter({ allow: [\"10.0.0.0/8\", \"203.0.113.42\"] })],\n * },\n * () => {\n * router.get(\"/dashboard\", dashboardController);\n * },\n * );\n *\n * router.post(\"/webhooks/provider\", webhookController, {\n * middleware: [middleware.ipFilter({ allow: [\"198.51.100.0/24\"] })],\n * });\n */\nexport function ipFilterMiddleware(options: IpFilterOptions): Middleware {\n return ({ request, response }) => {\n const ip = request.detectIp();\n\n if (!ip || typeof ip !== \"string\") {\n return response.forbidden({\n error: options.errorMessage || t(\"http.ipForbidden\"),\n errorCode: HttpErrorCodes.IpForbidden,\n });\n }\n\n if (options.deny && anyMatch(ip, options.deny)) {\n return response.forbidden({\n error: options.errorMessage || t(\"http.ipForbidden\"),\n errorCode: HttpErrorCodes.IpForbidden,\n });\n }\n\n if (options.allow && !anyMatch(ip, options.allow)) {\n return response.forbidden({\n error: options.errorMessage || t(\"http.ipForbidden\"),\n errorCode: HttpErrorCodes.IpForbidden,\n });\n }\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuDA,SAAgB,mBAAmB,SAAsC;CACvE,QAAQ,EAAE,SAAS,eAAe;EAChC,MAAM,KAAK,QAAQ,SAAS;EAE5B,IAAI,CAAC,MAAM,OAAO,OAAO,UACvB,OAAO,SAAS,UAAU;GACxB,OAAO,QAAQ,gBAAgB,EAAE,kBAAkB;GACnD;EACF,CAAC;EAGH,IAAI,QAAQ,QAAQ,SAAS,IAAI,QAAQ,IAAI,GAC3C,OAAO,SAAS,UAAU;GACxB,OAAO,QAAQ,gBAAgB,EAAE,kBAAkB;GACnD;EACF,CAAC;EAGH,IAAI,QAAQ,SAAS,CAAC,SAAS,IAAI,QAAQ,KAAK,GAC9C,OAAO,SAAS,UAAU;GACxB,OAAO,QAAQ,gBAAgB,EAAE,kBAAkB;GACnD;EACF,CAAC;CAEL;AACF"}
@@ -30,7 +30,7 @@ function isAllowlisted(path, patterns) {
30
30
  * };
31
31
  */
32
32
  function maintenanceMiddleware(options = {}) {
33
- return (request, response) => {
33
+ return ({ request, response }) => {
34
34
  if (!config.get("http.maintenance.enabled", false)) return;
35
35
  const allowlist = options.allowlist || config.get("http.maintenance.allowlist", ["/health"]);
36
36
  const pathname = request.path.split("?")[0];
@@ -1 +1 @@
1
- {"version":3,"file":"maintenance.middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/maintenance.middleware.ts"],"sourcesContent":["import config from \"@mongez/config\";\nimport type { Middleware } from \"../../router\";\nimport { HttpErrorCodes } from \"../error-codes\";\nimport { t } from \"./inject-request-context\";\n\n/**\n * Options for the maintenance middleware.\n */\nexport type MaintenanceOptions = {\n /**\n * Path prefixes (ending in `*`) or exact paths to bypass even when\n * maintenance is on. Falls back to `http.maintenance.allowlist`, then\n * `[\"/health\"]`.\n *\n * @example\n * allowlist: [\"/health\", \"/admin/*\", \"/webhooks/stripe\"]\n */\n allowlist?: string[];\n /**\n * Seconds advertised in the `Retry-After` header. Falls back to\n * `http.maintenance.retryAfter`, then `60`.\n */\n retryAfter?: number;\n /**\n * Override the default error message.\n */\n errorMessage?: string;\n};\n\nfunction isAllowlisted(path: string, patterns: string[]) {\n return patterns.some((pattern) => {\n if (pattern.endsWith(\"*\")) {\n return path.startsWith(pattern.slice(0, -1));\n }\n\n return path === pattern;\n });\n}\n\n/**\n * Return 503 + `Retry-After` for every request when `http.maintenance.enabled`\n * is true, except for paths matching the allowlist.\n *\n * Designed for app-wide registration via `http.middleware.all`. Toggled via\n * config — flipping the flag requires a restart (no runtime hot-flip yet).\n * Allowlist defaults to `[\"/health\"]` so health checks still pass during\n * planned downtime.\n *\n * @example\n * // src/config/http.ts\n * import { middleware } from \"@warlock.js/core\";\n *\n * export default {\n * maintenance: { enabled: env(\"MAINTENANCE_MODE\") === \"true\" },\n * middleware: {\n * all: [middleware.maintenance({ allowlist: [\"/health\", \"/admin/*\"] })],\n * },\n * };\n */\nexport function maintenanceMiddleware(options: MaintenanceOptions = {}): Middleware {\n return (request, response) => {\n const enabled = config.get(\"http.maintenance.enabled\", false);\n\n if (!enabled) return;\n\n const allowlist =\n options.allowlist || config.get(\"http.maintenance.allowlist\", [\"/health\"]);\n\n // `request.path` includes the query string, but allowlist entries are\n // path-only (\"/webhooks/stripe\"), so strip the query before matching —\n // otherwise \"/webhooks/stripe?sig=...\" never matches its exact entry.\n const pathname = request.path.split(\"?\")[0];\n\n if (isAllowlisted(pathname, allowlist)) return;\n\n const retryAfter =\n options.retryAfter || config.get(\"http.maintenance.retryAfter\", 60);\n\n response.header(\"Retry-After\", retryAfter);\n\n return response.serviceUnavailable({\n error: options.errorMessage || t(\"http.maintenance\"),\n errorCode: HttpErrorCodes.Maintenance,\n });\n };\n}\n"],"mappings":";;;;;AA6BA,SAAS,cAAc,MAAc,UAAoB;CACvD,OAAO,SAAS,MAAM,YAAY;EAChC,IAAI,QAAQ,SAAS,GAAG,GACtB,OAAO,KAAK,WAAW,QAAQ,MAAM,GAAG,EAAE,CAAC;EAG7C,OAAO,SAAS;CAClB,CAAC;AACH;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,sBAAsB,UAA8B,CAAC,GAAe;CAClF,QAAQ,SAAS,aAAa;EAG5B,IAAI,CAFY,OAAO,IAAI,4BAA4B,KAE5C,GAAG;EAEd,MAAM,YACJ,QAAQ,aAAa,OAAO,IAAI,8BAA8B,CAAC,SAAS,CAAC;EAK3E,MAAM,WAAW,QAAQ,KAAK,MAAM,GAAG,CAAC,CAAC;EAEzC,IAAI,cAAc,UAAU,SAAS,GAAG;EAExC,MAAM,aACJ,QAAQ,cAAc,OAAO,IAAI,+BAA+B,EAAE;EAEpE,SAAS,OAAO,eAAe,UAAU;EAEzC,OAAO,SAAS,mBAAmB;GACjC,OAAO,QAAQ,gBAAgB,EAAE,kBAAkB;GACnD;EACF,CAAC;CACH;AACF"}
1
+ {"version":3,"file":"maintenance.middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/maintenance.middleware.ts"],"sourcesContent":["import config from \"@mongez/config\";\nimport type { Middleware } from \"../../router\";\nimport { HttpErrorCodes } from \"../error-codes\";\nimport { t } from \"./inject-request-context\";\n\n/**\n * Options for the maintenance middleware.\n */\nexport type MaintenanceOptions = {\n /**\n * Path prefixes (ending in `*`) or exact paths to bypass even when\n * maintenance is on. Falls back to `http.maintenance.allowlist`, then\n * `[\"/health\"]`.\n *\n * @example\n * allowlist: [\"/health\", \"/admin/*\", \"/webhooks/stripe\"]\n */\n allowlist?: string[];\n /**\n * Seconds advertised in the `Retry-After` header. Falls back to\n * `http.maintenance.retryAfter`, then `60`.\n */\n retryAfter?: number;\n /**\n * Override the default error message.\n */\n errorMessage?: string;\n};\n\nfunction isAllowlisted(path: string, patterns: string[]) {\n return patterns.some((pattern) => {\n if (pattern.endsWith(\"*\")) {\n return path.startsWith(pattern.slice(0, -1));\n }\n\n return path === pattern;\n });\n}\n\n/**\n * Return 503 + `Retry-After` for every request when `http.maintenance.enabled`\n * is true, except for paths matching the allowlist.\n *\n * Designed for app-wide registration via `http.middleware.all`. Toggled via\n * config — flipping the flag requires a restart (no runtime hot-flip yet).\n * Allowlist defaults to `[\"/health\"]` so health checks still pass during\n * planned downtime.\n *\n * @example\n * // src/config/http.ts\n * import { middleware } from \"@warlock.js/core\";\n *\n * export default {\n * maintenance: { enabled: env(\"MAINTENANCE_MODE\") === \"true\" },\n * middleware: {\n * all: [middleware.maintenance({ allowlist: [\"/health\", \"/admin/*\"] })],\n * },\n * };\n */\nexport function maintenanceMiddleware(options: MaintenanceOptions = {}): Middleware {\n return ({ request, response }) => {\n const enabled = config.get(\"http.maintenance.enabled\", false);\n\n if (!enabled) return;\n\n const allowlist =\n options.allowlist || config.get(\"http.maintenance.allowlist\", [\"/health\"]);\n\n // `request.path` includes the query string, but allowlist entries are\n // path-only (\"/webhooks/stripe\"), so strip the query before matching —\n // otherwise \"/webhooks/stripe?sig=...\" never matches its exact entry.\n const pathname = request.path.split(\"?\")[0];\n\n if (isAllowlisted(pathname, allowlist)) return;\n\n const retryAfter =\n options.retryAfter || config.get(\"http.maintenance.retryAfter\", 60);\n\n response.header(\"Retry-After\", retryAfter);\n\n return response.serviceUnavailable({\n error: options.errorMessage || t(\"http.maintenance\"),\n errorCode: HttpErrorCodes.Maintenance,\n });\n };\n}\n"],"mappings":";;;;;AA6BA,SAAS,cAAc,MAAc,UAAoB;CACvD,OAAO,SAAS,MAAM,YAAY;EAChC,IAAI,QAAQ,SAAS,GAAG,GACtB,OAAO,KAAK,WAAW,QAAQ,MAAM,GAAG,EAAE,CAAC;EAG7C,OAAO,SAAS;CAClB,CAAC;AACH;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,sBAAsB,UAA8B,CAAC,GAAe;CAClF,QAAQ,EAAE,SAAS,eAAe;EAGhC,IAAI,CAFY,OAAO,IAAI,4BAA4B,KAE5C,GAAG;EAEd,MAAM,YACJ,QAAQ,aAAa,OAAO,IAAI,8BAA8B,CAAC,SAAS,CAAC;EAK3E,MAAM,WAAW,QAAQ,KAAK,MAAM,GAAG,CAAC,CAAC;EAEzC,IAAI,cAAc,UAAU,SAAS,GAAG;EAExC,MAAM,aACJ,QAAQ,cAAc,OAAO,IAAI,+BAA+B,EAAE;EAEpE,SAAS,OAAO,eAAe,UAAU;EAEzC,OAAO,SAAS,mBAAmB;GACjC,OAAO,QAAQ,gBAAgB,EAAE,kBAAkB;GACnD;EACF,CAAC;CACH;AACF"}
@@ -33,7 +33,7 @@ import { parseSize } from "./utils/parse-size.mjs";
33
33
  */
34
34
  function maxBodySizeMiddleware(limit) {
35
35
  const limitBytes = parseSize(limit);
36
- return (request, response) => {
36
+ return ({ request, response }) => {
37
37
  const contentLengthHeader = request.header("content-length");
38
38
  if (!contentLengthHeader) return;
39
39
  const contentLength = Number(contentLengthHeader);
@@ -1 +1 @@
1
- {"version":3,"file":"max-body-size.middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/max-body-size.middleware.ts"],"sourcesContent":["import type { Middleware } from \"../../router\";\nimport { HttpErrorCodes } from \"../error-codes\";\nimport { t } from \"./inject-request-context\";\nimport { parseSize } from \"./utils/parse-size\";\n\n/**\n * Reject requests whose `Content-Length` exceeds the configured cap.\n *\n * ⚠️ **This runs AFTER the body has been parsed, not before it.** Framework\n * middleware executes inside the Fastify route handler, and `@fastify/multipart`\n * is registered with `attachFieldsToBody: true` — so by the time this sees the\n * request, the bytes are already resident. It inspects `Content-Length` and\n * answers 413; it does not and cannot prevent the read.\n *\n * **So this is not a resource-exhaustion control.** For that, lower\n * `http.bodyLimit`, or set `bodyLimit` in the route's own `serverOptions` —\n * Fastify enforces those while reading. This middleware is a per-route\n * convenience for returning a consistent 413, not a replacement for either.\n *\n * The same caveat applies to `concurrencyLimit`: it bounds how many requests\n * run at once, not how many bytes are resident.\n *\n * @example\n * import { middleware } from \"@warlock.js/core\";\n *\n * router.post(\"/comments\", createCommentController, {\n * middleware: [middleware.maxBodySize(\"8kb\")],\n * });\n *\n * router.post(\"/uploads\", uploadController, {\n * middleware: [middleware.maxBodySize(\"10mb\")],\n * });\n */\nexport function maxBodySizeMiddleware(limit: string | number): Middleware {\n const limitBytes = parseSize(limit);\n\n return (request, response) => {\n const contentLengthHeader = request.header(\"content-length\");\n\n if (!contentLengthHeader) return;\n\n const contentLength = Number(contentLengthHeader);\n\n if (!Number.isFinite(contentLength)) return;\n\n if (contentLength <= limitBytes) return;\n\n response.header(\"Connection\", \"close\");\n\n return response.contentTooLarge({\n error: t(\"http.bodyTooLarge\"),\n errorCode: HttpErrorCodes.BodyTooLarge,\n limit: limitBytes,\n received: contentLength,\n },\n );\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,SAAgB,sBAAsB,OAAoC;CACxE,MAAM,aAAa,UAAU,KAAK;CAElC,QAAQ,SAAS,aAAa;EAC5B,MAAM,sBAAsB,QAAQ,OAAO,gBAAgB;EAE3D,IAAI,CAAC,qBAAqB;EAE1B,MAAM,gBAAgB,OAAO,mBAAmB;EAEhD,IAAI,CAAC,OAAO,SAAS,aAAa,GAAG;EAErC,IAAI,iBAAiB,YAAY;EAEjC,SAAS,OAAO,cAAc,OAAO;EAErC,OAAO,SAAS,gBAAgB;GAC5B,OAAO,EAAE,mBAAmB;GAC5B;GACA,OAAO;GACP,UAAU;EACZ,CACF;CACF;AACF"}
1
+ {"version":3,"file":"max-body-size.middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/max-body-size.middleware.ts"],"sourcesContent":["import type { Middleware } from \"../../router\";\nimport { HttpErrorCodes } from \"../error-codes\";\nimport { t } from \"./inject-request-context\";\nimport { parseSize } from \"./utils/parse-size\";\n\n/**\n * Reject requests whose `Content-Length` exceeds the configured cap.\n *\n * ⚠️ **This runs AFTER the body has been parsed, not before it.** Framework\n * middleware executes inside the Fastify route handler, and `@fastify/multipart`\n * is registered with `attachFieldsToBody: true` — so by the time this sees the\n * request, the bytes are already resident. It inspects `Content-Length` and\n * answers 413; it does not and cannot prevent the read.\n *\n * **So this is not a resource-exhaustion control.** For that, lower\n * `http.bodyLimit`, or set `bodyLimit` in the route's own `serverOptions` —\n * Fastify enforces those while reading. This middleware is a per-route\n * convenience for returning a consistent 413, not a replacement for either.\n *\n * The same caveat applies to `concurrencyLimit`: it bounds how many requests\n * run at once, not how many bytes are resident.\n *\n * @example\n * import { middleware } from \"@warlock.js/core\";\n *\n * router.post(\"/comments\", createCommentController, {\n * middleware: [middleware.maxBodySize(\"8kb\")],\n * });\n *\n * router.post(\"/uploads\", uploadController, {\n * middleware: [middleware.maxBodySize(\"10mb\")],\n * });\n */\nexport function maxBodySizeMiddleware(limit: string | number): Middleware {\n const limitBytes = parseSize(limit);\n\n return ({ request, response }) => {\n const contentLengthHeader = request.header(\"content-length\");\n\n if (!contentLengthHeader) return;\n\n const contentLength = Number(contentLengthHeader);\n\n if (!Number.isFinite(contentLength)) return;\n\n if (contentLength <= limitBytes) return;\n\n response.header(\"Connection\", \"close\");\n\n return response.contentTooLarge({\n error: t(\"http.bodyTooLarge\"),\n errorCode: HttpErrorCodes.BodyTooLarge,\n limit: limitBytes,\n received: contentLength,\n },\n );\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,SAAgB,sBAAsB,OAAoC;CACxE,MAAM,aAAa,UAAU,KAAK;CAElC,QAAQ,EAAE,SAAS,eAAe;EAChC,MAAM,sBAAsB,QAAQ,OAAO,gBAAgB;EAE3D,IAAI,CAAC,qBAAqB;EAE1B,MAAM,gBAAgB,OAAO,mBAAmB;EAEhD,IAAI,CAAC,OAAO,SAAS,aAAa,GAAG;EAErC,IAAI,iBAAiB,YAAY;EAEjC,SAAS,OAAO,cAAc,OAAO;EAErC,OAAO,SAAS,gBAAgB;GAC5B,OAAO,EAAE,mBAAmB;GAC5B;GACA,OAAO;GACP,UAAU;EACZ,CACF;CACF;AACF"}
@@ -33,7 +33,7 @@ function pruneExpired(now) {
33
33
  * });
34
34
  */
35
35
  function rateLimitMiddleware(options) {
36
- return (request, response) => {
36
+ return ({ request, response }) => {
37
37
  const now = Date.now();
38
38
  if (now - lastPruneAt > 6e4) {
39
39
  pruneExpired(now);
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limit.middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/rate-limit.middleware.ts"],"sourcesContent":["import type { Middleware } from \"../../router\";\nimport { HttpErrorCodes } from \"../error-codes\";\nimport type { Request } from \"../request\";\nimport { t } from \"./inject-request-context\";\n\n/**\n * Options for the per-route rate limit middleware.\n */\nexport type RateLimitOptions = {\n /**\n * Max requests allowed within the time window.\n */\n max: number;\n /**\n * Time window in milliseconds.\n */\n duration: number;\n /**\n * Group key generator. Defaults to the client IP. Override to scope per-user,\n * per-organization, per-tenant, etc.\n *\n * @example\n * keyGenerator: (request) => request.user?.id ?? request.ip,\n */\n keyGenerator?: (request: Request) => string;\n /**\n * Override the default error message.\n */\n errorMessage?: string;\n};\n\ntype Bucket = {\n count: number;\n resetAt: number;\n};\n\nconst buckets = new Map<string, Bucket>();\n\n/**\n * Sweep expired buckets so the Map doesn't grow unbounded with one-shot keys\n * (rare per-user keys, dynamic path parameters in `keyGenerator`, etc.).\n * Called opportunistically, at most once per minute, not on a timer — keeps\n * the module side-effect-free for testing.\n */\nlet lastPruneAt = 0;\n\nfunction pruneExpired(now: number) {\n for (const [key, bucket] of buckets) {\n if (bucket.resetAt <= now) {\n buckets.delete(key);\n }\n }\n}\n\n/**\n * Per-route / per-group rate limit. Layers on top of `@fastify/rate-limit`'s\n * global cap (configured via `http.rateLimit`) — both run, and either can\n * 429. Use this for endpoints that need a tighter cap than the global default:\n * login, OTP request, password reset, expensive search, AI completions.\n *\n * The counter lives in-process. With `N` replicas the effective cap is\n * `N × max`. For genuinely shared limits, configure `@fastify/rate-limit`'s\n * Redis store via `http.rateLimit` instead.\n *\n * Sets `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` on\n * every response; adds `Retry-After` (seconds) on a 429.\n *\n * @example\n * import { middleware } from \"@warlock.js/core\";\n * router.post(\"/auth/login\", loginController, {\n * middleware: [middleware.rateLimit({ max: 5, duration: 60_000 })],\n * });\n */\nexport function rateLimitMiddleware(options: RateLimitOptions): Middleware {\n return (request, response) => {\n const now = Date.now();\n\n if (now - lastPruneAt > 60_000) {\n pruneExpired(now);\n lastPruneAt = now;\n }\n\n const groupKey = options.keyGenerator?.(request) || request.detectIp() || \"unknown\";\n const cacheKey = `${request.route.path}:${groupKey}`;\n\n let bucket = buckets.get(cacheKey);\n\n if (!bucket || bucket.resetAt <= now) {\n bucket = { count: 0, resetAt: now + options.duration };\n buckets.set(cacheKey, bucket);\n }\n\n bucket.count++;\n\n const remaining = Math.max(0, options.max - bucket.count);\n const retryAfter = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));\n\n response.header(\"X-RateLimit-Limit\", options.max);\n response.header(\"X-RateLimit-Remaining\", remaining);\n response.header(\"X-RateLimit-Reset\", Math.ceil(bucket.resetAt / 1000));\n\n if (bucket.count > options.max) {\n response.header(\"Retry-After\", retryAfter);\n\n return response.tooManyRequests({\n error: options.errorMessage || t(\"http.rateLimitExceeded\"),\n errorCode: HttpErrorCodes.RateLimitExceeded,\n },\n );\n }\n };\n}\n"],"mappings":";;;;AAoCA,MAAM,0BAAU,IAAI,IAAoB;;;;;;;AAQxC,IAAI,cAAc;AAElB,SAAS,aAAa,KAAa;CACjC,KAAK,MAAM,CAAC,KAAK,WAAW,SAC1B,IAAI,OAAO,WAAW,KACpB,QAAQ,OAAO,GAAG;AAGxB;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,oBAAoB,SAAuC;CACzE,QAAQ,SAAS,aAAa;EAC5B,MAAM,MAAM,KAAK,IAAI;EAErB,IAAI,MAAM,cAAc,KAAQ;GAC9B,aAAa,GAAG;GAChB,cAAc;EAChB;EAEA,MAAM,WAAW,QAAQ,eAAe,OAAO,KAAK,QAAQ,SAAS,KAAK;EAC1E,MAAM,WAAW,GAAG,QAAQ,MAAM,KAAK,GAAG;EAE1C,IAAI,SAAS,QAAQ,IAAI,QAAQ;EAEjC,IAAI,CAAC,UAAU,OAAO,WAAW,KAAK;GACpC,SAAS;IAAE,OAAO;IAAG,SAAS,MAAM,QAAQ;GAAS;GACrD,QAAQ,IAAI,UAAU,MAAM;EAC9B;EAEA,OAAO;EAEP,MAAM,YAAY,KAAK,IAAI,GAAG,QAAQ,MAAM,OAAO,KAAK;EACxD,MAAM,aAAa,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,UAAU,OAAO,GAAI,CAAC;EAEvE,SAAS,OAAO,qBAAqB,QAAQ,GAAG;EAChD,SAAS,OAAO,yBAAyB,SAAS;EAClD,SAAS,OAAO,qBAAqB,KAAK,KAAK,OAAO,UAAU,GAAI,CAAC;EAErE,IAAI,OAAO,QAAQ,QAAQ,KAAK;GAC9B,SAAS,OAAO,eAAe,UAAU;GAEzC,OAAO,SAAS,gBAAgB;IAC5B,OAAO,QAAQ,gBAAgB,EAAE,wBAAwB;IACzD;GACF,CACF;EACF;CACF;AACF"}
1
+ {"version":3,"file":"rate-limit.middleware.mjs","names":[],"sources":["../../../../../../../../core/src/http/middleware/rate-limit.middleware.ts"],"sourcesContent":["import type { Middleware } from \"../../router\";\nimport { HttpErrorCodes } from \"../error-codes\";\nimport type { Request } from \"../request\";\nimport { t } from \"./inject-request-context\";\n\n/**\n * Options for the per-route rate limit middleware.\n */\nexport type RateLimitOptions = {\n /**\n * Max requests allowed within the time window.\n */\n max: number;\n /**\n * Time window in milliseconds.\n */\n duration: number;\n /**\n * Group key generator. Defaults to the client IP. Override to scope per-user,\n * per-organization, per-tenant, etc.\n *\n * @example\n * keyGenerator: (request) => request.user?.id ?? request.ip,\n */\n keyGenerator?: (request: Request) => string;\n /**\n * Override the default error message.\n */\n errorMessage?: string;\n};\n\ntype Bucket = {\n count: number;\n resetAt: number;\n};\n\nconst buckets = new Map<string, Bucket>();\n\n/**\n * Sweep expired buckets so the Map doesn't grow unbounded with one-shot keys\n * (rare per-user keys, dynamic path parameters in `keyGenerator`, etc.).\n * Called opportunistically, at most once per minute, not on a timer — keeps\n * the module side-effect-free for testing.\n */\nlet lastPruneAt = 0;\n\nfunction pruneExpired(now: number) {\n for (const [key, bucket] of buckets) {\n if (bucket.resetAt <= now) {\n buckets.delete(key);\n }\n }\n}\n\n/**\n * Per-route / per-group rate limit. Layers on top of `@fastify/rate-limit`'s\n * global cap (configured via `http.rateLimit`) — both run, and either can\n * 429. Use this for endpoints that need a tighter cap than the global default:\n * login, OTP request, password reset, expensive search, AI completions.\n *\n * The counter lives in-process. With `N` replicas the effective cap is\n * `N × max`. For genuinely shared limits, configure `@fastify/rate-limit`'s\n * Redis store via `http.rateLimit` instead.\n *\n * Sets `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` on\n * every response; adds `Retry-After` (seconds) on a 429.\n *\n * @example\n * import { middleware } from \"@warlock.js/core\";\n * router.post(\"/auth/login\", loginController, {\n * middleware: [middleware.rateLimit({ max: 5, duration: 60_000 })],\n * });\n */\nexport function rateLimitMiddleware(options: RateLimitOptions): Middleware {\n return ({ request, response }) => {\n const now = Date.now();\n\n if (now - lastPruneAt > 60_000) {\n pruneExpired(now);\n lastPruneAt = now;\n }\n\n const groupKey = options.keyGenerator?.(request) || request.detectIp() || \"unknown\";\n const cacheKey = `${request.route.path}:${groupKey}`;\n\n let bucket = buckets.get(cacheKey);\n\n if (!bucket || bucket.resetAt <= now) {\n bucket = { count: 0, resetAt: now + options.duration };\n buckets.set(cacheKey, bucket);\n }\n\n bucket.count++;\n\n const remaining = Math.max(0, options.max - bucket.count);\n const retryAfter = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));\n\n response.header(\"X-RateLimit-Limit\", options.max);\n response.header(\"X-RateLimit-Remaining\", remaining);\n response.header(\"X-RateLimit-Reset\", Math.ceil(bucket.resetAt / 1000));\n\n if (bucket.count > options.max) {\n response.header(\"Retry-After\", retryAfter);\n\n return response.tooManyRequests({\n error: options.errorMessage || t(\"http.rateLimitExceeded\"),\n errorCode: HttpErrorCodes.RateLimitExceeded,\n },\n );\n }\n };\n}\n"],"mappings":";;;;AAoCA,MAAM,0BAAU,IAAI,IAAoB;;;;;;;AAQxC,IAAI,cAAc;AAElB,SAAS,aAAa,KAAa;CACjC,KAAK,MAAM,CAAC,KAAK,WAAW,SAC1B,IAAI,OAAO,WAAW,KACpB,QAAQ,OAAO,GAAG;AAGxB;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,oBAAoB,SAAuC;CACzE,QAAQ,EAAE,SAAS,eAAe;EAChC,MAAM,MAAM,KAAK,IAAI;EAErB,IAAI,MAAM,cAAc,KAAQ;GAC9B,aAAa,GAAG;GAChB,cAAc;EAChB;EAEA,MAAM,WAAW,QAAQ,eAAe,OAAO,KAAK,QAAQ,SAAS,KAAK;EAC1E,MAAM,WAAW,GAAG,QAAQ,MAAM,KAAK,GAAG;EAE1C,IAAI,SAAS,QAAQ,IAAI,QAAQ;EAEjC,IAAI,CAAC,UAAU,OAAO,WAAW,KAAK;GACpC,SAAS;IAAE,OAAO;IAAG,SAAS,MAAM,QAAQ;GAAS;GACrD,QAAQ,IAAI,UAAU,MAAM;EAC9B;EAEA,OAAO;EAEP,MAAM,YAAY,KAAK,IAAI,GAAG,QAAQ,MAAM,OAAO,KAAK;EACxD,MAAM,aAAa,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,UAAU,OAAO,GAAI,CAAC;EAEvE,SAAS,OAAO,qBAAqB,QAAQ,GAAG;EAChD,SAAS,OAAO,yBAAyB,SAAS;EAClD,SAAS,OAAO,qBAAqB,KAAK,KAAK,OAAO,UAAU,GAAI,CAAC;EAErE,IAAI,OAAO,QAAQ,QAAQ,KAAK;GAC9B,SAAS,OAAO,eAAe,UAAU;GAEzC,OAAO,SAAS,gBAAgB;IAC5B,OAAO,QAAQ,gBAAgB,EAAE,wBAAwB;IACzD;GACF,CACF;EACF;CACF;AACF"}
@@ -13,20 +13,6 @@ declare function isValidIdempotencyKey(value: unknown): value is string;
13
13
  * hashBody({ amount: 100, currency: "USD" }); // "a1b2c3..."
14
14
  */
15
15
  declare function hashBody(body: unknown): string;
16
- /**
17
- * Build the cache key for an idempotency record.
18
- *
19
- * Scope is `userType:userId` when authenticated, falling back to the client IP
20
- * when anonymous. This prevents user A from replaying user B's key — even if
21
- * user B used a guessable value — while still letting the primitive work on
22
- * public endpoints.
23
- *
24
- * Idempotency middleware must run **after** `authMiddleware` so `request.user`
25
- * and `request.decodedAccessToken` are populated.
26
- *
27
- * @example
28
- * buildIdempotencyCacheKey(request, "01J9XZQ-ABC"); // "idem:client:user_123:01J9XZQ-ABC"
29
- */
30
16
  declare function buildIdempotencyCacheKey(request: Request, idempotencyKey: string): string;
31
17
  //#endregion
32
18
  export { buildIdempotencyCacheKey, hashBody, isValidIdempotencyKey };
@@ -1 +1 @@
1
- {"version":3,"file":"idempotency-key.d.mts","names":[],"sources":["../../../../../../../../../core/src/http/middleware/utils/idempotency-key.ts"],"mappings":";;;iBAcgB,qBAAA,CAAsB,KAAA,YAAiB,KAAK;;AAA5D;;;;AAA4D;AAmB5D;;;;iBAAgB,QAAA,CAAS,IAAa;AAoBtC;;;;;;;;AAAiF;;;;;;AAAjF,iBAAgB,wBAAA,CAAyB,OAAA,EAAS,OAAO,EAAE,cAAA"}
1
+ {"version":3,"file":"idempotency-key.d.mts","names":[],"sources":["../../../../../../../../../core/src/http/middleware/utils/idempotency-key.ts"],"mappings":";;;iBAegB,qBAAA,CAAsB,KAAA,YAAiB,KAAK;;AAA5D;;;;AAA4D;AAmB5D;;;;iBAAgB,QAAA,CAAS,IAAa;AAAA,iBAyCtB,wBAAA,CAAyB,OAAA,EAAS,OAAO,EAAE,cAAA"}
@@ -42,8 +42,26 @@ function hashBody(body) {
42
42
  * @example
43
43
  * buildIdempotencyCacheKey(request, "01J9XZQ-ABC"); // "idem:client:user_123:01J9XZQ-ABC"
44
44
  */
45
+ /**
46
+ * Read `id` off `request.user` without assuming every app's `RequestUser`
47
+ * augmentation declares it.
48
+ *
49
+ * `RequestUser` (`core/src/http/types.ts`) is empty by default — apps narrow
50
+ * it to their own model shape. Adding `id` directly to `RequestUser` here
51
+ * would force that exact field (and type) onto every app: TypeScript
52
+ * interface merging requires all declarations of a shared member to have an
53
+ * identical type, so an app augmenting `RequestUser` with, say, `id: string`
54
+ * only would conflict with a core-declared `id?: string | number`. A local,
55
+ * narrow read survives any augmentation shape (eed20184 step (b) —
56
+ * `implementation/2026-08-20-A2-request-locals.md` §6.2) — no `as any`.
57
+ */
58
+ function readUserId(user) {
59
+ if (!user || typeof user !== "object" || !("id" in user)) return void 0;
60
+ const id = user.id;
61
+ return typeof id === "string" || typeof id === "number" ? id : void 0;
62
+ }
45
63
  function buildIdempotencyCacheKey(request, idempotencyKey) {
46
- return `idem:${request.decodedAccessToken?.userType || "anonymous"}:${request.user?.id || request.detectIp() || "unknown"}:${idempotencyKey}`;
64
+ return `idem:${request.decodedAccessToken?.userType || "anonymous"}:${readUserId(request.user) || request.detectIp() || "unknown"}:${idempotencyKey}`;
47
65
  }
48
66
 
49
67
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"idempotency-key.mjs","names":[],"sources":["../../../../../../../../../core/src/http/middleware/utils/idempotency-key.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport type { Request } from \"../../request\";\n\n/**\n * Idempotency-key validation rules.\n *\n * RFC draft-ietf-httpapi-idempotency-key suggests UUIDs or ULIDs but doesn't\n * mandate a format. We accept any printable ASCII string up to 255 chars —\n * tight enough to reject log-injection (no control characters / newlines),\n * loose enough to accept whatever ID scheme the client picks.\n */\nconst MAX_KEY_LENGTH = 255;\nconst PRINTABLE_ASCII = /^[\\x21-\\x7e]+$/;\n\nexport function isValidIdempotencyKey(value: unknown): value is string {\n return (\n typeof value === \"string\" &&\n value.length > 0 &&\n value.length <= MAX_KEY_LENGTH &&\n PRINTABLE_ASCII.test(value)\n );\n}\n\n/**\n * Hash a request body to a stable hex digest.\n *\n * Used to detect \"same key, different body\" — that's a client bug, not a retry.\n * sha256 is overkill for collision resistance here, but it's the dependency-free\n * choice and avoids importing a faster hasher.\n *\n * @example\n * hashBody({ amount: 100, currency: \"USD\" }); // \"a1b2c3...\"\n */\nexport function hashBody(body: unknown): string {\n const serialized = typeof body === \"string\" ? body : JSON.stringify(body ?? {});\n\n return createHash(\"sha256\").update(serialized).digest(\"hex\");\n}\n\n/**\n * Build the cache key for an idempotency record.\n *\n * Scope is `userType:userId` when authenticated, falling back to the client IP\n * when anonymous. This prevents user A from replaying user B's key — even if\n * user B used a guessable value — while still letting the primitive work on\n * public endpoints.\n *\n * Idempotency middleware must run **after** `authMiddleware` so `request.user`\n * and `request.decodedAccessToken` are populated.\n *\n * @example\n * buildIdempotencyCacheKey(request, \"01J9XZQ-ABC\"); // \"idem:client:user_123:01J9XZQ-ABC\"\n */\nexport function buildIdempotencyCacheKey(request: Request, idempotencyKey: string): string {\n const userType = request.decodedAccessToken?.userType || \"anonymous\";\n const userId = request.user?.id || request.detectIp() || \"unknown\";\n\n return `idem:${userType}:${userId}:${idempotencyKey}`;\n}\n"],"mappings":";;;;;;;;;;;AAWA,MAAM,iBAAiB;AACvB,MAAM,kBAAkB;AAExB,SAAgB,sBAAsB,OAAiC;CACrE,OACE,OAAO,UAAU,YACjB,MAAM,SAAS,KACf,MAAM,UAAU,kBAChB,gBAAgB,KAAK,KAAK;AAE9B;;;;;;;;;;;AAYA,SAAgB,SAAS,MAAuB;CAC9C,MAAM,aAAa,OAAO,SAAS,WAAW,OAAO,KAAK,UAAU,QAAQ,CAAC,CAAC;CAE9E,OAAO,WAAW,QAAQ,CAAC,CAAC,OAAO,UAAU,CAAC,CAAC,OAAO,KAAK;AAC7D;;;;;;;;;;;;;;;AAgBA,SAAgB,yBAAyB,SAAkB,gBAAgC;CAIzF,OAAO,QAHU,QAAQ,oBAAoB,YAAY,YAGjC,GAFT,QAAQ,MAAM,MAAM,QAAQ,SAAS,KAAK,UAEvB,GAAG;AACvC"}
1
+ {"version":3,"file":"idempotency-key.mjs","names":[],"sources":["../../../../../../../../../core/src/http/middleware/utils/idempotency-key.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport type { Request } from \"../../request\";\nimport type { RequestUser } from \"../../types\";\n\n/**\n * Idempotency-key validation rules.\n *\n * RFC draft-ietf-httpapi-idempotency-key suggests UUIDs or ULIDs but doesn't\n * mandate a format. We accept any printable ASCII string up to 255 chars —\n * tight enough to reject log-injection (no control characters / newlines),\n * loose enough to accept whatever ID scheme the client picks.\n */\nconst MAX_KEY_LENGTH = 255;\nconst PRINTABLE_ASCII = /^[\\x21-\\x7e]+$/;\n\nexport function isValidIdempotencyKey(value: unknown): value is string {\n return (\n typeof value === \"string\" &&\n value.length > 0 &&\n value.length <= MAX_KEY_LENGTH &&\n PRINTABLE_ASCII.test(value)\n );\n}\n\n/**\n * Hash a request body to a stable hex digest.\n *\n * Used to detect \"same key, different body\" — that's a client bug, not a retry.\n * sha256 is overkill for collision resistance here, but it's the dependency-free\n * choice and avoids importing a faster hasher.\n *\n * @example\n * hashBody({ amount: 100, currency: \"USD\" }); // \"a1b2c3...\"\n */\nexport function hashBody(body: unknown): string {\n const serialized = typeof body === \"string\" ? body : JSON.stringify(body ?? {});\n\n return createHash(\"sha256\").update(serialized).digest(\"hex\");\n}\n\n/**\n * Build the cache key for an idempotency record.\n *\n * Scope is `userType:userId` when authenticated, falling back to the client IP\n * when anonymous. This prevents user A from replaying user B's key — even if\n * user B used a guessable value — while still letting the primitive work on\n * public endpoints.\n *\n * Idempotency middleware must run **after** `authMiddleware` so `request.user`\n * and `request.decodedAccessToken` are populated.\n *\n * @example\n * buildIdempotencyCacheKey(request, \"01J9XZQ-ABC\"); // \"idem:client:user_123:01J9XZQ-ABC\"\n */\n/**\n * Read `id` off `request.user` without assuming every app's `RequestUser`\n * augmentation declares it.\n *\n * `RequestUser` (`core/src/http/types.ts`) is empty by default — apps narrow\n * it to their own model shape. Adding `id` directly to `RequestUser` here\n * would force that exact field (and type) onto every app: TypeScript\n * interface merging requires all declarations of a shared member to have an\n * identical type, so an app augmenting `RequestUser` with, say, `id: string`\n * only would conflict with a core-declared `id?: string | number`. A local,\n * narrow read survives any augmentation shape (eed20184 step (b) —\n * `implementation/2026-08-20-A2-request-locals.md` §6.2) — no `as any`.\n */\nfunction readUserId(user: RequestUser | undefined): string | number | undefined {\n if (!user || typeof user !== \"object\" || !(\"id\" in user)) return undefined;\n\n const id = (user as { id?: unknown }).id;\n\n return typeof id === \"string\" || typeof id === \"number\" ? id : undefined;\n}\n\nexport function buildIdempotencyCacheKey(request: Request, idempotencyKey: string): string {\n const userType = request.decodedAccessToken?.userType || \"anonymous\";\n const userId = readUserId(request.user) || request.detectIp() || \"unknown\";\n\n return `idem:${userType}:${userId}:${idempotencyKey}`;\n}\n"],"mappings":";;;;;;;;;;;AAYA,MAAM,iBAAiB;AACvB,MAAM,kBAAkB;AAExB,SAAgB,sBAAsB,OAAiC;CACrE,OACE,OAAO,UAAU,YACjB,MAAM,SAAS,KACf,MAAM,UAAU,kBAChB,gBAAgB,KAAK,KAAK;AAE9B;;;;;;;;;;;AAYA,SAAgB,SAAS,MAAuB;CAC9C,MAAM,aAAa,OAAO,SAAS,WAAW,OAAO,KAAK,UAAU,QAAQ,CAAC,CAAC;CAE9E,OAAO,WAAW,QAAQ,CAAC,CAAC,OAAO,UAAU,CAAC,CAAC,OAAO,KAAK;AAC7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,SAAS,WAAW,MAA4D;CAC9E,IAAI,CAAC,QAAQ,OAAO,SAAS,YAAY,EAAE,QAAQ,OAAO,OAAO;CAEjE,MAAM,KAAM,KAA0B;CAEtC,OAAO,OAAO,OAAO,YAAY,OAAO,OAAO,WAAW,KAAK;AACjE;AAEA,SAAgB,yBAAyB,SAAkB,gBAAgC;CAIzF,OAAO,QAHU,QAAQ,oBAAoB,YAAY,YAGjC,GAFT,WAAW,QAAQ,IAAI,KAAK,QAAQ,SAAS,KAAK,UAE/B,GAAG;AACvC"}