@warlock.js/core 5.20.0 → 5.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/CHANGELOG.md +1130 -1086
  2. package/esm/benchmark/benchmark.mjs +34 -25
  3. package/esm/benchmark/benchmark.mjs.map +1 -1
  4. package/esm/benchmark/profiler.d.mts.map +1 -1
  5. package/esm/benchmark/profiler.mjs +8 -3
  6. package/esm/benchmark/profiler.mjs.map +1 -1
  7. package/esm/bootstrap.d.mts.map +1 -1
  8. package/esm/bootstrap.mjs +3 -0
  9. package/esm/bootstrap.mjs.map +1 -1
  10. package/esm/cache/database-cache-driver.d.mts +2 -2
  11. package/esm/cache/database-cache-driver.d.mts.map +1 -1
  12. package/esm/cache/database-cache-driver.mjs +3 -2
  13. package/esm/cache/database-cache-driver.mjs.map +1 -1
  14. package/esm/cli/cli-commands.manager.mjs +16 -14
  15. package/esm/cli/cli-commands.manager.mjs.map +1 -1
  16. package/esm/cli/commands/add.command.mjs +1 -1
  17. package/esm/cli/commands/add.command.mjs.map +1 -1
  18. package/esm/cli/commands/doctor/checks/optional-peers.check.mjs +3 -2
  19. package/esm/cli/commands/doctor/checks/optional-peers.check.mjs.map +1 -1
  20. package/esm/cli/commands/generate/generate.command.mjs +20 -0
  21. package/esm/cli/commands/generate/generate.command.mjs.map +1 -1
  22. package/esm/cli/commands/generate/generators/column-dsl-parser.mjs +4 -2
  23. package/esm/cli/commands/generate/generators/column-dsl-parser.mjs.map +1 -1
  24. package/esm/cli/commands/generate/generators/controller.generator.mjs +1 -1
  25. package/esm/cli/commands/generate/generators/controller.generator.mjs.map +1 -1
  26. package/esm/cli/commands/generate/generators/migration.generator.mjs +22 -6
  27. package/esm/cli/commands/generate/generators/migration.generator.mjs.map +1 -1
  28. package/esm/cli/commands/generate/generators/model.generator.mjs +22 -7
  29. package/esm/cli/commands/generate/generators/model.generator.mjs.map +1 -1
  30. package/esm/cli/commands/generate/generators/module.generator.mjs +8 -6
  31. package/esm/cli/commands/generate/generators/module.generator.mjs.map +1 -1
  32. package/esm/cli/commands/generate/generators/repository.generator.mjs +1 -1
  33. package/esm/cli/commands/generate/generators/repository.generator.mjs.map +1 -1
  34. package/esm/cli/commands/generate/generators/resource.generator.mjs +1 -1
  35. package/esm/cli/commands/generate/generators/resource.generator.mjs.map +1 -1
  36. package/esm/cli/commands/generate/generators/service.generator.mjs +1 -1
  37. package/esm/cli/commands/generate/generators/service.generator.mjs.map +1 -1
  38. package/esm/cli/commands/generate/templates/stubs.mjs +7 -7
  39. package/esm/cli/commands/generate/templates/stubs.mjs.map +1 -1
  40. package/esm/cli/commands/generate/utils/writer.mjs +25 -1
  41. package/esm/cli/commands/generate/utils/writer.mjs.map +1 -1
  42. package/esm/cli/commands/routes/routes-command.action.mjs +5 -1
  43. package/esm/cli/commands/routes/routes-command.action.mjs.map +1 -1
  44. package/esm/cli/commands/routes/with-console-on-stderr.mjs +24 -0
  45. package/esm/cli/commands/routes/with-console-on-stderr.mjs.map +1 -0
  46. package/esm/cli/commands/routes.command.mjs +3 -4
  47. package/esm/cli/commands/routes.command.mjs.map +1 -1
  48. package/esm/cli/commands/start-production.command.mjs +5 -5
  49. package/esm/cli/commands/start-production.command.mjs.map +1 -1
  50. package/esm/cli/commands/storage-put.action.mjs +2 -1
  51. package/esm/cli/commands/storage-put.action.mjs.map +1 -1
  52. package/esm/cli/commands/update.command.mjs +1 -0
  53. package/esm/cli/commands/update.command.mjs.map +1 -1
  54. package/esm/cli/parse-cli-args.mjs +13 -1
  55. package/esm/cli/parse-cli-args.mjs.map +1 -1
  56. package/esm/commands/cli-command.d.mts.map +1 -1
  57. package/esm/commands/cli-command.mjs +4 -2
  58. package/esm/commands/cli-command.mjs.map +1 -1
  59. package/esm/commands/index.mjs +3 -0
  60. package/esm/config/config-key-from-path.mjs +20 -0
  61. package/esm/config/config-key-from-path.mjs.map +1 -0
  62. package/esm/config/config-loader.d.mts.map +1 -1
  63. package/esm/config/config-loader.mjs +2 -3
  64. package/esm/config/config-loader.mjs.map +1 -1
  65. package/esm/connectors/connectors-manager.d.mts +4 -0
  66. package/esm/connectors/connectors-manager.d.mts.map +1 -1
  67. package/esm/connectors/connectors-manager.mjs +30 -2
  68. package/esm/connectors/connectors-manager.mjs.map +1 -1
  69. package/esm/connectors/socket-connector.d.mts +29 -0
  70. package/esm/connectors/socket-connector.d.mts.map +1 -1
  71. package/esm/connectors/socket-connector.mjs +73 -9
  72. package/esm/connectors/socket-connector.mjs.map +1 -1
  73. package/esm/connectors/types.d.mts +1 -1
  74. package/esm/connectors/types.mjs.map +1 -1
  75. package/esm/database/models/database-log/database-log.d.mts +47 -24
  76. package/esm/database/models/database-log/database-log.d.mts.map +1 -1
  77. package/esm/database/models/database-log/database-log.mjs +36 -4
  78. package/esm/database/models/database-log/database-log.mjs.map +1 -1
  79. package/esm/database/seed-command-action.mjs +10 -2
  80. package/esm/database/seed-command-action.mjs.map +1 -1
  81. package/esm/dev-server/development-server.mjs +11 -4
  82. package/esm/dev-server/development-server.mjs.map +1 -1
  83. package/esm/dev-server/file-event-handler.mjs +26 -6
  84. package/esm/dev-server/file-event-handler.mjs.map +1 -1
  85. package/esm/dev-server/file-manager.d.mts.map +1 -1
  86. package/esm/dev-server/file-manager.mjs +2 -2
  87. package/esm/dev-server/file-manager.mjs.map +1 -1
  88. package/esm/dev-server/files-orchestrator.mjs +3 -1
  89. package/esm/dev-server/files-orchestrator.mjs.map +1 -1
  90. package/esm/dev-server/files-watcher.mjs +64 -5
  91. package/esm/dev-server/files-watcher.mjs.map +1 -1
  92. package/esm/dev-server/health-checker/files-healthcare.manager.mjs +31 -9
  93. package/esm/dev-server/health-checker/files-healthcare.manager.mjs.map +1 -1
  94. package/esm/dev-server/health-checker/workers/eslint-health.worker.mjs +11 -7
  95. package/esm/dev-server/health-checker/workers/eslint-health.worker.mjs.map +1 -1
  96. package/esm/dev-server/loader/load-hook.mjs +1 -1
  97. package/esm/dev-server/loader/register-loader.mjs +1 -1
  98. package/esm/dev-server/manifest-manager.d.mts.map +1 -1
  99. package/esm/dev-server/manifest-manager.mjs +12 -6
  100. package/esm/dev-server/manifest-manager.mjs.map +1 -1
  101. package/esm/dev-server/parse-imports.mjs +9 -0
  102. package/esm/dev-server/parse-imports.mjs.map +1 -1
  103. package/esm/dev-server/register-dev-shortcuts.mjs +2 -1
  104. package/esm/dev-server/register-dev-shortcuts.mjs.map +1 -1
  105. package/esm/dev-server/shortcuts.mjs +2 -2
  106. package/esm/dev-server/shortcuts.mjs.map +1 -1
  107. package/esm/dev-server/special-file-patterns.mjs +18 -0
  108. package/esm/dev-server/special-file-patterns.mjs.map +1 -0
  109. package/esm/dev-server/special-files-collector.d.mts.map +1 -1
  110. package/esm/dev-server/special-files-collector.mjs +2 -5
  111. package/esm/dev-server/special-files-collector.mjs.map +1 -1
  112. package/esm/dev-server/start-development-server.mjs +1 -2
  113. package/esm/dev-server/start-development-server.mjs.map +1 -1
  114. package/esm/dev-server/supervisor.mjs +23 -3
  115. package/esm/dev-server/supervisor.mjs.map +1 -1
  116. package/esm/dev-server/tsconfig-manager.mjs +22 -12
  117. package/esm/dev-server/tsconfig-manager.mjs.map +1 -1
  118. package/esm/dev-server/type-generator.mjs +10 -9
  119. package/esm/dev-server/type-generator.mjs.map +1 -1
  120. package/esm/encryption/encrypt.d.mts.map +1 -1
  121. package/esm/encryption/encrypt.mjs +16 -5
  122. package/esm/encryption/encrypt.mjs.map +1 -1
  123. package/esm/encryption/hash.d.mts.map +1 -1
  124. package/esm/encryption/hash.mjs +2 -0
  125. package/esm/encryption/hash.mjs.map +1 -1
  126. package/esm/encryption/password.d.mts.map +1 -1
  127. package/esm/encryption/password.mjs +3 -1
  128. package/esm/encryption/password.mjs.map +1 -1
  129. package/esm/generations/features/index.mjs +1 -1
  130. package/esm/generations/features/mysql.feature.mjs +9 -2
  131. package/esm/generations/features/mysql.feature.mjs.map +1 -1
  132. package/esm/generations/features/test.feature.mjs +2 -1
  133. package/esm/generations/features/test.feature.mjs.map +1 -1
  134. package/esm/generations/features/web.feature.mjs +4 -2
  135. package/esm/generations/features/web.feature.mjs.map +1 -1
  136. package/esm/http/csrf-origin-policy.d.mts.map +1 -1
  137. package/esm/http/csrf-origin-policy.mjs +5 -2
  138. package/esm/http/csrf-origin-policy.mjs.map +1 -1
  139. package/esm/http/flush-pending-cookies.mjs +52 -0
  140. package/esm/http/flush-pending-cookies.mjs.map +1 -0
  141. package/esm/http/middleware/cache-response-middleware.d.mts.map +1 -1
  142. package/esm/http/middleware/cache-response-middleware.mjs +4 -2
  143. package/esm/http/middleware/cache-response-middleware.mjs.map +1 -1
  144. package/esm/http/middleware/inject-request-context.d.mts.map +1 -1
  145. package/esm/http/middleware/inject-request-context.mjs +14 -3
  146. package/esm/http/middleware/inject-request-context.mjs.map +1 -1
  147. package/esm/http/middleware/utils/cidr-match.d.mts +1 -1
  148. package/esm/http/middleware/utils/cidr-match.d.mts.map +1 -1
  149. package/esm/http/middleware/utils/cidr-match.mjs +11 -2
  150. package/esm/http/middleware/utils/cidr-match.mjs.map +1 -1
  151. package/esm/http/middleware/utils/idempotency-key.mjs +1 -1
  152. package/esm/http/middleware/utils/idempotency-key.mjs.map +1 -1
  153. package/esm/http/plugins.d.mts.map +1 -1
  154. package/esm/http/plugins.mjs +10 -1
  155. package/esm/http/plugins.mjs.map +1 -1
  156. package/esm/http/request.d.mts +12 -1
  157. package/esm/http/request.d.mts.map +1 -1
  158. package/esm/http/request.mjs +35 -7
  159. package/esm/http/request.mjs.map +1 -1
  160. package/esm/http/response.d.mts +16 -0
  161. package/esm/http/response.d.mts.map +1 -1
  162. package/esm/http/response.mjs +75 -37
  163. package/esm/http/response.mjs.map +1 -1
  164. package/esm/http/types.d.mts +24 -0
  165. package/esm/http/types.d.mts.map +1 -1
  166. package/esm/http/uploads/uploaded-file.controller.mjs +1 -1
  167. package/esm/http/uploads-config.mjs +2 -1
  168. package/esm/http/uploads-config.mjs.map +1 -1
  169. package/esm/http/uploads-types.d.mts +3 -3
  170. package/esm/image/image.d.mts +1 -1
  171. package/esm/image/image.d.mts.map +1 -1
  172. package/esm/image/image.mjs +25 -15
  173. package/esm/image/image.mjs.map +1 -1
  174. package/esm/index.d.mts +4 -3
  175. package/esm/index.mjs +5 -3
  176. package/esm/logger/logger.mjs +6 -1
  177. package/esm/logger/logger.mjs.map +1 -1
  178. package/esm/mail/config.d.mts.map +1 -1
  179. package/esm/mail/config.mjs +31 -21
  180. package/esm/mail/config.mjs.map +1 -1
  181. package/esm/mail/react-mail.mjs +6 -3
  182. package/esm/mail/react-mail.mjs.map +1 -1
  183. package/esm/mail/send-mail.d.mts.map +1 -1
  184. package/esm/mail/send-mail.mjs +40 -19
  185. package/esm/mail/send-mail.mjs.map +1 -1
  186. package/esm/production/build-contributions.mjs +3 -0
  187. package/esm/production/build-contributions.mjs.map +1 -1
  188. package/esm/production/esbuild-plugins.mjs +3 -2
  189. package/esm/production/esbuild-plugins.mjs.map +1 -1
  190. package/esm/production/production-builder.mjs +32 -36
  191. package/esm/production/production-builder.mjs.map +1 -1
  192. package/esm/production/production-supervisor.mjs +12 -5
  193. package/esm/production/production-supervisor.mjs.map +1 -1
  194. package/esm/repositories/adapters/cascade/cascade-adapter.d.mts +3 -1
  195. package/esm/repositories/adapters/cascade/cascade-adapter.d.mts.map +1 -1
  196. package/esm/repositories/adapters/cascade/cascade-adapter.mjs +17 -5
  197. package/esm/repositories/adapters/cascade/cascade-adapter.mjs.map +1 -1
  198. package/esm/repositories/adapters/cascade/cascade-query-builder.d.mts +3 -3
  199. package/esm/repositories/adapters/cascade/cascade-query-builder.mjs.map +1 -1
  200. package/esm/repositories/adapters/cascade/filter-applicator.mjs +88 -61
  201. package/esm/repositories/adapters/cascade/filter-applicator.mjs.map +1 -1
  202. package/esm/repositories/adapters/cascade/index.d.mts +1 -1
  203. package/esm/repositories/adapters/cascade/index.mjs +1 -1
  204. package/esm/repositories/contracts/query-builder.contract.d.mts +3 -3
  205. package/esm/repositories/contracts/types.d.mts +5 -0
  206. package/esm/repositories/contracts/types.d.mts.map +1 -1
  207. package/esm/repositories/index.d.mts +1 -1
  208. package/esm/repositories/index.mjs +1 -1
  209. package/esm/repositories/repository.manager.d.mts +65 -3
  210. package/esm/repositories/repository.manager.d.mts.map +1 -1
  211. package/esm/repositories/repository.manager.mjs +146 -57
  212. package/esm/repositories/repository.manager.mjs.map +1 -1
  213. package/esm/resource/resource-field-builder.d.mts.map +1 -1
  214. package/esm/resource/resource-field-builder.mjs +1 -0
  215. package/esm/resource/resource-field-builder.mjs.map +1 -1
  216. package/esm/resource/resource.d.mts +10 -3
  217. package/esm/resource/resource.d.mts.map +1 -1
  218. package/esm/resource/resource.mjs +19 -5
  219. package/esm/resource/resource.mjs.map +1 -1
  220. package/esm/restful/restful.d.mts +12 -5
  221. package/esm/restful/restful.d.mts.map +1 -1
  222. package/esm/restful/restful.mjs +47 -16
  223. package/esm/restful/restful.mjs.map +1 -1
  224. package/esm/router/build-route-rate-limit.mjs +22 -0
  225. package/esm/router/build-route-rate-limit.mjs.map +1 -0
  226. package/esm/router/dev-dispatch.mjs +60 -0
  227. package/esm/router/dev-dispatch.mjs.map +1 -0
  228. package/esm/router/route-builder.mjs +4 -1
  229. package/esm/router/route-builder.mjs.map +1 -1
  230. package/esm/router/route-registry.d.mts.map +1 -1
  231. package/esm/router/route-registry.mjs +2 -2
  232. package/esm/router/route-registry.mjs.map +1 -1
  233. package/esm/router/router.d.mts +1 -1
  234. package/esm/router/router.d.mts.map +1 -1
  235. package/esm/router/router.mjs +57 -39
  236. package/esm/router/router.mjs.map +1 -1
  237. package/esm/socket/types.d.mts +9 -2
  238. package/esm/socket/types.d.mts.map +1 -1
  239. package/esm/storage/drivers/cloud-driver.d.mts +17 -0
  240. package/esm/storage/drivers/cloud-driver.d.mts.map +1 -1
  241. package/esm/storage/drivers/cloud-driver.mjs +53 -17
  242. package/esm/storage/drivers/cloud-driver.mjs.map +1 -1
  243. package/esm/storage/drivers/local-driver.d.mts +7 -5
  244. package/esm/storage/drivers/local-driver.d.mts.map +1 -1
  245. package/esm/storage/drivers/local-driver.mjs +36 -31
  246. package/esm/storage/drivers/local-driver.mjs.map +1 -1
  247. package/esm/storage/index.d.mts +2 -1
  248. package/esm/storage/index.mjs +2 -1
  249. package/esm/storage/scoped-storage.d.mts +13 -1
  250. package/esm/storage/scoped-storage.d.mts.map +1 -1
  251. package/esm/storage/scoped-storage.mjs +18 -8
  252. package/esm/storage/scoped-storage.mjs.map +1 -1
  253. package/esm/storage/storage.d.mts +0 -5
  254. package/esm/storage/storage.d.mts.map +1 -1
  255. package/esm/storage/storage.mjs +7 -16
  256. package/esm/storage/storage.mjs.map +1 -1
  257. package/esm/storage/types.d.mts +22 -4
  258. package/esm/storage/types.d.mts.map +1 -1
  259. package/esm/storage/utils/classify-ip-address.d.mts +13 -0
  260. package/esm/storage/utils/classify-ip-address.d.mts.map +1 -0
  261. package/esm/storage/utils/classify-ip-address.mjs +98 -0
  262. package/esm/storage/utils/classify-ip-address.mjs.map +1 -0
  263. package/esm/storage/utils/safe-fetch.d.mts +3 -12
  264. package/esm/storage/utils/safe-fetch.d.mts.map +1 -1
  265. package/esm/storage/utils/safe-fetch.mjs +3 -48
  266. package/esm/storage/utils/safe-fetch.mjs.map +1 -1
  267. package/esm/updater/update-warlock-packages.mjs +11 -1
  268. package/esm/updater/update-warlock-packages.mjs.map +1 -1
  269. package/esm/use-cases/use-case-broadcast.mjs +7 -1
  270. package/esm/use-cases/use-case-broadcast.mjs.map +1 -1
  271. package/esm/use-cases/use-cases-registry.mjs +21 -2
  272. package/esm/use-cases/use-cases-registry.mjs.map +1 -1
  273. package/esm/utils/database-log.mjs +0 -3
  274. package/esm/utils/database-log.mjs.map +1 -1
  275. package/esm/utils/queue.d.mts +6 -4
  276. package/esm/utils/queue.d.mts.map +1 -1
  277. package/esm/utils/queue.mjs +20 -9
  278. package/esm/utils/queue.mjs.map +1 -1
  279. package/esm/validation/database/exists-except-current-id.mjs +6 -2
  280. package/esm/validation/database/exists-except-current-id.mjs.map +1 -1
  281. package/esm/validation/database/unique-except-current-id.mjs +6 -2
  282. package/esm/validation/database/unique-except-current-id.mjs.map +1 -1
  283. package/esm/validation/init.mjs +6 -2
  284. package/esm/validation/init.mjs.map +1 -1
  285. package/esm/validation/validateAll.mjs +3 -2
  286. package/esm/validation/validateAll.mjs.map +1 -1
  287. package/esm/warlock-config/types.d.mts +14 -1
  288. package/esm/warlock-config/types.d.mts.map +1 -1
  289. package/esm/warlock-config/warlock-config.manager.d.mts.map +1 -1
  290. package/esm/warlock-config/warlock-config.manager.mjs +3 -1
  291. package/esm/warlock-config/warlock-config.manager.mjs.map +1 -1
  292. package/esm/warlock-config/warn-deprecated-config.mjs +25 -0
  293. package/esm/warlock-config/warn-deprecated-config.mjs.map +1 -0
  294. package/package.json +16 -16
  295. package/esm/_virtual/_rolldown/runtime.mjs +0 -7
@@ -29,8 +29,8 @@ import { shadcnFeature } from "./shadcn.feature.mjs";
29
29
  import { sitemapFeature } from "./sitemap.feature.mjs";
30
30
  import { socketFeature } from "./socket.feature.mjs";
31
31
  import { tailwindFeature } from "./tailwind.feature.mjs";
32
- import { testFeature } from "./test.feature.mjs";
33
32
  import { webFeature } from "./web.feature.mjs";
33
+ import { testFeature } from "./test.feature.mjs";
34
34
 
35
35
  //#region ../core/src/generations/features/index.ts
36
36
  /**
@@ -1,7 +1,14 @@
1
1
  //#region ../core/src/generations/features/mysql.feature.ts
2
+ /**
3
+ * MySQL is not implemented by Cascade (`connectToDatabase` throws for it), so
4
+ * installing `mysql2` would only produce an app that crashes on boot. No
5
+ * dependencies are declared and running the feature fails loudly instead.
6
+ */
2
7
  const mysqlFeature = {
3
- description: "Installs mysql2 for MySQL database driver (Cascade Package)",
4
- dependencies: { mysql2: "^3.5.0" }
8
+ description: "MySQL (not supported by @warlock.js/cascade yet)",
9
+ onExecuting: async () => {
10
+ throw new Error("MySQL is not supported by @warlock.js/cascade yet; use postgres or mongodb");
11
+ }
5
12
  };
6
13
 
7
14
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"mysql.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/mysql.feature.ts"],"sourcesContent":["import type { FeatureDefinition } from \"./types\";\r\n\r\nexport const mysqlFeature: FeatureDefinition = {\r\n description: \"Installs mysql2 for MySQL database driver (Cascade Package)\",\r\n dependencies: {\r\n mysql2: \"^3.5.0\",\r\n },\r\n};\r\n"],"mappings":";AAEA,MAAa,eAAkC;CAC7C,aAAa;CACb,cAAc,EACZ,QAAQ,SACV;AACF"}
1
+ {"version":3,"file":"mysql.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/mysql.feature.ts"],"sourcesContent":["import type { FeatureDefinition } from \"./types\";\n\n/**\n * MySQL is not implemented by Cascade (`connectToDatabase` throws for it), so\n * installing `mysql2` would only produce an app that crashes on boot. No\n * dependencies are declared and running the feature fails loudly instead.\n */\nexport const mysqlFeature: FeatureDefinition = {\n description: \"MySQL (not supported by @warlock.js/cascade yet)\",\n onExecuting: async () => {\n throw new Error(\n \"MySQL is not supported by @warlock.js/cascade yet; use postgres or mongodb\",\n );\n },\n};\n"],"mappings":";;;;;;AAOA,MAAa,eAAkC;CAC7C,aAAa;CACb,aAAa,YAAY;EACvB,MAAM,IAAI,MACR,4EACF;CACF;AACF"}
@@ -1,5 +1,6 @@
1
1
  import { rootPath, srcPath } from "../../utils/paths.mjs";
2
2
  import "../../utils/index.mjs";
3
+ import { VITE_RANGE } from "./web.feature.mjs";
3
4
  import { colors } from "@mongez/copper";
4
5
  import { fileExistsAsync, putFileAsync } from "@warlock.js/fs";
5
6
 
@@ -85,7 +86,7 @@ const testFeature = {
85
86
  },
86
87
  devDependencies: {
87
88
  "@mongez/vite": "^2.0.4",
88
- vite: "^8.0.16",
89
+ vite: VITE_RANGE,
89
90
  vitest: "^4.1.8",
90
91
  "@vitest/coverage-v8": "^4.1.8"
91
92
  }
@@ -1 +1 @@
1
- {"version":3,"file":"test.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/test.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport { fileExistsAsync, putFileAsync } from \"@warlock.js/fs\";\r\nimport type { CommandActionData } from \"../../commands/types\";\r\nimport { rootPath, srcPath } from \"../../utils\";\r\nimport type { FeatureDefinition } from \"./types\";\r\n\r\nasync function completeTestInstallation(options: CommandActionData) {\r\n // Create test-global-setup.ts (runs once before all tests)\r\n const testGlobalSetupPath = srcPath(\"test-global-setup.ts\");\r\n const testGlobalSetupExists = await fileExistsAsync(testGlobalSetupPath);\r\n\r\n if (!testGlobalSetupExists) {\r\n await putFileAsync(\r\n testGlobalSetupPath,\r\n `/**\r\n * Global Test Setup\r\n *\r\n * Runs ONCE before all test workers.\r\n * Starts the HTTP server for integration tests.\r\n */\r\nimport { startHttpTestServer, stopHttpTestServer } from \"@warlock.js/core/tests\";\r\n\r\nexport async function setup() {\r\n await startHttpTestServer();\r\n}\r\n\r\nexport async function teardown() {\r\n await stopHttpTestServer();\r\n}\r\n`,\r\n );\r\n console.log(`${colors.green(\"✓\")} Created src/test-global-setup.ts`);\r\n }\r\n\r\n // Create test-setup.ts (runs before EVERY test file)\r\n const testSetupPath = srcPath(\"test-setup.ts\");\r\n const testSetupExists = await fileExistsAsync(testSetupPath);\r\n\r\n if (!testSetupExists) {\r\n await putFileAsync(\r\n testSetupPath,\r\n `/**\r\n * Test Setup - runs before EVERY test file\r\n *\r\n * Vitest runs setupFiles before each test file and rebuilds the module\r\n * registry with it, so this pair boots and closes the test runtime once per\r\n * test file.\r\n *\r\n * setupTest() is called with no options on purpose: an explicit connectors\r\n * value outranks tests.connectors from src/config/tests.ts, so passing one\r\n * here would erase your project config. Omitting it leaves the config in\r\n * charge.\r\n *\r\n * afterAll(teardownTest) is the other half of the pair: whoever calls\r\n * setupTest() owns closing it in the same runtime context.\r\n */\r\nimport { setupTest, teardownTest } from \"@warlock.js/core/tests\";\r\nimport { afterAll } from \"vitest\";\r\n\r\nawait setupTest();\r\n\r\nafterAll(teardownTest);\r\n`,\r\n );\r\n console.log(`${colors.green(\"✓\")} Created src/test-setup.ts`);\r\n }\r\n\r\n // Create vite.config.ts\r\n const viteConfigPath = rootPath(\"vite.config.ts\");\r\n const viteConfigExists = await fileExistsAsync(viteConfigPath);\r\n\r\n if (!viteConfigExists) {\r\n await putFileAsync(\r\n viteConfigPath,\r\n `import { lowerStage3Decorators } from \"@warlock.js/core/vite\";\r\nimport mongezVite from \"@mongez/vite\";\r\nimport { defineConfig } from \"vitest/config\";\r\n\r\nexport default defineConfig({\r\n // lowerStage3Decorators MUST come first: it lowers native (@RegisterModel, …)\r\n // decorators with esbuild before oxc / the SSR rewrite can mangle them, so\r\n // decorated Cascade models load under Vitest.\r\n plugins: [lowerStage3Decorators(), mongezVite()],\r\n test: {\r\n globalSetup: \"./src/test-global-setup.ts\", // HTTP server - runs once\r\n setupFiles: [\"./src/test-setup.ts\"], // DB/cache - runs per test file\r\n environment: \"node\",\r\n globals: false,\r\n include: [\"src/app/**/*.test.ts\"],\r\n },\r\n});\r\n`,\r\n );\r\n console.log(`${colors.green(\"✓\")} Created vite.config.ts`);\r\n }\r\n}\r\n\r\nexport const testFeature: FeatureDefinition = {\r\n description: \"Installs warlock test for testing\",\r\n onExecuting: completeTestInstallation,\r\n script: {\r\n test: \"vitest run\",\r\n \"test:coverage\": \"vitest run --coverage\",\r\n \"test:ui\": \"vitest --ui\",\r\n \"test:watch\": \"vitest --watch\",\r\n },\r\n devDependencies: {\r\n \"@mongez/vite\": \"^2.0.4\",\r\n vite: \"^8.0.16\",\r\n vitest: \"^4.1.8\",\r\n \"@vitest/coverage-v8\": \"^4.1.8\",\r\n },\r\n};\r\n"],"mappings":";;;;;;AAMA,eAAe,yBAAyB,SAA4B;CAElE,MAAM,sBAAsB,QAAQ,sBAAsB;CAG1D,IAAI,CAAC,MAF+B,gBAAgB,mBAAmB,GAE3C;EAC1B,MAAM,aACJ,qBACA;;;;;;;;;;;;;;;CAgBF;EACA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,kCAAkC;CACrE;CAGA,MAAM,gBAAgB,QAAQ,eAAe;CAG7C,IAAI,CAAC,MAFyB,gBAAgB,aAAa,GAErC;EACpB,MAAM,aACJ,eACA;;;;;;;;;;;;;;;;;;;;;CAsBF;EACA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,2BAA2B;CAC9D;CAGA,MAAM,iBAAiB,SAAS,gBAAgB;CAGhD,IAAI,CAAC,MAF0B,gBAAgB,cAAc,GAEtC;EACrB,MAAM,aACJ,gBACA;;;;;;;;;;;;;;;;;CAkBF;EACA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,wBAAwB;CAC3D;AACF;AAEA,MAAa,cAAiC;CAC5C,aAAa;CACb,aAAa;CACb,QAAQ;EACN,MAAM;EACN,iBAAiB;EACjB,WAAW;EACX,cAAc;CAChB;CACA,iBAAiB;EACf,gBAAgB;EAChB,MAAM;EACN,QAAQ;EACR,uBAAuB;CACzB;AACF"}
1
+ {"version":3,"file":"test.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/test.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport { fileExistsAsync, putFileAsync } from \"@warlock.js/fs\";\r\nimport type { CommandActionData } from \"../../commands/types\";\r\nimport { rootPath, srcPath } from \"../../utils\";\r\nimport type { FeatureDefinition } from \"./types\";\r\nimport { VITE_RANGE } from \"./web.feature\";\r\n\r\nasync function completeTestInstallation(options: CommandActionData) {\r\n // Create test-global-setup.ts (runs once before all tests)\r\n const testGlobalSetupPath = srcPath(\"test-global-setup.ts\");\r\n const testGlobalSetupExists = await fileExistsAsync(testGlobalSetupPath);\r\n\r\n if (!testGlobalSetupExists) {\r\n await putFileAsync(\r\n testGlobalSetupPath,\r\n `/**\r\n * Global Test Setup\r\n *\r\n * Runs ONCE before all test workers.\r\n * Starts the HTTP server for integration tests.\r\n */\r\nimport { startHttpTestServer, stopHttpTestServer } from \"@warlock.js/core/tests\";\r\n\r\nexport async function setup() {\r\n await startHttpTestServer();\r\n}\r\n\r\nexport async function teardown() {\r\n await stopHttpTestServer();\r\n}\r\n`,\r\n );\r\n console.log(`${colors.green(\"✓\")} Created src/test-global-setup.ts`);\r\n }\r\n\r\n // Create test-setup.ts (runs before EVERY test file)\r\n const testSetupPath = srcPath(\"test-setup.ts\");\r\n const testSetupExists = await fileExistsAsync(testSetupPath);\r\n\r\n if (!testSetupExists) {\r\n await putFileAsync(\r\n testSetupPath,\r\n `/**\r\n * Test Setup - runs before EVERY test file\r\n *\r\n * Vitest runs setupFiles before each test file and rebuilds the module\r\n * registry with it, so this pair boots and closes the test runtime once per\r\n * test file.\r\n *\r\n * setupTest() is called with no options on purpose: an explicit connectors\r\n * value outranks tests.connectors from src/config/tests.ts, so passing one\r\n * here would erase your project config. Omitting it leaves the config in\r\n * charge.\r\n *\r\n * afterAll(teardownTest) is the other half of the pair: whoever calls\r\n * setupTest() owns closing it in the same runtime context.\r\n */\r\nimport { setupTest, teardownTest } from \"@warlock.js/core/tests\";\r\nimport { afterAll } from \"vitest\";\r\n\r\nawait setupTest();\r\n\r\nafterAll(teardownTest);\r\n`,\r\n );\r\n console.log(`${colors.green(\"✓\")} Created src/test-setup.ts`);\r\n }\r\n\r\n // Create vite.config.ts\r\n const viteConfigPath = rootPath(\"vite.config.ts\");\r\n const viteConfigExists = await fileExistsAsync(viteConfigPath);\r\n\r\n if (!viteConfigExists) {\r\n await putFileAsync(\r\n viteConfigPath,\r\n `import { lowerStage3Decorators } from \"@warlock.js/core/vite\";\r\nimport mongezVite from \"@mongez/vite\";\r\nimport { defineConfig } from \"vitest/config\";\r\n\r\nexport default defineConfig({\r\n // lowerStage3Decorators MUST come first: it lowers native (@RegisterModel, …)\r\n // decorators with esbuild before oxc / the SSR rewrite can mangle them, so\r\n // decorated Cascade models load under Vitest.\r\n plugins: [lowerStage3Decorators(), mongezVite()],\r\n test: {\r\n globalSetup: \"./src/test-global-setup.ts\", // HTTP server - runs once\r\n setupFiles: [\"./src/test-setup.ts\"], // DB/cache - runs per test file\r\n environment: \"node\",\r\n globals: false,\r\n include: [\"src/app/**/*.test.ts\"],\r\n },\r\n});\r\n`,\r\n );\r\n console.log(`${colors.green(\"✓\")} Created vite.config.ts`);\r\n }\r\n}\r\n\r\nexport const testFeature: FeatureDefinition = {\r\n description: \"Installs warlock test for testing\",\r\n onExecuting: completeTestInstallation,\r\n script: {\r\n test: \"vitest run\",\r\n \"test:coverage\": \"vitest run --coverage\",\r\n \"test:ui\": \"vitest --ui\",\r\n \"test:watch\": \"vitest --watch\",\r\n },\r\n devDependencies: {\r\n \"@mongez/vite\": \"^2.0.4\",\r\n vite: VITE_RANGE,\r\n vitest: \"^4.1.8\",\r\n \"@vitest/coverage-v8\": \"^4.1.8\",\r\n },\r\n};\r\n"],"mappings":";;;;;;;AAOA,eAAe,yBAAyB,SAA4B;CAElE,MAAM,sBAAsB,QAAQ,sBAAsB;CAG1D,IAAI,CAAC,MAF+B,gBAAgB,mBAAmB,GAE3C;EAC1B,MAAM,aACJ,qBACA;;;;;;;;;;;;;;;CAgBF;EACA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,kCAAkC;CACrE;CAGA,MAAM,gBAAgB,QAAQ,eAAe;CAG7C,IAAI,CAAC,MAFyB,gBAAgB,aAAa,GAErC;EACpB,MAAM,aACJ,eACA;;;;;;;;;;;;;;;;;;;;;CAsBF;EACA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,2BAA2B;CAC9D;CAGA,MAAM,iBAAiB,SAAS,gBAAgB;CAGhD,IAAI,CAAC,MAF0B,gBAAgB,cAAc,GAEtC;EACrB,MAAM,aACJ,gBACA;;;;;;;;;;;;;;;;;CAkBF;EACA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,wBAAwB;CAC3D;AACF;AAEA,MAAa,cAAiC;CAC5C,aAAa;CACb,aAAa;CACb,QAAQ;EACN,MAAM;EACN,iBAAiB;EACjB,WAAW;EACX,cAAc;CAChB;CACA,iBAAiB;EACf,gBAAgB;EAChB,MAAM;EACN,QAAQ;EACR,uBAAuB;CACzB;AACF"}
@@ -9,6 +9,8 @@ import { colors } from "@mongez/copper";
9
9
  import { ensureDirectoryAsync, fileExistsAsync, getFileAsync, putFileAsync } from "@warlock.js/fs";
10
10
 
11
11
  //#region ../core/src/generations/features/web.feature.ts
12
+ /** The one vite range shared by every feature (web's peer range is `>=7.3.5 <8`). */
13
+ const VITE_RANGE = "^7.3.5";
12
14
  /**
13
15
  * Register the WebConnector in `warlock.config.ts`, and ONLY there.
14
16
  *
@@ -105,12 +107,12 @@ const webFeature = {
105
107
  devDependencies: {
106
108
  "@types/react": "^19.2.7",
107
109
  "@types/react-dom": "^19.2.3",
108
- vite: "^7.3.5",
110
+ vite: VITE_RANGE,
109
111
  "@vitejs/plugin-react": "^5.2.0"
110
112
  },
111
113
  onExecuting: completeWebInstallation
112
114
  };
113
115
 
114
116
  //#endregion
115
- export { webFeature };
117
+ export { VITE_RANGE, webFeature };
116
118
  //# sourceMappingURL=web.feature.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"web.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/web.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport { ensureDirectoryAsync, fileExistsAsync, getFileAsync, putFileAsync } from \"@warlock.js/fs\";\r\nimport type { CommandActionData } from \"../../commands/types\";\r\nimport { rootPath, srcPath } from \"../../utils\";\r\nimport { insertConnectorEntry } from \"./shared/insert-connector-entry\";\r\nimport { relocateConflictingHomeRoute } from \"./shared/relocate-conflicting-home-route\";\r\nimport { resolveContactScaffold } from \"./shared/resolve-contact-scaffold\";\r\nimport {\r\n webContactControllerStub,\r\n webContactRoutesStub,\r\n webHomePageStub,\r\n webHomeRegisterStub,\r\n webRootStub,\r\n} from \"../stubs\";\r\nimport { type FeatureDefinition, INSTALLED_WARLOCK_VERSION } from \"./types\";\r\n\r\n/**\r\n * Register the WebConnector in `warlock.config.ts`, and ONLY there.\r\n *\r\n * It belongs to the config array or to app code, never both. Both halves are\r\n * registered before app code loads — the CLI preloader in dev, the generated\r\n * entry in production — so also calling `connectorsManager.register(...)` in\r\n * `src/app/main.ts` boots the connector twice and installs every page route\r\n * twice. That surfaces at PRODUCTION boot as `Route name \"...\" is already\r\n * taken`, because pages and API routes share one route-name namespace.\r\n *\r\n * The config array is the half to prefer: `warlock build` reads the same array\r\n * to drain each connector's build contribution, so \"built for\" and \"boots with\"\r\n * cannot drift.\r\n *\r\n * String surgery rather than a TypeScript parse: `warlock.config.ts` is an\r\n * app-owned file that may carry any formatting, and a parse-and-print would\r\n * reformat the parts we did not come to change.\r\n */\r\nasync function registerWebConnector(): Promise<void> {\r\n const configPath = rootPath(\"warlock.config.ts\");\r\n\r\n if (!(await fileExistsAsync(configPath))) {\r\n console.log(\r\n `${colors.yellowBright(\"warlock.config.ts\")} not found — add this yourself:\\n` +\r\n ` import { webConnector } from \"@warlock.js/web/connector\";\\n` +\r\n ` export default defineConfig({ connectors: [webConnector()] });`,\r\n );\r\n\r\n return;\r\n }\r\n\r\n const current = await getFileAsync(configPath);\r\n\r\n if (current.includes(\"webConnector\")) {\r\n console.log(`${colors.yellowBright(\"webConnector\")} already registered, skipping...`);\r\n\r\n return;\r\n }\r\n\r\n const importLine = 'import { webConnector } from \"@warlock.js/web/connector\";';\r\n let next = current.includes(importLine) ? current : `${importLine}\\n${current}`;\r\n\r\n // An existing `connectors: [` gains one entry; otherwise the key is added to\r\n // the object `defineConfig` receives.\r\n const insertion = insertConnectorEntry(next, \"webConnector()\");\r\n\r\n if (insertion.status === \"already-present\") {\r\n return;\r\n }\r\n\r\n if (insertion.status === \"added\") {\r\n next = insertion.next;\r\n } else if (next.includes(\"defineConfig({\")) {\r\n next = next.replace(\"defineConfig({\", \"defineConfig({\\n connectors: [webConnector()],\");\r\n } else {\r\n console.log(\r\n `${colors.yellowBright(\"warlock.config.ts\")} has no recognisable defineConfig({...}) — ` +\r\n \"add `connectors: [webConnector()]` yourself.\",\r\n );\r\n\r\n return;\r\n }\r\n\r\n await putFileAsync(configPath, next);\r\n console.log(`${colors.green(\"✓\")} Registered webConnector in warlock.config.ts`);\r\n}\r\n\r\n/**\r\n * Scaffold the smallest page layer that renders, and register the connector.\r\n *\r\n * `src/web/root.tsx` is the sentinel for \"already scaffolded\" — the framework\r\n * ships a default root, so its presence means a human has been here.\r\n */\r\nasync function completeWebInstallation(_options: CommandActionData) {\r\n const rootFile = srcPath(\"web/root.tsx\");\r\n\r\n if (await fileExistsAsync(rootFile)) {\r\n console.log(`${colors.yellowBright(\"src/web\")} already scaffolded, skipping...`);\r\n } else {\r\n await ensureDirectoryAsync(srcPath(\"web\"));\r\n await putFileAsync(rootFile, webRootStub);\r\n console.log(`${colors.green(\"✓\")} Created src/web/root.tsx`);\r\n\r\n const collision = await relocateConflictingHomeRoute();\r\n\r\n if (collision.outcome === \"relocated\") {\r\n console.log(\r\n `${colors.green(\"✓\")} Moved the existing ${colors.yellowBright('GET \"/\"')} route to ` +\r\n `${colors.yellowBright('\"/welcome\"')} in ${colors.yellowBright(`src/${collision.relativePath}`)} — ` +\r\n \"the new page owns `/` now, and the JSON welcome route still answers at /welcome.\",\r\n );\r\n }\r\n\r\n // The page is written ONLY when `/` is provably free. Writing it while\r\n // another handler holds `/` produces a homepage that 500s on first request,\r\n // which is precisely the outcome a scaffolder must never hand back.\r\n if (collision.outcome === \"conflict\" || collision.outcome === \"failed\") {\r\n const verb =\r\n collision.outcome === \"failed\" ? colors.redBright(\"✗\") : colors.yellowBright(\"!\");\r\n\r\n console.log(\r\n `${verb} Did not create src/web/index.page.tsx: ${collision.reason}.\\n` +\r\n ` The page stub declares ${colors.yellowBright('route.path = \"/\"')}, and two handlers on one ` +\r\n \"path is a 500 at request time, not a startup error.\\n\" +\r\n ` Free up ${colors.yellowBright('GET \"/\"')} under src/app — move it to a path of its own, ` +\r\n \"or remove it — then create src/web/index.page.tsx yourself. Giving the page a `route` other \" +\r\n \"than `/` works too.\",\r\n );\r\n\r\n // Non-zero on BOTH branches. The page layer this command exists to\r\n // scaffold was not scaffolded, and a 0 here is the exact \"looked like it\r\n // worked\" signal that put `/` in this state to begin with — a conflict we\r\n // declined to guess at is still an incomplete install, not a success.\r\n //\r\n // `exitCode` rather than `exit(1)`: the connector below still has to be\r\n // registered, and any other feature in the same `warlock add` invocation\r\n // still has to install, or the project is left half-wired on top of this.\r\n process.exitCode = 1;\r\n } else {\r\n await putFileAsync(srcPath(\"web/index.page.tsx\"), webHomePageStub);\r\n await putFileAsync(srcPath(\"web/index.register.ts\"), webHomeRegisterStub);\r\n\r\n // Unlike root.tsx above, the contact controller and its routes file are\r\n // ordinary application files a project can already have — independently\r\n // of ever having run `warlock add web`. Each is guarded on its OWN\r\n // existence, not on the (already-consumed) root.tsx sentinel, so an\r\n // existing `contact` module is skipped rather than clobbered. See\r\n // resolveContactScaffold's doc comment for why.\r\n const contactPlan = resolveContactScaffold({\r\n controllerExists: await fileExistsAsync(\r\n srcPath(\"app/contact/controllers/contact.controller.ts\"),\r\n ),\r\n routesExists: await fileExistsAsync(srcPath(\"app/contact/routes.ts\")),\r\n });\r\n\r\n if (contactPlan.writeController) {\r\n await ensureDirectoryAsync(srcPath(\"app/contact/controllers\"));\r\n await putFileAsync(\r\n srcPath(\"app/contact/controllers/contact.controller.ts\"),\r\n webContactControllerStub,\r\n );\r\n }\r\n\r\n if (contactPlan.writeRoutes) {\r\n await ensureDirectoryAsync(srcPath(\"app/contact\"));\r\n await putFileAsync(srcPath(\"app/contact/routes.ts\"), webContactRoutesStub);\r\n }\r\n\r\n console.log(`${colors.green(\"✓\")} Created src/web/index.page.tsx`);\r\n\r\n for (const message of contactPlan.messages) {\r\n console.log(message);\r\n }\r\n }\r\n }\r\n\r\n await registerWebConnector();\r\n}\r\n\r\nexport const webFeature: FeatureDefinition = {\r\n description:\r\n \"Installs @warlock.js/web — SSR React pages served by the Warlock HTTP server. Scaffolds src/web (root.tsx + a home page) and registers the WebConnector in warlock.config.ts. Pages are opt-in: a Warlock app is an API until you add this.\",\r\n dependencies: {\r\n \"@warlock.js/web\": INSTALLED_WARLOCK_VERSION,\r\n \"@mongez/http\": \"^3.5.0\",\r\n \"@mongez/react-form\": \"^4.0.0\",\r\n \"@mongez/react-localization\": \"^3.4.7\",\r\n react: \"^19.2.3\",\r\n \"react-dom\": \"^19.2.3\",\r\n },\r\n devDependencies: {\r\n \"@types/react\": \"^19.2.7\",\r\n \"@types/react-dom\": \"^19.2.3\",\r\n // Loaded through `await import()` by the dev server only, so both are\r\n // optional peers of `web` rather than hard dependencies.\r\n vite: \"^7.3.5\",\r\n \"@vitejs/plugin-react\": \"^5.2.0\",\r\n },\r\n onExecuting: completeWebInstallation,\r\n};\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,eAAe,uBAAsC;CACnD,MAAM,aAAa,SAAS,mBAAmB;CAE/C,IAAI,CAAE,MAAM,gBAAgB,UAAU,GAAI;EACxC,QAAQ,IACN,GAAG,OAAO,aAAa,mBAAmB,EAAE,+JAG9C;EAEA;CACF;CAEA,MAAM,UAAU,MAAM,aAAa,UAAU;CAE7C,IAAI,QAAQ,SAAS,cAAc,GAAG;EACpC,QAAQ,IAAI,GAAG,OAAO,aAAa,cAAc,EAAE,iCAAiC;EAEpF;CACF;CAEA,MAAM,aAAa;CACnB,IAAI,OAAO,QAAQ,SAAS,UAAU,IAAI,UAAU,GAAG,WAAW,IAAI;CAItE,MAAM,YAAY,qBAAqB,MAAM,gBAAgB;CAE7D,IAAI,UAAU,WAAW,mBACvB;CAGF,IAAI,UAAU,WAAW,SACvB,OAAO,UAAU;MACZ,IAAI,KAAK,SAAS,gBAAgB,GACvC,OAAO,KAAK,QAAQ,kBAAkB,iDAAiD;MAClF;EACL,QAAQ,IACN,GAAG,OAAO,aAAa,mBAAmB,EAAE,0FAE9C;EAEA;CACF;CAEA,MAAM,aAAa,YAAY,IAAI;CACnC,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,8CAA8C;AACjF;;;;;;;AAQA,eAAe,wBAAwB,UAA6B;CAClE,MAAM,WAAW,QAAQ,cAAc;CAEvC,IAAI,MAAM,gBAAgB,QAAQ,GAChC,QAAQ,IAAI,GAAG,OAAO,aAAa,SAAS,EAAE,iCAAiC;MAC1E;EACL,MAAM,qBAAqB,QAAQ,KAAK,CAAC;EACzC,MAAM,aAAa,UAAU,WAAW;EACxC,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,0BAA0B;EAE3D,MAAM,YAAY,MAAM,6BAA6B;EAErD,IAAI,UAAU,YAAY,aACxB,QAAQ,IACN,GAAG,OAAO,MAAM,GAAG,EAAE,sBAAsB,OAAO,aAAa,WAAS,EAAE,YACrE,OAAO,aAAa,cAAY,EAAE,MAAM,OAAO,aAAa,OAAO,UAAU,cAAc,EAAE,sFAEpG;EAMF,IAAI,UAAU,YAAY,cAAc,UAAU,YAAY,UAAU;GACtE,MAAM,OACJ,UAAU,YAAY,WAAW,OAAO,UAAU,GAAG,IAAI,OAAO,aAAa,GAAG;GAElF,QAAQ,IACN,GAAG,KAAK,0CAA0C,UAAU,OAAO,8BACrC,OAAO,aAAa,oBAAkB,EAAE;YAEvD,OAAO,aAAa,WAAS,EAAE,mKAGhD;GAUA,QAAQ,WAAW;EACrB,OAAO;GACL,MAAM,aAAa,QAAQ,oBAAoB,GAAG,eAAe;GACjE,MAAM,aAAa,QAAQ,uBAAuB,GAAG,mBAAmB;GAQxE,MAAM,cAAc,uBAAuB;IACzC,kBAAkB,MAAM,gBACtB,QAAQ,+CAA+C,CACzD;IACA,cAAc,MAAM,gBAAgB,QAAQ,uBAAuB,CAAC;GACtE,CAAC;GAED,IAAI,YAAY,iBAAiB;IAC/B,MAAM,qBAAqB,QAAQ,yBAAyB,CAAC;IAC7D,MAAM,aACJ,QAAQ,+CAA+C,GACvD,wBACF;GACF;GAEA,IAAI,YAAY,aAAa;IAC3B,MAAM,qBAAqB,QAAQ,aAAa,CAAC;IACjD,MAAM,aAAa,QAAQ,uBAAuB,GAAG,oBAAoB;GAC3E;GAEA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,gCAAgC;GAEjE,KAAK,MAAM,WAAW,YAAY,UAChC,QAAQ,IAAI,OAAO;EAEvB;CACF;CAEA,MAAM,qBAAqB;AAC7B;AAEA,MAAa,aAAgC;CAC3C,aACE;CACF,cAAc;EACZ,mBAAmB;EACnB,gBAAgB;EAChB,sBAAsB;EACtB,8BAA8B;EAC9B,OAAO;EACP,aAAa;CACf;CACA,iBAAiB;EACf,gBAAgB;EAChB,oBAAoB;EAGpB,MAAM;EACN,wBAAwB;CAC1B;CACA,aAAa;AACf"}
1
+ {"version":3,"file":"web.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/web.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport { ensureDirectoryAsync, fileExistsAsync, getFileAsync, putFileAsync } from \"@warlock.js/fs\";\r\nimport type { CommandActionData } from \"../../commands/types\";\r\nimport { rootPath, srcPath } from \"../../utils\";\r\nimport { insertConnectorEntry } from \"./shared/insert-connector-entry\";\r\nimport { relocateConflictingHomeRoute } from \"./shared/relocate-conflicting-home-route\";\r\nimport { resolveContactScaffold } from \"./shared/resolve-contact-scaffold\";\r\nimport {\r\n webContactControllerStub,\r\n webContactRoutesStub,\r\n webHomePageStub,\r\n webHomeRegisterStub,\r\n webRootStub,\r\n} from \"../stubs\";\r\nimport { type FeatureDefinition, INSTALLED_WARLOCK_VERSION } from \"./types\";\r\n\r\n/** The one vite range shared by every feature (web's peer range is `>=7.3.5 <8`). */\r\nexport const VITE_RANGE = \"^7.3.5\";\r\n\r\n/**\r\n * Register the WebConnector in `warlock.config.ts`, and ONLY there.\r\n *\r\n * It belongs to the config array or to app code, never both. Both halves are\r\n * registered before app code loads — the CLI preloader in dev, the generated\r\n * entry in production — so also calling `connectorsManager.register(...)` in\r\n * `src/app/main.ts` boots the connector twice and installs every page route\r\n * twice. That surfaces at PRODUCTION boot as `Route name \"...\" is already\r\n * taken`, because pages and API routes share one route-name namespace.\r\n *\r\n * The config array is the half to prefer: `warlock build` reads the same array\r\n * to drain each connector's build contribution, so \"built for\" and \"boots with\"\r\n * cannot drift.\r\n *\r\n * String surgery rather than a TypeScript parse: `warlock.config.ts` is an\r\n * app-owned file that may carry any formatting, and a parse-and-print would\r\n * reformat the parts we did not come to change.\r\n */\r\nasync function registerWebConnector(): Promise<void> {\r\n const configPath = rootPath(\"warlock.config.ts\");\r\n\r\n if (!(await fileExistsAsync(configPath))) {\r\n console.log(\r\n `${colors.yellowBright(\"warlock.config.ts\")} not found — add this yourself:\\n` +\r\n ` import { webConnector } from \"@warlock.js/web/connector\";\\n` +\r\n ` export default defineConfig({ connectors: [webConnector()] });`,\r\n );\r\n\r\n return;\r\n }\r\n\r\n const current = await getFileAsync(configPath);\r\n\r\n if (current.includes(\"webConnector\")) {\r\n console.log(`${colors.yellowBright(\"webConnector\")} already registered, skipping...`);\r\n\r\n return;\r\n }\r\n\r\n const importLine = 'import { webConnector } from \"@warlock.js/web/connector\";';\r\n let next = current.includes(importLine) ? current : `${importLine}\\n${current}`;\r\n\r\n // An existing `connectors: [` gains one entry; otherwise the key is added to\r\n // the object `defineConfig` receives.\r\n const insertion = insertConnectorEntry(next, \"webConnector()\");\r\n\r\n if (insertion.status === \"already-present\") {\r\n return;\r\n }\r\n\r\n if (insertion.status === \"added\") {\r\n next = insertion.next;\r\n } else if (next.includes(\"defineConfig({\")) {\r\n next = next.replace(\"defineConfig({\", \"defineConfig({\\n connectors: [webConnector()],\");\r\n } else {\r\n console.log(\r\n `${colors.yellowBright(\"warlock.config.ts\")} has no recognisable defineConfig({...}) — ` +\r\n \"add `connectors: [webConnector()]` yourself.\",\r\n );\r\n\r\n return;\r\n }\r\n\r\n await putFileAsync(configPath, next);\r\n console.log(`${colors.green(\"✓\")} Registered webConnector in warlock.config.ts`);\r\n}\r\n\r\n/**\r\n * Scaffold the smallest page layer that renders, and register the connector.\r\n *\r\n * `src/web/root.tsx` is the sentinel for \"already scaffolded\" — the framework\r\n * ships a default root, so its presence means a human has been here.\r\n */\r\nasync function completeWebInstallation(_options: CommandActionData) {\r\n const rootFile = srcPath(\"web/root.tsx\");\r\n\r\n if (await fileExistsAsync(rootFile)) {\r\n console.log(`${colors.yellowBright(\"src/web\")} already scaffolded, skipping...`);\r\n } else {\r\n await ensureDirectoryAsync(srcPath(\"web\"));\r\n await putFileAsync(rootFile, webRootStub);\r\n console.log(`${colors.green(\"✓\")} Created src/web/root.tsx`);\r\n\r\n const collision = await relocateConflictingHomeRoute();\r\n\r\n if (collision.outcome === \"relocated\") {\r\n console.log(\r\n `${colors.green(\"✓\")} Moved the existing ${colors.yellowBright('GET \"/\"')} route to ` +\r\n `${colors.yellowBright('\"/welcome\"')} in ${colors.yellowBright(`src/${collision.relativePath}`)} — ` +\r\n \"the new page owns `/` now, and the JSON welcome route still answers at /welcome.\",\r\n );\r\n }\r\n\r\n // The page is written ONLY when `/` is provably free. Writing it while\r\n // another handler holds `/` produces a homepage that 500s on first request,\r\n // which is precisely the outcome a scaffolder must never hand back.\r\n if (collision.outcome === \"conflict\" || collision.outcome === \"failed\") {\r\n const verb =\r\n collision.outcome === \"failed\" ? colors.redBright(\"✗\") : colors.yellowBright(\"!\");\r\n\r\n console.log(\r\n `${verb} Did not create src/web/index.page.tsx: ${collision.reason}.\\n` +\r\n ` The page stub declares ${colors.yellowBright('route.path = \"/\"')}, and two handlers on one ` +\r\n \"path is a 500 at request time, not a startup error.\\n\" +\r\n ` Free up ${colors.yellowBright('GET \"/\"')} under src/app — move it to a path of its own, ` +\r\n \"or remove it — then create src/web/index.page.tsx yourself. Giving the page a `route` other \" +\r\n \"than `/` works too.\",\r\n );\r\n\r\n // Non-zero on BOTH branches. The page layer this command exists to\r\n // scaffold was not scaffolded, and a 0 here is the exact \"looked like it\r\n // worked\" signal that put `/` in this state to begin with — a conflict we\r\n // declined to guess at is still an incomplete install, not a success.\r\n //\r\n // `exitCode` rather than `exit(1)`: the connector below still has to be\r\n // registered, and any other feature in the same `warlock add` invocation\r\n // still has to install, or the project is left half-wired on top of this.\r\n process.exitCode = 1;\r\n } else {\r\n await putFileAsync(srcPath(\"web/index.page.tsx\"), webHomePageStub);\r\n await putFileAsync(srcPath(\"web/index.register.ts\"), webHomeRegisterStub);\r\n\r\n // Unlike root.tsx above, the contact controller and its routes file are\r\n // ordinary application files a project can already have — independently\r\n // of ever having run `warlock add web`. Each is guarded on its OWN\r\n // existence, not on the (already-consumed) root.tsx sentinel, so an\r\n // existing `contact` module is skipped rather than clobbered. See\r\n // resolveContactScaffold's doc comment for why.\r\n const contactPlan = resolveContactScaffold({\r\n controllerExists: await fileExistsAsync(\r\n srcPath(\"app/contact/controllers/contact.controller.ts\"),\r\n ),\r\n routesExists: await fileExistsAsync(srcPath(\"app/contact/routes.ts\")),\r\n });\r\n\r\n if (contactPlan.writeController) {\r\n await ensureDirectoryAsync(srcPath(\"app/contact/controllers\"));\r\n await putFileAsync(\r\n srcPath(\"app/contact/controllers/contact.controller.ts\"),\r\n webContactControllerStub,\r\n );\r\n }\r\n\r\n if (contactPlan.writeRoutes) {\r\n await ensureDirectoryAsync(srcPath(\"app/contact\"));\r\n await putFileAsync(srcPath(\"app/contact/routes.ts\"), webContactRoutesStub);\r\n }\r\n\r\n console.log(`${colors.green(\"✓\")} Created src/web/index.page.tsx`);\r\n\r\n for (const message of contactPlan.messages) {\r\n console.log(message);\r\n }\r\n }\r\n }\r\n\r\n await registerWebConnector();\r\n}\r\n\r\nexport const webFeature: FeatureDefinition = {\r\n description:\r\n \"Installs @warlock.js/web — SSR React pages served by the Warlock HTTP server. Scaffolds src/web (root.tsx + a home page) and registers the WebConnector in warlock.config.ts. Pages are opt-in: a Warlock app is an API until you add this.\",\r\n dependencies: {\r\n \"@warlock.js/web\": INSTALLED_WARLOCK_VERSION,\r\n \"@mongez/http\": \"^3.5.0\",\r\n \"@mongez/react-form\": \"^4.0.0\",\r\n \"@mongez/react-localization\": \"^3.4.7\",\r\n react: \"^19.2.3\",\r\n \"react-dom\": \"^19.2.3\",\r\n },\r\n devDependencies: {\r\n \"@types/react\": \"^19.2.7\",\r\n \"@types/react-dom\": \"^19.2.3\",\r\n // Loaded through `await import()` by the dev server only, so both are\r\n // optional peers of `web` rather than hard dependencies.\r\n vite: VITE_RANGE,\r\n \"@vitejs/plugin-react\": \"^5.2.0\",\r\n },\r\n onExecuting: completeWebInstallation,\r\n};\r\n"],"mappings":";;;;;;;;;;;;AAiBA,MAAa,aAAa;;;;;;;;;;;;;;;;;;;AAoB1B,eAAe,uBAAsC;CACnD,MAAM,aAAa,SAAS,mBAAmB;CAE/C,IAAI,CAAE,MAAM,gBAAgB,UAAU,GAAI;EACxC,QAAQ,IACN,GAAG,OAAO,aAAa,mBAAmB,EAAE,+JAG9C;EAEA;CACF;CAEA,MAAM,UAAU,MAAM,aAAa,UAAU;CAE7C,IAAI,QAAQ,SAAS,cAAc,GAAG;EACpC,QAAQ,IAAI,GAAG,OAAO,aAAa,cAAc,EAAE,iCAAiC;EAEpF;CACF;CAEA,MAAM,aAAa;CACnB,IAAI,OAAO,QAAQ,SAAS,UAAU,IAAI,UAAU,GAAG,WAAW,IAAI;CAItE,MAAM,YAAY,qBAAqB,MAAM,gBAAgB;CAE7D,IAAI,UAAU,WAAW,mBACvB;CAGF,IAAI,UAAU,WAAW,SACvB,OAAO,UAAU;MACZ,IAAI,KAAK,SAAS,gBAAgB,GACvC,OAAO,KAAK,QAAQ,kBAAkB,iDAAiD;MAClF;EACL,QAAQ,IACN,GAAG,OAAO,aAAa,mBAAmB,EAAE,0FAE9C;EAEA;CACF;CAEA,MAAM,aAAa,YAAY,IAAI;CACnC,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,8CAA8C;AACjF;;;;;;;AAQA,eAAe,wBAAwB,UAA6B;CAClE,MAAM,WAAW,QAAQ,cAAc;CAEvC,IAAI,MAAM,gBAAgB,QAAQ,GAChC,QAAQ,IAAI,GAAG,OAAO,aAAa,SAAS,EAAE,iCAAiC;MAC1E;EACL,MAAM,qBAAqB,QAAQ,KAAK,CAAC;EACzC,MAAM,aAAa,UAAU,WAAW;EACxC,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,0BAA0B;EAE3D,MAAM,YAAY,MAAM,6BAA6B;EAErD,IAAI,UAAU,YAAY,aACxB,QAAQ,IACN,GAAG,OAAO,MAAM,GAAG,EAAE,sBAAsB,OAAO,aAAa,WAAS,EAAE,YACrE,OAAO,aAAa,cAAY,EAAE,MAAM,OAAO,aAAa,OAAO,UAAU,cAAc,EAAE,sFAEpG;EAMF,IAAI,UAAU,YAAY,cAAc,UAAU,YAAY,UAAU;GACtE,MAAM,OACJ,UAAU,YAAY,WAAW,OAAO,UAAU,GAAG,IAAI,OAAO,aAAa,GAAG;GAElF,QAAQ,IACN,GAAG,KAAK,0CAA0C,UAAU,OAAO,8BACrC,OAAO,aAAa,oBAAkB,EAAE;YAEvD,OAAO,aAAa,WAAS,EAAE,mKAGhD;GAUA,QAAQ,WAAW;EACrB,OAAO;GACL,MAAM,aAAa,QAAQ,oBAAoB,GAAG,eAAe;GACjE,MAAM,aAAa,QAAQ,uBAAuB,GAAG,mBAAmB;GAQxE,MAAM,cAAc,uBAAuB;IACzC,kBAAkB,MAAM,gBACtB,QAAQ,+CAA+C,CACzD;IACA,cAAc,MAAM,gBAAgB,QAAQ,uBAAuB,CAAC;GACtE,CAAC;GAED,IAAI,YAAY,iBAAiB;IAC/B,MAAM,qBAAqB,QAAQ,yBAAyB,CAAC;IAC7D,MAAM,aACJ,QAAQ,+CAA+C,GACvD,wBACF;GACF;GAEA,IAAI,YAAY,aAAa;IAC3B,MAAM,qBAAqB,QAAQ,aAAa,CAAC;IACjD,MAAM,aAAa,QAAQ,uBAAuB,GAAG,oBAAoB;GAC3E;GAEA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,gCAAgC;GAEjE,KAAK,MAAM,WAAW,YAAY,UAChC,QAAQ,IAAI,OAAO;EAEvB;CACF;CAEA,MAAM,qBAAqB;AAC7B;AAEA,MAAa,aAAgC;CAC3C,aACE;CACF,cAAc;EACZ,mBAAmB;EACnB,gBAAgB;EAChB,sBAAsB;EACtB,8BAA8B;EAC9B,OAAO;EACP,aAAa;CACf;CACA,iBAAiB;EACf,gBAAgB;EAChB,oBAAoB;EAGpB,MAAM;EACN,wBAAwB;CAC1B;CACA,aAAa;AACf"}
@@ -1 +1 @@
1
- {"version":3,"file":"csrf-origin-policy.d.mts","names":[],"sources":["../../../../../../../core/src/http/csrf-origin-policy.ts"],"mappings":";;;;KAyBY,qBAAA;AAAZ;AAAA,KAMY,iBAAA;EACN,OAAA;AAAA;EACA,OAAA;EAAgB,MAAA,EAAQ,qBAAqB;AAAA;;;;;;;;AAAA;AAgBnD;;;;AAA0C;AAQ1C;iBARgB,SAAA,CAAU,OAAgB,EAAP,OAAO;;iBAQ1B,QAAA,CAAS,MAAc;AAAA;AAgBvC;;;;AAA8C;AAhBP,iBAgBvB,eAAA,CAAgB,MAAc;;iBAc9B,mBAAA,CAAoB,MAAA,UAAgB,OAAA,EAAS,OAAO;;;;;;AAAA;AAmBpE;;;;;iBAAgB,wBAAA,CAAyB,OAAA,EAAS,OAAA,GAAU,iBAAiB"}
1
+ {"version":3,"file":"csrf-origin-policy.d.mts","names":[],"sources":["../../../../../../../core/src/http/csrf-origin-policy.ts"],"mappings":";;;;KAyBY,qBAAA;AAAZ;AAAA,KAMY,iBAAA;EACN,OAAA;AAAA;EACA,OAAA;EAAgB,MAAA,EAAQ,qBAAqB;AAAA;;;;;;;;AAAA;AAgBnD;;;;AAA0C;AAgB1C;iBAhBgB,SAAA,CAAU,OAAgB,EAAP,OAAO;;iBAgB1B,QAAA,CAAS,MAAc;AAAA;AAgBvC;;;;AAA8C;AAhBP,iBAgBvB,eAAA,CAAgB,MAAc;;iBAc9B,mBAAA,CAAoB,MAAA,UAAgB,OAAA,EAAS,OAAO;;;;;;AAAA;AAqBpE;;;;;iBAAgB,wBAAA,CAAyB,OAAA,EAAS,OAAA,GAAU,iBAAiB"}
@@ -37,8 +37,9 @@ import config from "@mongez/config";
37
37
  * function does not need its own proxy-trust logic on top of that.
38
38
  */
39
39
  function ownOrigin(request) {
40
+ const fastifyHost = request.baseRequest?.host;
40
41
  const hostHeader = request.header("host");
41
- const host = typeof hostHeader === "string" && hostHeader ? hostHeader : request.hostname;
42
+ const host = typeof fastifyHost === "string" && fastifyHost ? fastifyHost : typeof hostHeader === "string" && hostHeader ? hostHeader : request.hostname;
42
43
  return `${request.protocol}://${host}`;
43
44
  }
44
45
  /** Extract `scheme://host` from a full URL (e.g. a `Referer` header value). */
@@ -68,7 +69,9 @@ function normalizeOrigin(origin) {
68
69
  /** Same-origin, or an explicit entry in `auth.csrf.allowedOrigins` (default `[]`). */
69
70
  function isAllowedCsrfOrigin(origin, request) {
70
71
  if (normalizeOrigin(origin) === normalizeOrigin(ownOrigin(request))) return true;
71
- return config.get("auth.csrf.allowedOrigins", []).includes(origin);
72
+ const allowedOrigins = config.get("auth.csrf.allowedOrigins", []);
73
+ const normalized = normalizeOrigin(origin);
74
+ return allowedOrigins.some((allowed) => normalizeOrigin(allowed) === normalized);
72
75
  }
73
76
  /**
74
77
  * The shared CSRF Origin/Referer verdict (lead decision, card 8a752ab2 §3.1):
@@ -1 +1 @@
1
- {"version":3,"file":"csrf-origin-policy.mjs","names":[],"sources":["../../../../../../../core/src/http/csrf-origin-policy.ts"],"sourcesContent":["/**\n * The Origin/Referer same-origin (or `auth.csrf.allowedOrigins`) policy — the\n * one comparison every CSRF-Origin check in the framework must agree on.\n *\n * SECURITY, card 8a752ab2 (5.17). Extracted from\n * `@warlock.js/auth`'s `csrf-origin-check.ts` (the ONLY prior owner of this\n * logic) into `@warlock.js/core` so both call sites — the\n * `authMiddleware(\"cookie:*\")` check in `@warlock.js/auth` and the default\n * core HTTP-seam guard in `./csrf-default-guard.ts` — share ONE\n * implementation instead of drifting copies. `@warlock.js/core` cannot depend\n * on `@warlock.js/auth` (the dependency runs the other way), so the shared\n * logic had to move down into `core`, not up into a new shared package —\n * `auth`'s `csrf-origin-check.ts` now delegates to\n * {@link resolveCsrfOriginVerdict} instead of reimplementing it.\n *\n * Reads `auth.csrf.allowedOrigins` directly via `@mongez/config` (the exact\n * same config key `@warlock.js/auth`'s `authConfig.csrf.allowedOrigins()`\n * reads) rather than importing `@warlock.js/auth`, so an app's one\n * `auth.csrf.allowedOrigins` setting governs both checks without `core`\n * taking a hard dependency on `auth`.\n */\nimport config from \"@mongez/config\";\nimport type { Request } from \"./request\";\n\n/** Why {@link resolveCsrfOriginVerdict} refused a request. */\nexport type CsrfOriginCheckReason =\n | \"origin-mismatch\"\n | \"referer-mismatch\"\n | \"missing-origin-and-referer\";\n\n/** The verdict {@link resolveCsrfOriginVerdict} reaches for one request. */\nexport type CsrfOriginVerdict =\n | { allowed: true }\n | { allowed: false; reason: CsrfOriginCheckReason };\n\n/**\n * The request's own origin — what an `Origin`/`Referer` header must match.\n *\n * `request.hostname` (core's `Request`, backed by Fastify's `hostname`) never\n * carries a port. A browser's `Origin` header on a non-default port (e.g. any\n * `warlock dev` session) does, so the port must come from the raw `Host`\n * header instead — core exposes no getter for that, so it is read directly\n * here.\n *\n * Trust-proxy-aware for free: `request.protocol` is Fastify's own\n * `request.protocol`, which already honours `http.trustProxy`\n * (`X-Forwarded-Proto`) when the app has configured it (`server.ts`) — this\n * function does not need its own proxy-trust logic on top of that.\n */\nexport function ownOrigin(request: Request): string {\n const hostHeader = request.header(\"host\");\n const host = typeof hostHeader === \"string\" && hostHeader ? hostHeader : request.hostname;\n\n return `${request.protocol}://${host}`;\n}\n\n/** Extract `scheme://host` from a full URL (e.g. a `Referer` header value). */\nexport function originOf(rawUrl: string): string | undefined {\n try {\n const url = new URL(rawUrl);\n\n return `${url.protocol}//${url.host}`;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Normalize an origin string (`scheme://host[:port]`) so that a default port\n * (`:80` on `http:`, `:443` on `https:`) compares equal to the same origin\n * written without a port. Falls back to the raw value if it does not parse\n * as a URL (in which case it will simply fail the exact-match comparison).\n */\nexport function normalizeOrigin(origin: string): string {\n try {\n const url = new URL(origin);\n const isDefaultPort =\n (url.protocol === \"http:\" && (url.port === \"\" || url.port === \"80\")) ||\n (url.protocol === \"https:\" && (url.port === \"\" || url.port === \"443\"));\n\n return `${url.protocol}//${isDefaultPort ? url.hostname : url.host}`;\n } catch {\n return origin;\n }\n}\n\n/** Same-origin, or an explicit entry in `auth.csrf.allowedOrigins` (default `[]`). */\nexport function isAllowedCsrfOrigin(origin: string, request: Request): boolean {\n if (normalizeOrigin(origin) === normalizeOrigin(ownOrigin(request))) return true;\n\n const allowedOrigins: string[] = config.get(\"auth.csrf.allowedOrigins\", []);\n\n return allowedOrigins.includes(origin);\n}\n\n/**\n * The shared CSRF Origin/Referer verdict (lead decision, card 8a752ab2 §3.1):\n * allowed when `Origin` — or, when `Origin` is absent, `Referer` — names the\n * request's own origin or an entry in `auth.csrf.allowedOrigins`. Refused\n * otherwise, including when BOTH headers are absent (fail closed).\n *\n * Callers decide WHETHER a request is in scope for this check at all (a\n * cookie-sourced credential / a non-locale cookie, an unsafe method, no route\n * exemption) — this function only answers the Origin/Referer question once a\n * caller has already decided the check applies.\n */\nexport function resolveCsrfOriginVerdict(request: Request): CsrfOriginVerdict {\n const origin = request.origin;\n\n if (origin) {\n return isAllowedCsrfOrigin(origin, request)\n ? { allowed: true }\n : { allowed: false, reason: \"origin-mismatch\" };\n }\n\n const referer = request.header(\"referer\");\n const refererOrigin = typeof referer === \"string\" ? originOf(referer) : undefined;\n\n if (refererOrigin) {\n return isAllowedCsrfOrigin(refererOrigin, request)\n ? { allowed: true }\n : { allowed: false, reason: \"referer-mismatch\" };\n }\n\n return { allowed: false, reason: \"missing-origin-and-referer\" };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiDA,SAAgB,UAAU,SAA0B;CAClD,MAAM,aAAa,QAAQ,OAAO,MAAM;CACxC,MAAM,OAAO,OAAO,eAAe,YAAY,aAAa,aAAa,QAAQ;CAEjF,OAAO,GAAG,QAAQ,SAAS,KAAK;AAClC;;AAGA,SAAgB,SAAS,QAAoC;CAC3D,IAAI;EACF,MAAM,MAAM,IAAI,IAAI,MAAM;EAE1B,OAAO,GAAG,IAAI,SAAS,IAAI,IAAI;CACjC,QAAQ;EACN;CACF;AACF;;;;;;;AAQA,SAAgB,gBAAgB,QAAwB;CACtD,IAAI;EACF,MAAM,MAAM,IAAI,IAAI,MAAM;EAC1B,MAAM,gBACH,IAAI,aAAa,YAAY,IAAI,SAAS,MAAM,IAAI,SAAS,SAC7D,IAAI,aAAa,aAAa,IAAI,SAAS,MAAM,IAAI,SAAS;EAEjE,OAAO,GAAG,IAAI,SAAS,IAAI,gBAAgB,IAAI,WAAW,IAAI;CAChE,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAgB,oBAAoB,QAAgB,SAA2B;CAC7E,IAAI,gBAAgB,MAAM,MAAM,gBAAgB,UAAU,OAAO,CAAC,GAAG,OAAO;CAI5E,OAFiC,OAAO,IAAI,4BAA4B,CAAC,CAErD,CAAC,CAAC,SAAS,MAAM;AACvC;;;;;;;;;;;;AAaA,SAAgB,yBAAyB,SAAqC;CAC5E,MAAM,SAAS,QAAQ;CAEvB,IAAI,QACF,OAAO,oBAAoB,QAAQ,OAAO,IACtC,EAAE,SAAS,KAAK,IAChB;EAAE,SAAS;EAAO,QAAQ;CAAkB;CAGlD,MAAM,UAAU,QAAQ,OAAO,SAAS;CACxC,MAAM,gBAAgB,OAAO,YAAY,WAAW,SAAS,OAAO,IAAI;CAExE,IAAI,eACF,OAAO,oBAAoB,eAAe,OAAO,IAC7C,EAAE,SAAS,KAAK,IAChB;EAAE,SAAS;EAAO,QAAQ;CAAmB;CAGnD,OAAO;EAAE,SAAS;EAAO,QAAQ;CAA6B;AAChE"}
1
+ {"version":3,"file":"csrf-origin-policy.mjs","names":[],"sources":["../../../../../../../core/src/http/csrf-origin-policy.ts"],"sourcesContent":["/**\n * The Origin/Referer same-origin (or `auth.csrf.allowedOrigins`) policy — the\n * one comparison every CSRF-Origin check in the framework must agree on.\n *\n * SECURITY, card 8a752ab2 (5.17). Extracted from\n * `@warlock.js/auth`'s `csrf-origin-check.ts` (the ONLY prior owner of this\n * logic) into `@warlock.js/core` so both call sites — the\n * `authMiddleware(\"cookie:*\")` check in `@warlock.js/auth` and the default\n * core HTTP-seam guard in `./csrf-default-guard.ts` — share ONE\n * implementation instead of drifting copies. `@warlock.js/core` cannot depend\n * on `@warlock.js/auth` (the dependency runs the other way), so the shared\n * logic had to move down into `core`, not up into a new shared package —\n * `auth`'s `csrf-origin-check.ts` now delegates to\n * {@link resolveCsrfOriginVerdict} instead of reimplementing it.\n *\n * Reads `auth.csrf.allowedOrigins` directly via `@mongez/config` (the exact\n * same config key `@warlock.js/auth`'s `authConfig.csrf.allowedOrigins()`\n * reads) rather than importing `@warlock.js/auth`, so an app's one\n * `auth.csrf.allowedOrigins` setting governs both checks without `core`\n * taking a hard dependency on `auth`.\n */\nimport config from \"@mongez/config\";\nimport type { Request } from \"./request\";\n\n/** Why {@link resolveCsrfOriginVerdict} refused a request. */\nexport type CsrfOriginCheckReason =\n | \"origin-mismatch\"\n | \"referer-mismatch\"\n | \"missing-origin-and-referer\";\n\n/** The verdict {@link resolveCsrfOriginVerdict} reaches for one request. */\nexport type CsrfOriginVerdict =\n | { allowed: true }\n | { allowed: false; reason: CsrfOriginCheckReason };\n\n/**\n * The request's own origin — what an `Origin`/`Referer` header must match.\n *\n * `request.hostname` (core's `Request`, backed by Fastify's `hostname`) never\n * carries a port. A browser's `Origin` header on a non-default port (e.g. any\n * `warlock dev` session) does, so the port must come from the raw `Host`\n * header instead — core exposes no getter for that, so it is read directly\n * here.\n *\n * Trust-proxy-aware for free: `request.protocol` is Fastify's own\n * `request.protocol`, which already honours `http.trustProxy`\n * (`X-Forwarded-Proto`) when the app has configured it (`server.ts`) — this\n * function does not need its own proxy-trust logic on top of that.\n */\nexport function ownOrigin(request: Request): string {\n // Fastify's `host` keeps the port and honours `X-Forwarded-Host` under\n // `trustProxy`, so a proxy that rewrites `Host` does not break the match.\n const fastifyHost = request.baseRequest?.host;\n const hostHeader = request.header(\"host\");\n const host =\n typeof fastifyHost === \"string\" && fastifyHost\n ? fastifyHost\n : typeof hostHeader === \"string\" && hostHeader\n ? hostHeader\n : request.hostname;\n\n return `${request.protocol}://${host}`;\n}\n\n/** Extract `scheme://host` from a full URL (e.g. a `Referer` header value). */\nexport function originOf(rawUrl: string): string | undefined {\n try {\n const url = new URL(rawUrl);\n\n return `${url.protocol}//${url.host}`;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Normalize an origin string (`scheme://host[:port]`) so that a default port\n * (`:80` on `http:`, `:443` on `https:`) compares equal to the same origin\n * written without a port. Falls back to the raw value if it does not parse\n * as a URL (in which case it will simply fail the exact-match comparison).\n */\nexport function normalizeOrigin(origin: string): string {\n try {\n const url = new URL(origin);\n const isDefaultPort =\n (url.protocol === \"http:\" && (url.port === \"\" || url.port === \"80\")) ||\n (url.protocol === \"https:\" && (url.port === \"\" || url.port === \"443\"));\n\n return `${url.protocol}//${isDefaultPort ? url.hostname : url.host}`;\n } catch {\n return origin;\n }\n}\n\n/** Same-origin, or an explicit entry in `auth.csrf.allowedOrigins` (default `[]`). */\nexport function isAllowedCsrfOrigin(origin: string, request: Request): boolean {\n if (normalizeOrigin(origin) === normalizeOrigin(ownOrigin(request))) return true;\n\n const allowedOrigins: string[] = config.get(\"auth.csrf.allowedOrigins\", []);\n\n const normalized = normalizeOrigin(origin);\n\n return allowedOrigins.some(allowed => normalizeOrigin(allowed) === normalized);\n}\n\n/**\n * The shared CSRF Origin/Referer verdict (lead decision, card 8a752ab2 §3.1):\n * allowed when `Origin` — or, when `Origin` is absent, `Referer` — names the\n * request's own origin or an entry in `auth.csrf.allowedOrigins`. Refused\n * otherwise, including when BOTH headers are absent (fail closed).\n *\n * Callers decide WHETHER a request is in scope for this check at all (a\n * cookie-sourced credential / a non-locale cookie, an unsafe method, no route\n * exemption) — this function only answers the Origin/Referer question once a\n * caller has already decided the check applies.\n */\nexport function resolveCsrfOriginVerdict(request: Request): CsrfOriginVerdict {\n const origin = request.origin;\n\n if (origin) {\n return isAllowedCsrfOrigin(origin, request)\n ? { allowed: true }\n : { allowed: false, reason: \"origin-mismatch\" };\n }\n\n const referer = request.header(\"referer\");\n const refererOrigin = typeof referer === \"string\" ? originOf(referer) : undefined;\n\n if (refererOrigin) {\n return isAllowedCsrfOrigin(refererOrigin, request)\n ? { allowed: true }\n : { allowed: false, reason: \"referer-mismatch\" };\n }\n\n return { allowed: false, reason: \"missing-origin-and-referer\" };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiDA,SAAgB,UAAU,SAA0B;CAGlD,MAAM,cAAc,QAAQ,aAAa;CACzC,MAAM,aAAa,QAAQ,OAAO,MAAM;CACxC,MAAM,OACJ,OAAO,gBAAgB,YAAY,cAC/B,cACA,OAAO,eAAe,YAAY,aAChC,aACA,QAAQ;CAEhB,OAAO,GAAG,QAAQ,SAAS,KAAK;AAClC;;AAGA,SAAgB,SAAS,QAAoC;CAC3D,IAAI;EACF,MAAM,MAAM,IAAI,IAAI,MAAM;EAE1B,OAAO,GAAG,IAAI,SAAS,IAAI,IAAI;CACjC,QAAQ;EACN;CACF;AACF;;;;;;;AAQA,SAAgB,gBAAgB,QAAwB;CACtD,IAAI;EACF,MAAM,MAAM,IAAI,IAAI,MAAM;EAC1B,MAAM,gBACH,IAAI,aAAa,YAAY,IAAI,SAAS,MAAM,IAAI,SAAS,SAC7D,IAAI,aAAa,aAAa,IAAI,SAAS,MAAM,IAAI,SAAS;EAEjE,OAAO,GAAG,IAAI,SAAS,IAAI,gBAAgB,IAAI,WAAW,IAAI;CAChE,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAgB,oBAAoB,QAAgB,SAA2B;CAC7E,IAAI,gBAAgB,MAAM,MAAM,gBAAgB,UAAU,OAAO,CAAC,GAAG,OAAO;CAE5E,MAAM,iBAA2B,OAAO,IAAI,4BAA4B,CAAC,CAAC;CAE1E,MAAM,aAAa,gBAAgB,MAAM;CAEzC,OAAO,eAAe,MAAK,YAAW,gBAAgB,OAAO,MAAM,UAAU;AAC/E;;;;;;;;;;;;AAaA,SAAgB,yBAAyB,SAAqC;CAC5E,MAAM,SAAS,QAAQ;CAEvB,IAAI,QACF,OAAO,oBAAoB,QAAQ,OAAO,IACtC,EAAE,SAAS,KAAK,IAChB;EAAE,SAAS;EAAO,QAAQ;CAAkB;CAGlD,MAAM,UAAU,QAAQ,OAAO,SAAS;CACxC,MAAM,gBAAgB,OAAO,YAAY,WAAW,SAAS,OAAO,IAAI;CAExE,IAAI,eACF,OAAO,oBAAoB,eAAe,OAAO,IAC7C,EAAE,SAAS,KAAK,IAChB;EAAE,SAAS;EAAO,QAAQ;CAAmB;CAGnD,OAAO;EAAE,SAAS;EAAO,QAAQ;CAA6B;AAChE"}
@@ -0,0 +1,52 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+
4
+ //#region ../core/src/http/flush-pending-cookies.ts
5
+ /** The installed `@fastify/cookie` version, or undefined when it can't be resolved. */
6
+ function installedCookiePluginVersion() {
7
+ try {
8
+ const require = createRequire(path.join(process.cwd(), "noop.js"));
9
+ return require(require.resolve("@fastify/cookie/package.json")).version;
10
+ } catch {
11
+ return;
12
+ }
13
+ }
14
+ /**
15
+ * `@fastify/cookie` only parks cookies set via `reply.setCookie()` in a map
16
+ * keyed by a private symbol; it serialises them in its own `onSend` hook.
17
+ * Raw-path writers (`raw.writeHead`) bypass `onSend`, so they must call this
18
+ * right before writing headers.
19
+ *
20
+ * Serialises the parked cookies, appends them to the `set-cookie` header
21
+ * (skipping any value already present) and clears the map so a later `onSend`
22
+ * cannot send them twice.
23
+ *
24
+ * Throws when the plugin is registered but its parked-cookie symbol cannot be
25
+ * found: silently doing nothing would drop every cookie on raw paths. Does
26
+ * nothing when the plugin is not registered.
27
+ */
28
+ function flushPendingCookies(reply) {
29
+ const parkedSymbol = Object.getOwnPropertySymbols(reply).find((symbol) => symbol.description === "fastify.reply.setCookies");
30
+ if (!parkedSymbol) {
31
+ if (typeof reply.setCookie !== "function") return;
32
+ const version = installedCookiePluginVersion();
33
+ throw new Error(`@fastify/cookie's internal cookie storage changed: reply.setCookie exists but the "fastify.reply.setCookies" symbol was not found${version ? ` (installed @fastify/cookie@${version})` : ""}. Cookies set before a raw-path response would be lost; update flushPendingCookies for this version.`);
34
+ }
35
+ const parked = reply[parkedSymbol];
36
+ if (!parked || parked.size === 0) return;
37
+ const serializeCookie = reply.server.serializeCookie;
38
+ if (!serializeCookie) return;
39
+ const existing = reply.getHeader("set-cookie");
40
+ const cookies = existing === void 0 ? [] : Array.isArray(existing) ? [...existing] : [String(existing)];
41
+ for (const cookie of parked.values()) {
42
+ const serialized = serializeCookie(cookie.name, cookie.value, cookie.opts);
43
+ if (!cookies.includes(serialized)) cookies.push(serialized);
44
+ }
45
+ reply.removeHeader("set-cookie");
46
+ reply.header("set-cookie", cookies);
47
+ parked.clear();
48
+ }
49
+
50
+ //#endregion
51
+ export { flushPendingCookies };
52
+ //# sourceMappingURL=flush-pending-cookies.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flush-pending-cookies.mjs","names":[],"sources":["../../../../../../../core/src/http/flush-pending-cookies.ts"],"sourcesContent":["import { createRequire } from \"node:module\";\nimport path from \"node:path\";\nimport type { FastifyReply } from \"fastify\";\n\ntype ParkedCookie = { name: string; value: string; opts?: Record<string, unknown> };\n\n/** The installed `@fastify/cookie` version, or undefined when it can't be resolved. */\nfunction installedCookiePluginVersion(): string | undefined {\n try {\n const require = createRequire(path.join(process.cwd(), \"noop.js\"));\n\n return (require(require.resolve(\"@fastify/cookie/package.json\")) as { version?: string })\n .version;\n } catch {\n return undefined;\n }\n}\n\n/**\n * `@fastify/cookie` only parks cookies set via `reply.setCookie()` in a map\n * keyed by a private symbol; it serialises them in its own `onSend` hook.\n * Raw-path writers (`raw.writeHead`) bypass `onSend`, so they must call this\n * right before writing headers.\n *\n * Serialises the parked cookies, appends them to the `set-cookie` header\n * (skipping any value already present) and clears the map so a later `onSend`\n * cannot send them twice.\n *\n * Throws when the plugin is registered but its parked-cookie symbol cannot be\n * found: silently doing nothing would drop every cookie on raw paths. Does\n * nothing when the plugin is not registered.\n */\nexport function flushPendingCookies(reply: FastifyReply): void {\n const parkedSymbol = Object.getOwnPropertySymbols(reply).find(\n symbol => symbol.description === \"fastify.reply.setCookies\",\n );\n\n if (!parkedSymbol) {\n if (typeof (reply as { setCookie?: unknown }).setCookie !== \"function\") return;\n\n const version = installedCookiePluginVersion();\n\n throw new Error(\n `@fastify/cookie's internal cookie storage changed: reply.setCookie exists but the \"fastify.reply.setCookies\" symbol was not found${\n version ? ` (installed @fastify/cookie@${version})` : \"\"\n }. Cookies set before a raw-path response would be lost; update flushPendingCookies for this version.`,\n );\n }\n\n const parked = (reply as unknown as Record<symbol, Map<string, ParkedCookie> | null>)[\n parkedSymbol\n ];\n\n if (!parked || parked.size === 0) return;\n\n const serializeCookie = (\n reply.server as unknown as {\n serializeCookie?: (name: string, value: string, opts?: unknown) => string;\n }\n ).serializeCookie;\n\n if (!serializeCookie) return;\n\n const existing = reply.getHeader(\"set-cookie\");\n const cookies: string[] =\n existing === undefined ? [] : Array.isArray(existing) ? [...existing] : [String(existing)];\n\n for (const cookie of parked.values()) {\n const serialized = serializeCookie(cookie.name, cookie.value, cookie.opts);\n\n if (!cookies.includes(serialized)) {\n cookies.push(serialized);\n }\n }\n\n reply.removeHeader(\"set-cookie\");\n reply.header(\"set-cookie\", cookies);\n parked.clear();\n}\n"],"mappings":";;;;;AAOA,SAAS,+BAAmD;CAC1D,IAAI;EACF,MAAM,UAAU,cAAc,KAAK,KAAK,QAAQ,IAAI,GAAG,SAAS,CAAC;EAEjE,OAAQ,QAAQ,QAAQ,QAAQ,8BAA8B,CAAC,CAAC,CAC7D;CACL,QAAQ;EACN;CACF;AACF;;;;;;;;;;;;;;;AAgBA,SAAgB,oBAAoB,OAA2B;CAC7D,MAAM,eAAe,OAAO,sBAAsB,KAAK,CAAC,CAAC,MACvD,WAAU,OAAO,gBAAgB,0BACnC;CAEA,IAAI,CAAC,cAAc;EACjB,IAAI,OAAQ,MAAkC,cAAc,YAAY;EAExE,MAAM,UAAU,6BAA6B;EAE7C,MAAM,IAAI,MACR,oIACE,UAAU,+BAA+B,QAAQ,KAAK,GACvD,qGACH;CACF;CAEA,MAAM,SAAU,MACd;CAGF,IAAI,CAAC,UAAU,OAAO,SAAS,GAAG;CAElC,MAAM,kBACJ,MAAM,OAGN;CAEF,IAAI,CAAC,iBAAiB;CAEtB,MAAM,WAAW,MAAM,UAAU,YAAY;CAC7C,MAAM,UACJ,aAAa,SAAY,CAAC,IAAI,MAAM,QAAQ,QAAQ,IAAI,CAAC,GAAG,QAAQ,IAAI,CAAC,OAAO,QAAQ,CAAC;CAE3F,KAAK,MAAM,UAAU,OAAO,OAAO,GAAG;EACpC,MAAM,aAAa,gBAAgB,OAAO,MAAM,OAAO,OAAO,OAAO,IAAI;EAEzE,IAAI,CAAC,QAAQ,SAAS,UAAU,GAC9B,QAAQ,KAAK,UAAU;CAE3B;CAEA,MAAM,aAAa,YAAY;CAC/B,MAAM,OAAO,cAAc,OAAO;CAClC,OAAO,MAAM;AACf"}
@@ -1 +1 @@
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,aAAqB,OAAA,EAAS,OAAA,iBAAwB,OAAA,EAAS,OAAA,KAAY,OAAA;EAA7C;;;;;;EAO9B,UAAA;EAP8B;;;;;EAa9B,IAAA;EAAA;;;EAIA,GAAA;EAmB6B;;;AAAO;AAyDtC;;EArEE,MAAA;EAqEgG;;;;;AAAA;;;;;;EAzDhG,IAAA,gBAAoB,OAAA,EAAS,OAAA;AAAA;AAAA,iBAyDf,eAAA,CAAgB,oBAAA,EAAsB,sBAAA,YAAkC,UAAU"}
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,aAAqB,OAAA,EAAS,OAAA,iBAAwB,OAAA,EAAS,OAAA,KAAY,OAAA;EAA7C;;;;;;EAO9B,UAAA;EAP8B;;;;;EAa9B,IAAA;EAAA;;;EAIA,GAAA;EAmB6B;;;AAAO;AA+DtC;;EA3EE,MAAA;EA2EgG;;;;;AAAA;;;;;;EA/DhG,IAAA,gBAAoB,OAAA,EAAS,OAAA;AAAA;AAAA,iBA+Df,eAAA,CAAgB,oBAAA,EAAsB,sBAAA,YAAkC,UAAU"}
@@ -16,12 +16,14 @@ function resolveCacheTags(tags, request) {
16
16
  }
17
17
  async function parseCacheOptions(cacheOptions, request) {
18
18
  if (typeof cacheOptions === "string") cacheOptions = { cacheKey: cacheOptions };
19
- if (typeof cacheOptions.cacheKey === "function") cacheOptions.cacheKey = await cacheOptions.cacheKey(request);
19
+ const resolvedCacheKey = typeof cacheOptions.cacheKey === "function" ? await cacheOptions.cacheKey(request) : cacheOptions.cacheKey;
20
20
  const tags = resolveCacheTags(cacheOptions.tags, request);
21
21
  const finalCacheOptions = {
22
22
  ...defaultCacheOptions,
23
23
  ...cacheOptions,
24
- tags
24
+ cacheKey: resolvedCacheKey,
25
+ tags: [...tags],
26
+ omit: cacheOptions.omit ? [...cacheOptions.omit] : void 0
25
27
  };
26
28
  if (finalCacheOptions.withLocale) {
27
29
  const locale = request.getLocaleCode();
@@ -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 { 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: string | ((request: Request) => string) | ((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 * Tags this cached response is stored under, mirroring `route.cache.tags`\n * on `@warlock.js/web`'s page cache (`PageCacheOptIn.tags`,\n * `web/src/routing/route-identity.ts`) so the two caches share one mental\n * model: `cache.tags([...]).invalidate()` from `@warlock.js/cache` evicts\n * a tagged API response the same way it evicts a tagged page. Either a\n * static list, or a function of the request — resolved once per request,\n * right before the response is stored.\n *\n * @default undefined (untagged — behaves exactly as before this option existed)\n */\n tags?: string[] | ((request: Request) => string[]);\n};\n\nconst defaultCacheOptions: Partial<CacheMiddlewareOptions> = {\n withLocale: true,\n};\n\ntype ParsedCacheOptions = Required<Omit<CacheMiddlewareOptions, \"tags\">> & {\n cacheKey: string;\n /** Resolved, concrete tag list — see {@link resolveCacheTags}. */\n tags: string[];\n};\n\n/**\n * Resolves `CacheMiddlewareOptions.tags` — a static list or a function of\n * the request — into a concrete list at store time. Mirrors\n * `resolveCacheTags` in `@warlock.js/web`'s `create-page-route-handler.ts`,\n * the equivalent seam for `route.cache.tags`.\n */\nfunction resolveCacheTags(tags: CacheMiddlewareOptions[\"tags\"], request: Request): string[] {\n if (tags === undefined) return [];\n\n return typeof tags === \"function\" ? tags(request) : tags;\n}\n\nasync function parseCacheOptions(cacheOptions: CacheMiddlewareOptions | string, request: Request) {\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 tags = resolveCacheTags(cacheOptions.tags, request);\n\n const finalCacheOptions = {\n ...defaultCacheOptions,\n ...cacheOptions,\n tags,\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(responseCacheOptions: CacheMiddlewareOptions | string): 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, tags } = 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 //\n // Tagged and untagged writes go through separate calls rather than a\n // shared branch-free path so an untagged route's write stays byte\n // identical to what it was before `tags` existed.\n const write =\n tags.length > 0\n ? cacheDriver.tags(tags).set(cacheKey, content, ttl)\n : cacheDriver.set(cacheKey, content, ttl);\n\n write.catch((error: unknown) => {\n log.error(\"cache-middleware\", \"set\", error);\n });\n });\n };\n}\n"],"mappings":";;;;;AAiEA,MAAM,sBAAuD,EAC3D,YAAY,KACd;;;;;;;AAcA,SAAS,iBAAiB,MAAsC,SAA4B;CAC1F,IAAI,SAAS,QAAW,OAAO,CAAC;CAEhC,OAAO,OAAO,SAAS,aAAa,KAAK,OAAO,IAAI;AACtD;AAEA,eAAe,kBAAkB,cAA+C,SAAkB;CAChG,IAAI,OAAO,iBAAiB,UAC1B,eAAe,EACb,UAAU,aACZ;CAGF,IAAI,OAAO,aAAa,aAAa,YACnC,aAAa,WAAW,MAAM,aAAa,SAAS,OAAO;CAG7D,MAAM,OAAO,iBAAiB,aAAa,MAAM,OAAO;CAExD,MAAM,oBAAoB;EACxB,GAAG;EACH,GAAG;EACH;CACF;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,gBAAgB,sBAAmE;CAIjG,OAAO,eAAgB,EAAE,SAAS,YAAY;EAC5C,MAAM,EAAE,KAAK,MAAM,UAAU,QAAQ,SAAS,MAAM,kBAClD,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;GAaA,CAJE,KAAK,SAAS,IACV,YAAY,KAAK,IAAI,CAAC,CAAC,IAAI,UAAU,SAAS,GAAG,IACjD,YAAY,IAAI,UAAU,SAAS,GAAG,EAEvC,CAAC,OAAO,UAAmB;IAC9B,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: string | ((request: Request) => string) | ((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 * Tags this cached response is stored under, mirroring `route.cache.tags`\n * on `@warlock.js/web`'s page cache (`PageCacheOptIn.tags`,\n * `web/src/routing/route-identity.ts`) so the two caches share one mental\n * model: `cache.tags([...]).invalidate()` from `@warlock.js/cache` evicts\n * a tagged API response the same way it evicts a tagged page. Either a\n * static list, or a function of the request — resolved once per request,\n * right before the response is stored.\n *\n * @default undefined (untagged — behaves exactly as before this option existed)\n */\n tags?: string[] | ((request: Request) => string[]);\n};\n\nconst defaultCacheOptions: Partial<CacheMiddlewareOptions> = {\n withLocale: true,\n};\n\ntype ParsedCacheOptions = Required<Omit<CacheMiddlewareOptions, \"tags\">> & {\n cacheKey: string;\n /** Resolved, concrete tag list — see {@link resolveCacheTags}. */\n tags: string[];\n};\n\n/**\n * Resolves `CacheMiddlewareOptions.tags` — a static list or a function of\n * the request — into a concrete list at store time. Mirrors\n * `resolveCacheTags` in `@warlock.js/web`'s `create-page-route-handler.ts`,\n * the equivalent seam for `route.cache.tags`.\n */\nfunction resolveCacheTags(tags: CacheMiddlewareOptions[\"tags\"], request: Request): string[] {\n if (tags === undefined) return [];\n\n return typeof tags === \"function\" ? tags(request) : tags;\n}\n\nasync function parseCacheOptions(cacheOptions: CacheMiddlewareOptions | string, request: Request) {\n if (typeof cacheOptions === \"string\") {\n cacheOptions = {\n cacheKey: cacheOptions,\n };\n }\n\n // Everything below resolves into a fresh per-request object; the options\n // the factory closed over are shared across all requests and users, so they\n // must never be written to.\n const resolvedCacheKey =\n typeof cacheOptions.cacheKey === \"function\"\n ? await cacheOptions.cacheKey(request)\n : cacheOptions.cacheKey;\n\n const tags = resolveCacheTags(cacheOptions.tags, request);\n\n const finalCacheOptions = {\n ...defaultCacheOptions,\n ...cacheOptions,\n cacheKey: resolvedCacheKey,\n tags: [...tags],\n omit: cacheOptions.omit ? [...cacheOptions.omit] : undefined,\n } as unknown 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(responseCacheOptions: CacheMiddlewareOptions | string): 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, tags } = 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 //\n // Tagged and untagged writes go through separate calls rather than a\n // shared branch-free path so an untagged route's write stays byte\n // identical to what it was before `tags` existed.\n const write =\n tags.length > 0\n ? cacheDriver.tags(tags).set(cacheKey, content, ttl)\n : cacheDriver.set(cacheKey, content, ttl);\n\n write.catch((error: unknown) => {\n log.error(\"cache-middleware\", \"set\", error);\n });\n });\n };\n}\n"],"mappings":";;;;;AAiEA,MAAM,sBAAuD,EAC3D,YAAY,KACd;;;;;;;AAcA,SAAS,iBAAiB,MAAsC,SAA4B;CAC1F,IAAI,SAAS,QAAW,OAAO,CAAC;CAEhC,OAAO,OAAO,SAAS,aAAa,KAAK,OAAO,IAAI;AACtD;AAEA,eAAe,kBAAkB,cAA+C,SAAkB;CAChG,IAAI,OAAO,iBAAiB,UAC1B,eAAe,EACb,UAAU,aACZ;CAMF,MAAM,mBACJ,OAAO,aAAa,aAAa,aAC7B,MAAM,aAAa,SAAS,OAAO,IACnC,aAAa;CAEnB,MAAM,OAAO,iBAAiB,aAAa,MAAM,OAAO;CAExD,MAAM,oBAAoB;EACxB,GAAG;EACH,GAAG;EACH,UAAU;EACV,MAAM,CAAC,GAAG,IAAI;EACd,MAAM,aAAa,OAAO,CAAC,GAAG,aAAa,IAAI,IAAI;CACrD;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,gBAAgB,sBAAmE;CAIjG,OAAO,eAAgB,EAAE,SAAS,YAAY;EAC5C,MAAM,EAAE,KAAK,MAAM,UAAU,QAAQ,SAAS,MAAM,kBAClD,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;GAaA,CAJE,KAAK,SAAS,IACV,YAAY,KAAK,IAAI,CAAC,CAAC,IAAI,UAAU,SAAS,GAAG,IACjD,YAAY,IAAI,UAAU,SAAS,GAAG,EAEvC,CAAC,OAAO,UAAmB;IAC9B,IAAI,MAAM,oBAAoB,OAAO,KAAK;GAC5C,CAAC;EACH,CAAC;CACH;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"inject-request-context.d.mts","names":[],"sources":["../../../../../../../../core/src/http/middleware/inject-request-context.ts"],"mappings":";;;;;;;AA8CA;;;iBAAgB,kBAAA,CACd,OAAA,EAAS,OAAA,OACT,QAAA,EAAU,QAAA,GACT,OAAA,CAAQ,gBAAA;;;;iBAoIK,CAAA,CAAE,OAAA,UAAiB,YAAkB"}
1
+ {"version":3,"file":"inject-request-context.d.mts","names":[],"sources":["../../../../../../../../core/src/http/middleware/inject-request-context.ts"],"mappings":";;;;;;;AA8CA;;;iBAAgB,kBAAA,CACd,OAAA,EAAS,OAAA,OACT,QAAA,EAAU,QAAA,GACT,OAAA,CAAQ,gBAAA;;;;iBA4JK,CAAA,CAAE,OAAA,UAAiB,YAAkB"}
@@ -70,24 +70,35 @@ function createRequestStore(request, response) {
70
70
  return output;
71
71
  } catch (error) {
72
72
  request.log(error, "error");
73
- return handleRequestError(error, response);
73
+ return handleRequestError(error, response, request);
74
74
  }
75
75
  });
76
76
  }
77
77
  /**
78
+ * Unconditional floor for an unhandled request error (canon 8d3c13a8): route it
79
+ * through the logger, and when no logger channel is configured (nothing would
80
+ * record it) fall back to `console.error` so the only copy is never lost.
81
+ * @internal
82
+ */
83
+ function reportUnhandledRequestError(error, request) {
84
+ log.error("http", "unhandled-request-error", error, { requestId: request.id });
85
+ if (log.channels.length === 0) console.error(`[http] unhandled-request-error (requestId: ${request.id})`, error);
86
+ }
87
+ /**
78
88
  * Handle request errors
79
89
  * @internal
80
90
  */
81
- function handleRequestError(error, response) {
91
+ function handleRequestError(error, response, request) {
82
92
  response.header("Cache-Control", "private, no-store");
83
93
  if (error instanceof HttpError) {
94
+ if (error.status >= 500) log.error("http", "request-error", error, { requestId: request.id });
84
95
  const payload = { error: error.message };
85
96
  if (error.payload) payload.payload = error.payload;
86
97
  if (environment() === "development") payload.stack = error.stack;
87
98
  return response.setStatusCode(error.status).send(payload);
88
99
  }
89
100
  if (error instanceof DatabaseWriterValidationError) return response.badRequest({ errors: error.errors });
90
- console.error("[warlock] unhandled request error:", error);
101
+ reportUnhandledRequestError(error, request);
91
102
  return response.serverError({ error: "Internal server error." });
92
103
  }
93
104
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"inject-request-context.mjs","names":["requestContextInstance"],"sources":["../../../../../../../../core/src/http/middleware/inject-request-context.ts"],"sourcesContent":["/**\n * Request Context Middleware\n *\n * Creates a unified context for each request using the ContextManager.\n * All framework contexts (request, storage, database) are available throughout the request lifecycle.\n */\nimport { trans } from \"@mongez/localization\";\nimport { type GenericObject } from \"@mongez/reinforcements\";\nimport { DatabaseWriterValidationError } from \"@warlock.js/cascade\";\nimport { contextManager } from \"@warlock.js/context\";\nimport { log } from \"@warlock.js/logger\";\nimport config from \"@mongez/config\";\nimport { environment } from \"../../utils\";\nimport { requestContext as requestContextInstance } from \"../context/request-context\";\nimport { applyCspHeader } from \"../csp\";\nimport { runDefaultCsrfGuard } from \"../csrf-default-guard\";\nimport { HttpError } from \"../errors\";\nimport { type Request } from \"../request\";\nimport { type Response } from \"../response\";\nimport { buildTracingContext, dispatchPhase, isTracingEnabled } from \"../tracing\";\nimport { type ReturnedResponse } from \"./../types\";\n\n// Contexts are now registered in core/context/init-contexts.ts via initializeContexts()\n\n/**\n * Echo `request.id` back as a response header so the FE / proxies / log\n * aggregators can correlate by the same value the server logs against.\n *\n * Reads the header name from `http.requestId.header` (default `X-Request-Id`).\n * Skip when `http.requestId.enabled` is explicitly false.\n */\nfunction stampRequestIdHeader(request: Request, response: Response) {\n const requestIdConfig = config.get(\"http.requestId\", {} as Record<string, any>);\n\n if (requestIdConfig.enabled === false) return;\n\n const headerName = requestIdConfig.header || \"X-Request-Id\";\n\n response.header(headerName, request.id);\n}\n\n/**\n * Create request store and execute middleware + handler\n *\n * Runs all registered contexts together using ContextManager.\n */\nexport function createRequestStore(\n request: Request<any>,\n response: Response,\n): Promise<ReturnedResponse> {\n stampRequestIdHeader(request, response);\n\n // Stamped this early — before any middleware or handler runs — so the\n // header carries the SAME nonce the web layer later reads off\n // `request.nonce` for its `<script>` tags: `request.nonce` caches on first\n // read, and this is deliberately the first read. A no-op when the app\n // hasn't opted into `http.csp` (see `csp.ts`).\n applyCspHeader(request, response);\n\n // Build all context stores using the immutable API\n // Each context defines its own store initialization via buildStore()\n const httpContextStore = contextManager.buildStores({ request, response });\n\n // Run all contexts together!\n return contextManager.runAll(httpContextStore, async () => {\n try {\n // Default CSRF-Origin guard (SECURITY, card 8a752ab2) — the earliest\n // seam common to every request, BEFORE the route's own middleware\n // (`request.runMiddleware()` below) and before any app handler, so it\n // applies even to a route that never attaches\n // `authMiddleware(\"cookie:*\")`. See `../csrf-default-guard.ts` for the\n // full scope rule and `{ csrf: false }` exemption.\n const csrfGuardResponse = await runDefaultCsrfGuard(request, response, t, (reason) => {\n log.error(\n \"http\",\n \"csrf-default-guard\",\n new Error(`Default CSRF Origin guard refused request: ${reason}`),\n );\n });\n\n if (csrfGuardResponse) {\n return csrfGuardResponse as ReturnedResponse;\n }\n\n // Run middleware chain\n const result = await request.runMiddleware();\n\n if (result) {\n return result as ReturnedResponse;\n }\n\n // Execute route handler\n request.trigger(\"executingAction\", request.route);\n\n const handler = request.getHandler();\n\n request.log(\"Executing Handler\", \"info\");\n\n const tracingEnabled = isTracingEnabled();\n const handlerStartedAt = tracingEnabled ? performance.now() : 0;\n\n const output = await handler({ request, response });\n\n if (tracingEnabled) {\n dispatchPhase(buildTracingContext(request), {\n name: \"handler\",\n durationMs: performance.now() - handlerStartedAt,\n });\n }\n\n request.log(\"Handler Executed Successfully\", \"success\");\n\n request.trigger(\"executedAction\", request.route);\n\n return output as ReturnedResponse;\n } catch (error) {\n request.log(error, \"error\");\n return handleRequestError(error, response);\n }\n });\n}\n\n/**\n * Handle request errors\n * @internal\n */\nfunction handleRequestError(error: unknown, response: Response): ReturnedResponse {\n // Availability floor, not a cache-policy nit: `handleRequestError` is the\n // single funnel every unhandled error in every Warlock app passes through\n // (`createRequestStore`'s catch above), and none of the branches below set\n // a `Cache-Control` header. Without this, an error response — a 500 as\n // much as a 401/403/404 carrying per-request/-user state — can be stored\n // by a shared cache or CDN and replayed to other requests/users long after\n // the condition that caused it is gone: a cached 500 becomes an outage\n // that outlives its cause; a cached 401/403 becomes a leak across users.\n // Set once, here, before any branch runs — not per branch — because the\n // branches below do not partition by status. `ResourceNotFoundError`,\n // `UnAuthorizedError`, `ForbiddenError`, `BadRequestError` and `ServerError`\n // all extend `HttpError`, so the `HttpError` branch answers for every one of\n // them, and a raw `HttpError` can carry any caller-chosen status, 4xx or 5xx.\n // Gating the floor on the eventual status would mean re-deriving that status\n // per branch — one rule meeting one form while others reach the same output.\n // Applying it once, unconditionally, is both simpler and safer.\n response.header(\"Cache-Control\", \"private, no-store\");\n\n if (error instanceof HttpError) {\n const payload: GenericObject = {\n error: error.message,\n };\n if (error.payload) {\n payload.payload = error.payload;\n }\n\n if (environment() === \"development\") {\n payload.stack = error.stack;\n }\n\n return response.setStatusCode(error.status).send(payload);\n }\n\n if (error instanceof DatabaseWriterValidationError) {\n return response.badRequest({\n errors: error.errors,\n });\n }\n\n // Last resort: the error matched none of the known shapes above, so the\n // client gets a deliberately opaque message. Without this line the error\n // itself is discarded here — no stack, no message, nothing in any log — and\n // an unrecognised failure becomes indistinguishable from a working server\n // returning 500. Never swallow the only copy of an error (`65e476ee`).\n console.error(\"[warlock] unhandled request error:\", error);\n\n return response.serverError({\n error: \"Internal server error.\",\n });\n}\n\n/**\n * Translate a keyword (uses request context for locale)\n */\nexport function t(keyword: string, placeholders?: any) {\n return (\n requestContextInstance.getRequest()?.trans(keyword, placeholders) ||\n trans(keyword, placeholders)\n );\n}\n\n// `fromRequest` was removed in v5. It cached computed values as dynamic\n// properties on the Request instance, which only compiled because of the\n// `[key: string]: any` index signature that v5 deletes (eed20184). Use\n// `requestMemo(key, fn)` from `../context/request-memo` instead — same\n// per-request lifetime, single-flight, and it never touches the Request object.\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,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;CAOtC,eAAe,SAAS,QAAQ;CAIhC,MAAM,mBAAmB,eAAe,YAAY;EAAE;EAAS;CAAS,CAAC;CAGzE,OAAO,eAAe,OAAO,kBAAkB,YAAY;EACzD,IAAI;GAOF,MAAM,oBAAoB,MAAM,oBAAoB,SAAS,UAAU,IAAI,WAAW;IACpF,IAAI,MACF,QACA,sCACA,IAAI,MAAM,8CAA8C,QAAQ,CAClE;GACF,CAAC;GAED,IAAI,mBACF,OAAO;GAIT,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,iBAAiB,iBAAiB;GACxC,MAAM,mBAAmB,iBAAiB,YAAY,IAAI,IAAI;GAE9D,MAAM,SAAS,MAAM,QAAQ;IAAE;IAAS;GAAS,CAAC;GAElD,IAAI,gBACF,cAAc,oBAAoB,OAAO,GAAG;IAC1C,MAAM;IACN,YAAY,YAAY,IAAI,IAAI;GAClC,CAAC;GAGH,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;CAiBhF,SAAS,OAAO,iBAAiB,mBAAmB;CAEpD,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,+BACnB,OAAO,SAAS,WAAW,EACzB,QAAQ,MAAM,OAChB,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"}
1
+ {"version":3,"file":"inject-request-context.mjs","names":["requestContextInstance"],"sources":["../../../../../../../../core/src/http/middleware/inject-request-context.ts"],"sourcesContent":["/**\n * Request Context Middleware\n *\n * Creates a unified context for each request using the ContextManager.\n * All framework contexts (request, storage, database) are available throughout the request lifecycle.\n */\nimport { trans } from \"@mongez/localization\";\nimport { type GenericObject } from \"@mongez/reinforcements\";\nimport { DatabaseWriterValidationError } from \"@warlock.js/cascade\";\nimport { contextManager } from \"@warlock.js/context\";\nimport { log } from \"@warlock.js/logger\";\nimport config from \"@mongez/config\";\nimport { environment } from \"../../utils\";\nimport { requestContext as requestContextInstance } from \"../context/request-context\";\nimport { applyCspHeader } from \"../csp\";\nimport { runDefaultCsrfGuard } from \"../csrf-default-guard\";\nimport { HttpError } from \"../errors\";\nimport { type Request } from \"../request\";\nimport { type Response } from \"../response\";\nimport { buildTracingContext, dispatchPhase, isTracingEnabled } from \"../tracing\";\nimport { type ReturnedResponse } from \"./../types\";\n\n// Contexts are now registered in core/context/init-contexts.ts via initializeContexts()\n\n/**\n * Echo `request.id` back as a response header so the FE / proxies / log\n * aggregators can correlate by the same value the server logs against.\n *\n * Reads the header name from `http.requestId.header` (default `X-Request-Id`).\n * Skip when `http.requestId.enabled` is explicitly false.\n */\nfunction stampRequestIdHeader(request: Request, response: Response) {\n const requestIdConfig = config.get(\"http.requestId\", {} as Record<string, any>);\n\n if (requestIdConfig.enabled === false) return;\n\n const headerName = requestIdConfig.header || \"X-Request-Id\";\n\n response.header(headerName, request.id);\n}\n\n/**\n * Create request store and execute middleware + handler\n *\n * Runs all registered contexts together using ContextManager.\n */\nexport function createRequestStore(\n request: Request<any>,\n response: Response,\n): Promise<ReturnedResponse> {\n stampRequestIdHeader(request, response);\n\n // Stamped this early — before any middleware or handler runs — so the\n // header carries the SAME nonce the web layer later reads off\n // `request.nonce` for its `<script>` tags: `request.nonce` caches on first\n // read, and this is deliberately the first read. A no-op when the app\n // hasn't opted into `http.csp` (see `csp.ts`).\n applyCspHeader(request, response);\n\n // Build all context stores using the immutable API\n // Each context defines its own store initialization via buildStore()\n const httpContextStore = contextManager.buildStores({ request, response });\n\n // Run all contexts together!\n return contextManager.runAll(httpContextStore, async () => {\n try {\n // Default CSRF-Origin guard (SECURITY, card 8a752ab2) — the earliest\n // seam common to every request, BEFORE the route's own middleware\n // (`request.runMiddleware()` below) and before any app handler, so it\n // applies even to a route that never attaches\n // `authMiddleware(\"cookie:*\")`. See `../csrf-default-guard.ts` for the\n // full scope rule and `{ csrf: false }` exemption.\n const csrfGuardResponse = await runDefaultCsrfGuard(request, response, t, (reason) => {\n log.error(\n \"http\",\n \"csrf-default-guard\",\n new Error(`Default CSRF Origin guard refused request: ${reason}`),\n );\n });\n\n if (csrfGuardResponse) {\n return csrfGuardResponse as ReturnedResponse;\n }\n\n // Run middleware chain\n const result = await request.runMiddleware();\n\n if (result) {\n return result as ReturnedResponse;\n }\n\n // Execute route handler\n request.trigger(\"executingAction\", request.route);\n\n const handler = request.getHandler();\n\n request.log(\"Executing Handler\", \"info\");\n\n const tracingEnabled = isTracingEnabled();\n const handlerStartedAt = tracingEnabled ? performance.now() : 0;\n\n const output = await handler({ request, response });\n\n if (tracingEnabled) {\n dispatchPhase(buildTracingContext(request), {\n name: \"handler\",\n durationMs: performance.now() - handlerStartedAt,\n });\n }\n\n request.log(\"Handler Executed Successfully\", \"success\");\n\n request.trigger(\"executedAction\", request.route);\n\n return output as ReturnedResponse;\n } catch (error) {\n request.log(error, \"error\");\n return handleRequestError(error, response, request);\n }\n });\n}\n\n/**\n * Unconditional floor for an unhandled request error (canon 8d3c13a8): route it\n * through the logger, and when no logger channel is configured (nothing would\n * record it) fall back to `console.error` so the only copy is never lost.\n * @internal\n */\nfunction reportUnhandledRequestError(error: unknown, request: Request<any>) {\n log.error(\"http\", \"unhandled-request-error\", error, { requestId: request.id });\n\n if (log.channels.length === 0) {\n console.error(`[http] unhandled-request-error (requestId: ${request.id})`, error);\n }\n}\n\n/**\n * Handle request errors\n * @internal\n */\nfunction handleRequestError(\n error: unknown,\n response: Response,\n request: Request<any>,\n): ReturnedResponse {\n // Availability floor, not a cache-policy nit: `handleRequestError` is the\n // single funnel every unhandled error in every Warlock app passes through\n // (`createRequestStore`'s catch above), and none of the branches below set\n // a `Cache-Control` header. Without this, an error response — a 500 as\n // much as a 401/403/404 carrying per-request/-user state — can be stored\n // by a shared cache or CDN and replayed to other requests/users long after\n // the condition that caused it is gone: a cached 500 becomes an outage\n // that outlives its cause; a cached 401/403 becomes a leak across users.\n // Set once, here, before any branch runs — not per branch — because the\n // branches below do not partition by status. `ResourceNotFoundError`,\n // `UnAuthorizedError`, `ForbiddenError`, `BadRequestError` and `ServerError`\n // all extend `HttpError`, so the `HttpError` branch answers for every one of\n // them, and a raw `HttpError` can carry any caller-chosen status, 4xx or 5xx.\n // Gating the floor on the eventual status would mean re-deriving that status\n // per branch — one rule meeting one form while others reach the same output.\n // Applying it once, unconditionally, is both simpler and safer.\n response.header(\"Cache-Control\", \"private, no-store\");\n\n if (error instanceof HttpError) {\n // 5xx HttpErrors (e.g. `ServerError`) are server faults: send them to the\n // logger channels too, tagged with the request id.\n if (error.status >= 500) {\n log.error(\"http\", \"request-error\", error, { requestId: request.id });\n }\n\n const payload: GenericObject = {\n error: error.message,\n };\n if (error.payload) {\n payload.payload = error.payload;\n }\n\n if (environment() === \"development\") {\n payload.stack = error.stack;\n }\n\n return response.setStatusCode(error.status).send(payload);\n }\n\n if (error instanceof DatabaseWriterValidationError) {\n return response.badRequest({\n errors: error.errors,\n });\n }\n\n // Last resort: the error matched none of the known shapes above, so the\n // client gets a deliberately opaque message. Without this line the error\n // itself is discarded here — no stack, no message, nothing in any log — and\n // an unrecognised failure becomes indistinguishable from a working server\n // returning 500. Never swallow the only copy of an error (`65e476ee`).\n reportUnhandledRequestError(error, request);\n\n return response.serverError({\n error: \"Internal server error.\",\n });\n}\n\n/**\n * Translate a keyword (uses request context for locale)\n */\nexport function t(keyword: string, placeholders?: any) {\n return (\n requestContextInstance.getRequest()?.trans(keyword, placeholders) ||\n trans(keyword, placeholders)\n );\n}\n\n// `fromRequest` was removed in v5. It cached computed values as dynamic\n// properties on the Request instance, which only compiled because of the\n// `[key: string]: any` index signature that v5 deletes (eed20184). Use\n// `requestMemo(key, fn)` from `../context/request-memo` instead — same\n// per-request lifetime, single-flight, and it never touches the Request object.\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,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;CAOtC,eAAe,SAAS,QAAQ;CAIhC,MAAM,mBAAmB,eAAe,YAAY;EAAE;EAAS;CAAS,CAAC;CAGzE,OAAO,eAAe,OAAO,kBAAkB,YAAY;EACzD,IAAI;GAOF,MAAM,oBAAoB,MAAM,oBAAoB,SAAS,UAAU,IAAI,WAAW;IACpF,IAAI,MACF,QACA,sCACA,IAAI,MAAM,8CAA8C,QAAQ,CAClE;GACF,CAAC;GAED,IAAI,mBACF,OAAO;GAIT,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,iBAAiB,iBAAiB;GACxC,MAAM,mBAAmB,iBAAiB,YAAY,IAAI,IAAI;GAE9D,MAAM,SAAS,MAAM,QAAQ;IAAE;IAAS;GAAS,CAAC;GAElD,IAAI,gBACF,cAAc,oBAAoB,OAAO,GAAG;IAC1C,MAAM;IACN,YAAY,YAAY,IAAI,IAAI;GAClC,CAAC;GAGH,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,UAAU,OAAO;EACpD;CACF,CAAC;AACH;;;;;;;AAQA,SAAS,4BAA4B,OAAgB,SAAuB;CAC1E,IAAI,MAAM,QAAQ,2BAA2B,OAAO,EAAE,WAAW,QAAQ,GAAG,CAAC;CAE7E,IAAI,IAAI,SAAS,WAAW,GAC1B,QAAQ,MAAM,8CAA8C,QAAQ,GAAG,IAAI,KAAK;AAEpF;;;;;AAMA,SAAS,mBACP,OACA,UACA,SACkB;CAiBlB,SAAS,OAAO,iBAAiB,mBAAmB;CAEpD,IAAI,iBAAiB,WAAW;EAG9B,IAAI,MAAM,UAAU,KAClB,IAAI,MAAM,QAAQ,iBAAiB,OAAO,EAAE,WAAW,QAAQ,GAAG,CAAC;EAGrE,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,+BACnB,OAAO,SAAS,WAAW,EACzB,QAAQ,MAAM,OAChB,CAAC;CAQH,4BAA4B,OAAO,OAAO;CAE1C,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"}
@@ -1,5 +1,5 @@
1
1
  //#region ../core/src/http/middleware/utils/cidr-match.d.ts
2
- declare function ipMatches(ip: string, pattern: string): boolean;
2
+ declare function ipMatches(rawIp: string, rawPattern: string): boolean;
3
3
  /**
4
4
  * Returns true if `ip` matches any pattern in the list.
5
5
  *
@@ -1 +1 @@
1
- {"version":3,"file":"cidr-match.d.mts","names":[],"sources":["../../../../../../../../../core/src/http/middleware/utils/cidr-match.ts"],"mappings":";iBAkCgB,SAAA,CAAU,EAAA,UAAY,OAAe;AAArD;;;;AAAqD;AA+CrD;AA/CA,iBA+CgB,QAAA,CAAS,EAAA,UAAY,QAAkB"}
1
+ {"version":3,"file":"cidr-match.d.mts","names":[],"sources":["../../../../../../../../../core/src/http/middleware/utils/cidr-match.ts"],"mappings":";iBA2CgB,SAAA,CAAU,KAAA,UAAe,UAAkB;AAA3D;;;;AAA2D;AAkD3D;AAlDA,iBAkDgB,QAAA,CAAS,EAAA,UAAY,QAAkB"}
@@ -22,8 +22,17 @@ function ipv4ToInt(ip) {
22
22
  }
23
23
  return result >>> 0;
24
24
  }
25
- function ipMatches(ip, pattern) {
26
- if (!ip || !pattern) return false;
25
+ const IPV4_MAPPED_PREFIX = /^::ffff:(?=\d+\.\d+\.\d+\.\d+$)/i;
26
+ /**
27
+ * Normalize IPv4-mapped IPv6 (`::ffff:1.2.3.4`) to plain IPv4.
28
+ */
29
+ function normalizeIp(value) {
30
+ return value.replace(IPV4_MAPPED_PREFIX, "");
31
+ }
32
+ function ipMatches(rawIp, rawPattern) {
33
+ if (!rawIp || !rawPattern) return false;
34
+ const ip = normalizeIp(rawIp);
35
+ const pattern = normalizeIp(rawPattern);
27
36
  if (pattern === ip) return true;
28
37
  if (!pattern.includes("/")) return false;
29
38
  const [base, bitsRaw] = pattern.split("/");
@@ -1 +1 @@
1
- {"version":3,"file":"cidr-match.mjs","names":[],"sources":["../../../../../../../../../core/src/http/middleware/utils/cidr-match.ts"],"sourcesContent":["/**\n * Match an IPv4 address against an exact IP or a CIDR block.\n *\n * IPv6 patterns are matched as exact-string only — full IPv6 CIDR support is\n * out of scope; ACLs in production almost always use IPv4 ranges. Add IPv6 CIDR\n * later if a real use-case appears.\n *\n * @example\n * ipMatches(\"10.0.0.5\", \"10.0.0.0/8\"); // true\n * ipMatches(\"10.0.0.5\", \"10.0.0.5\"); // true\n * ipMatches(\"192.168.1.1\", \"10.0.0.0/8\"); // false\n */\nfunction ipv4ToInt(ip: string): number | null {\n const parts = ip.split(\".\");\n\n if (parts.length !== 4) {\n return null;\n }\n\n let result = 0;\n\n for (const part of parts) {\n const num = Number(part);\n\n if (!Number.isInteger(num) || num < 0 || num > 255) {\n return null;\n }\n\n result = (result << 8) + num;\n }\n\n return result >>> 0;\n}\n\nexport function ipMatches(ip: string, pattern: string): boolean {\n if (!ip || !pattern) {\n return false;\n }\n\n if (pattern === ip) {\n return true;\n }\n\n if (!pattern.includes(\"/\")) {\n return false;\n }\n\n const [base, bitsRaw] = pattern.split(\"/\");\n\n if (base === undefined || bitsRaw === undefined) {\n return false;\n }\n\n const bits = Number(bitsRaw);\n\n if (!Number.isInteger(bits) || bits < 0 || bits > 32) {\n return false;\n }\n\n const ipInt = ipv4ToInt(ip);\n const baseInt = ipv4ToInt(base);\n\n if (ipInt === null || baseInt === null) {\n return false;\n }\n\n if (bits === 0) {\n return true;\n }\n\n const mask = (~0 << (32 - bits)) >>> 0;\n\n return (ipInt & mask) === (baseInt & mask);\n}\n\n/**\n * Returns true if `ip` matches any pattern in the list.\n *\n * @example\n * anyMatch(\"10.0.0.5\", [\"192.168.0.0/16\", \"10.0.0.0/8\"]); // true\n */\nexport function anyMatch(ip: string, patterns: string[]): boolean {\n return patterns.some((pattern) => ipMatches(ip, pattern));\n}\n"],"mappings":";;;;;;;;;;;;;AAYA,SAAS,UAAU,IAA2B;CAC5C,MAAM,QAAQ,GAAG,MAAM,GAAG;CAE1B,IAAI,MAAM,WAAW,GACnB,OAAO;CAGT,IAAI,SAAS;CAEb,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,MAAM,OAAO,IAAI;EAEvB,IAAI,CAAC,OAAO,UAAU,GAAG,KAAK,MAAM,KAAK,MAAM,KAC7C,OAAO;EAGT,UAAU,UAAU,KAAK;CAC3B;CAEA,OAAO,WAAW;AACpB;AAEA,SAAgB,UAAU,IAAY,SAA0B;CAC9D,IAAI,CAAC,MAAM,CAAC,SACV,OAAO;CAGT,IAAI,YAAY,IACd,OAAO;CAGT,IAAI,CAAC,QAAQ,SAAS,GAAG,GACvB,OAAO;CAGT,MAAM,CAAC,MAAM,WAAW,QAAQ,MAAM,GAAG;CAEzC,IAAI,SAAS,UAAa,YAAY,QACpC,OAAO;CAGT,MAAM,OAAO,OAAO,OAAO;CAE3B,IAAI,CAAC,OAAO,UAAU,IAAI,KAAK,OAAO,KAAK,OAAO,IAChD,OAAO;CAGT,MAAM,QAAQ,UAAU,EAAE;CAC1B,MAAM,UAAU,UAAU,IAAI;CAE9B,IAAI,UAAU,QAAQ,YAAY,MAChC,OAAO;CAGT,IAAI,SAAS,GACX,OAAO;CAGT,MAAM,OAAQ,MAAO,KAAK,SAAW;CAErC,QAAQ,QAAQ,WAAW,UAAU;AACvC;;;;;;;AAQA,SAAgB,SAAS,IAAY,UAA6B;CAChE,OAAO,SAAS,MAAM,YAAY,UAAU,IAAI,OAAO,CAAC;AAC1D"}
1
+ {"version":3,"file":"cidr-match.mjs","names":[],"sources":["../../../../../../../../../core/src/http/middleware/utils/cidr-match.ts"],"sourcesContent":["/**\n * Match an IPv4 address against an exact IP or a CIDR block.\n *\n * IPv6 patterns are matched as exact-string only — full IPv6 CIDR support is\n * out of scope; ACLs in production almost always use IPv4 ranges. Add IPv6 CIDR\n * later if a real use-case appears.\n *\n * @example\n * ipMatches(\"10.0.0.5\", \"10.0.0.0/8\"); // true\n * ipMatches(\"10.0.0.5\", \"10.0.0.5\"); // true\n * ipMatches(\"192.168.1.1\", \"10.0.0.0/8\"); // false\n */\nfunction ipv4ToInt(ip: string): number | null {\n const parts = ip.split(\".\");\n\n if (parts.length !== 4) {\n return null;\n }\n\n let result = 0;\n\n for (const part of parts) {\n const num = Number(part);\n\n if (!Number.isInteger(num) || num < 0 || num > 255) {\n return null;\n }\n\n result = (result << 8) + num;\n }\n\n return result >>> 0;\n}\n\nconst IPV4_MAPPED_PREFIX = /^::ffff:(?=\\d+\\.\\d+\\.\\d+\\.\\d+$)/i;\n\n/**\n * Normalize IPv4-mapped IPv6 (`::ffff:1.2.3.4`) to plain IPv4.\n */\nfunction normalizeIp(value: string): string {\n return value.replace(IPV4_MAPPED_PREFIX, \"\");\n}\n\nexport function ipMatches(rawIp: string, rawPattern: string): boolean {\n if (!rawIp || !rawPattern) {\n return false;\n }\n\n const ip = normalizeIp(rawIp);\n const pattern = normalizeIp(rawPattern);\n\n if (pattern === ip) {\n return true;\n }\n\n if (!pattern.includes(\"/\")) {\n return false;\n }\n\n const [base, bitsRaw] = pattern.split(\"/\");\n\n if (base === undefined || bitsRaw === undefined) {\n return false;\n }\n\n const bits = Number(bitsRaw);\n\n if (!Number.isInteger(bits) || bits < 0 || bits > 32) {\n return false;\n }\n\n const ipInt = ipv4ToInt(ip);\n const baseInt = ipv4ToInt(base);\n\n if (ipInt === null || baseInt === null) {\n return false;\n }\n\n if (bits === 0) {\n return true;\n }\n\n const mask = (~0 << (32 - bits)) >>> 0;\n\n return (ipInt & mask) === (baseInt & mask);\n}\n\n/**\n * Returns true if `ip` matches any pattern in the list.\n *\n * @example\n * anyMatch(\"10.0.0.5\", [\"192.168.0.0/16\", \"10.0.0.0/8\"]); // true\n */\nexport function anyMatch(ip: string, patterns: string[]): boolean {\n return patterns.some((pattern) => ipMatches(ip, pattern));\n}\n"],"mappings":";;;;;;;;;;;;;AAYA,SAAS,UAAU,IAA2B;CAC5C,MAAM,QAAQ,GAAG,MAAM,GAAG;CAE1B,IAAI,MAAM,WAAW,GACnB,OAAO;CAGT,IAAI,SAAS;CAEb,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,MAAM,OAAO,IAAI;EAEvB,IAAI,CAAC,OAAO,UAAU,GAAG,KAAK,MAAM,KAAK,MAAM,KAC7C,OAAO;EAGT,UAAU,UAAU,KAAK;CAC3B;CAEA,OAAO,WAAW;AACpB;AAEA,MAAM,qBAAqB;;;;AAK3B,SAAS,YAAY,OAAuB;CAC1C,OAAO,MAAM,QAAQ,oBAAoB,EAAE;AAC7C;AAEA,SAAgB,UAAU,OAAe,YAA6B;CACpE,IAAI,CAAC,SAAS,CAAC,YACb,OAAO;CAGT,MAAM,KAAK,YAAY,KAAK;CAC5B,MAAM,UAAU,YAAY,UAAU;CAEtC,IAAI,YAAY,IACd,OAAO;CAGT,IAAI,CAAC,QAAQ,SAAS,GAAG,GACvB,OAAO;CAGT,MAAM,CAAC,MAAM,WAAW,QAAQ,MAAM,GAAG;CAEzC,IAAI,SAAS,UAAa,YAAY,QACpC,OAAO;CAGT,MAAM,OAAO,OAAO,OAAO;CAE3B,IAAI,CAAC,OAAO,UAAU,IAAI,KAAK,OAAO,KAAK,OAAO,IAChD,OAAO;CAGT,MAAM,QAAQ,UAAU,EAAE;CAC1B,MAAM,UAAU,UAAU,IAAI;CAE9B,IAAI,UAAU,QAAQ,YAAY,MAChC,OAAO;CAGT,IAAI,SAAS,GACX,OAAO;CAGT,MAAM,OAAQ,MAAO,KAAK,SAAW;CAErC,QAAQ,QAAQ,WAAW,UAAU;AACvC;;;;;;;AAQA,SAAgB,SAAS,IAAY,UAA6B;CAChE,OAAO,SAAS,MAAM,YAAY,UAAU,IAAI,OAAO,CAAC;AAC1D"}
@@ -61,7 +61,7 @@ function readUserId(user) {
61
61
  function buildIdempotencyCacheKey(request, idempotencyKey) {
62
62
  const userType = request.decodedAccessToken?.userType || "anonymous";
63
63
  const locals = request.locals;
64
- return `idem:${userType}:${readUserId(locals.user) || request.detectIp() || "unknown"}:${idempotencyKey}`;
64
+ return `idem:${userType}:${readUserId(locals.user) || request.detectIp() || "unknown"}:${(request.route?.method || request.method || "").toUpperCase()}:${request.route?.path || ""}:${idempotencyKey}`;
65
65
  }
66
66
 
67
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\n * `request.locals.user` (written by `@warlock.js/auth`) and\n * `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.locals.user` without assuming any app's\n * `RequestUser` augmentation (declared by `@warlock.js/auth`, which core\n * cannot import) declares it, and without assuming `@warlock.js/auth` is\n * even installed — hence the untyped `unknown` read here rather than a\n * typed `RequestLocals[\"user\"]` access.\n *\n * A local, narrow runtime read survives any augmentation shape — no\n * `as any`.\n */\nfunction readUserId(user: unknown): 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 locals = request.locals as Record<string, unknown>;\n const userId = readUserId(locals.user) || 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;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,SAAS,WAAW,MAA4C;CAC9D,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;CACzF,MAAM,WAAW,QAAQ,oBAAoB,YAAY;CACzD,MAAM,SAAS,QAAQ;CAGvB,OAAO,QAAQ,SAAS,GAFT,WAAW,OAAO,IAAI,KAAK,QAAQ,SAAS,KAAK,UAE9B,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\";\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\n * `request.locals.user` (written by `@warlock.js/auth`) and\n * `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.locals.user` without assuming any app's\n * `RequestUser` augmentation (declared by `@warlock.js/auth`, which core\n * cannot import) declares it, and without assuming `@warlock.js/auth` is\n * even installed — hence the untyped `unknown` read here rather than a\n * typed `RequestLocals[\"user\"]` access.\n *\n * A local, narrow runtime read survives any augmentation shape — no\n * `as any`.\n */\nfunction readUserId(user: unknown): 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 locals = request.locals as Record<string, unknown>;\n const userId = readUserId(locals.user) || request.detectIp() || \"unknown\";\n\n // scope by endpoint so one key reused on another route/method never replays\n // that other endpoint's response\n const method = (request.route?.method || request.method || \"\").toUpperCase();\n const routePath = request.route?.path || \"\";\n\n return `idem:${userType}:${userId}:${method}:${routePath}:${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;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,SAAS,WAAW,MAA4C;CAC9D,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;CACzF,MAAM,WAAW,QAAQ,oBAAoB,YAAY;CACzD,MAAM,SAAS,QAAQ;CAQvB,OAAO,QAAQ,SAAS,GAPT,WAAW,OAAO,IAAI,KAAK,QAAQ,SAAS,KAAK,UAO9B,IAHlB,QAAQ,OAAO,UAAU,QAAQ,UAAU,GAAE,CAAE,YAGrB,EAAE,GAF1B,QAAQ,OAAO,QAAQ,GAEgB,GAAG;AAC9D"}
@@ -1 +1 @@
1
- {"version":3,"file":"plugins.d.mts","names":[],"sources":["../../../../../../../core/src/http/plugins.ts"],"mappings":";;;iBASsB,mBAAA,CAAoB,MAAA,EAAQ,eAAA,GAAe,OAAA"}
1
+ {"version":3,"file":"plugins.d.mts","names":[],"sources":["../../../../../../../core/src/http/plugins.ts"],"mappings":";;;iBAWsB,mBAAA,CAAoB,MAAA,EAAQ,eAAA,GAAe,OAAA"}
@@ -1,19 +1,28 @@
1
1
  import { rootPath } from "../utils/paths.mjs";
2
2
  import "../utils/index.mjs";
3
3
  import { buildCorsOptions } from "./build-cors-options.mjs";
4
+ import { router } from "../router/router.mjs";
5
+ import "../router/index.mjs";
4
6
  import { buildRateLimitOptions } from "./build-rate-limit-options.mjs";
5
7
  import { parseUrlencodedBody } from "./parse-urlencoded-body.mjs";
6
8
  import config from "@mongez/config";
9
+ import { log } from "@warlock.js/logger";
7
10
  import fastifyMultipart from "@fastify/multipart";
8
11
 
9
12
  //#region ../core/src/http/plugins.ts
10
13
  async function registerHttpPlugins(server) {
11
14
  const rateLimitOptions = buildRateLimitOptions(config.get("http.rateLimit"));
12
15
  if (rateLimitOptions) server.register(import("@fastify/rate-limit"), rateLimitOptions);
16
+ else if (router.list().some((route) => route.rateLimit)) log.warn("http", "rateLimit", "Routes declare `rateLimit`, but `http.rateLimit.enabled` is false so the rate-limit plugin is not registered: route rate limits (and their errorMessage) are inactive.");
13
17
  server.register(import("@fastify/cors"), buildCorsOptions());
14
18
  server.register(fastifyMultipart, {
15
19
  attachFieldsToBody: true,
16
- limits: { fileSize: config.get("http.fileUploadLimit", 10 * 1024 * 1024) }
20
+ limits: {
21
+ fileSize: config.get("http.fileUploadLimit", 10 * 1024 * 1024),
22
+ files: config.get("http.multipart.files", 10),
23
+ fields: config.get("http.multipart.fields", 100),
24
+ fieldSize: config.get("http.multipart.fieldSize", 1024 * 1024)
25
+ }
17
26
  });
18
27
  server.addContentTypeParser("application/x-www-form-urlencoded", { parseAs: "string" }, (_request, body, done) => {
19
28
  try {