@classytic/arc 1.1.0 → 2.1.2

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 (322) hide show
  1. package/README.md +247 -794
  2. package/bin/arc.js +91 -52
  3. package/dist/EventTransport-BD2U0BTc.d.mts +100 -0
  4. package/dist/EventTransport-BD2U0BTc.d.mts.map +1 -0
  5. package/dist/HookSystem-BsGV-j2l.mjs +405 -0
  6. package/dist/HookSystem-BsGV-j2l.mjs.map +1 -0
  7. package/dist/ResourceRegistry-DsN4KJjV.mjs +250 -0
  8. package/dist/ResourceRegistry-DsN4KJjV.mjs.map +1 -0
  9. package/dist/adapters/index.d.mts +5 -0
  10. package/dist/adapters/index.mjs +3 -0
  11. package/dist/audit/index.d.mts +82 -0
  12. package/dist/audit/index.d.mts.map +1 -0
  13. package/dist/audit/index.mjs +276 -0
  14. package/dist/audit/index.mjs.map +1 -0
  15. package/dist/audit/mongodb.d.mts +5 -0
  16. package/dist/audit/mongodb.mjs +3 -0
  17. package/dist/audited-C3T5DTUx.mjs +141 -0
  18. package/dist/audited-C3T5DTUx.mjs.map +1 -0
  19. package/dist/auth/index.d.mts +189 -0
  20. package/dist/auth/index.d.mts.map +1 -0
  21. package/dist/auth/index.mjs +1102 -0
  22. package/dist/auth/index.mjs.map +1 -0
  23. package/dist/auth/redis-session.d.mts +44 -0
  24. package/dist/auth/redis-session.d.mts.map +1 -0
  25. package/dist/auth/redis-session.mjs +76 -0
  26. package/dist/auth/redis-session.mjs.map +1 -0
  27. package/dist/betterAuthOpenApi-BrHKeSAx.mjs +250 -0
  28. package/dist/betterAuthOpenApi-BrHKeSAx.mjs.map +1 -0
  29. package/dist/cache/index.d.mts +146 -0
  30. package/dist/cache/index.d.mts.map +1 -0
  31. package/dist/cache/index.mjs +92 -0
  32. package/dist/cache/index.mjs.map +1 -0
  33. package/dist/caching-Bl28lYsR.mjs +94 -0
  34. package/dist/caching-Bl28lYsR.mjs.map +1 -0
  35. package/dist/chunk-C7Uep-_p.mjs +20 -0
  36. package/dist/circuitBreaker-DeY4FCjs.mjs +1097 -0
  37. package/dist/circuitBreaker-DeY4FCjs.mjs.map +1 -0
  38. package/dist/cli/commands/describe.d.mts +19 -0
  39. package/dist/cli/commands/describe.d.mts.map +1 -0
  40. package/dist/cli/commands/describe.mjs +239 -0
  41. package/dist/cli/commands/describe.mjs.map +1 -0
  42. package/dist/cli/commands/docs.d.mts +14 -0
  43. package/dist/cli/commands/docs.d.mts.map +1 -0
  44. package/dist/cli/commands/docs.mjs +53 -0
  45. package/dist/cli/commands/docs.mjs.map +1 -0
  46. package/dist/cli/commands/{generate.d.ts → generate.d.mts} +3 -1
  47. package/dist/cli/commands/generate.d.mts.map +1 -0
  48. package/dist/cli/commands/generate.mjs +358 -0
  49. package/dist/cli/commands/generate.mjs.map +1 -0
  50. package/dist/cli/commands/{init.d.ts → init.d.mts} +12 -8
  51. package/dist/cli/commands/init.d.mts.map +1 -0
  52. package/dist/cli/commands/{init.js → init.mjs} +807 -616
  53. package/dist/cli/commands/init.mjs.map +1 -0
  54. package/dist/cli/commands/introspect.d.mts +11 -0
  55. package/dist/cli/commands/introspect.d.mts.map +1 -0
  56. package/dist/cli/commands/introspect.mjs +76 -0
  57. package/dist/cli/commands/introspect.mjs.map +1 -0
  58. package/dist/cli/index.d.mts +17 -0
  59. package/dist/cli/index.d.mts.map +1 -0
  60. package/dist/cli/index.mjs +157 -0
  61. package/dist/cli/index.mjs.map +1 -0
  62. package/dist/constants-DdXFXQtN.mjs +85 -0
  63. package/dist/constants-DdXFXQtN.mjs.map +1 -0
  64. package/dist/core/index.d.mts +5 -0
  65. package/dist/core/index.mjs +4 -0
  66. package/dist/createApp-CUgNqegw.mjs +560 -0
  67. package/dist/createApp-CUgNqegw.mjs.map +1 -0
  68. package/dist/defineResource-k0_BDn8v.mjs +2197 -0
  69. package/dist/defineResource-k0_BDn8v.mjs.map +1 -0
  70. package/dist/discovery/index.d.mts +47 -0
  71. package/dist/discovery/index.d.mts.map +1 -0
  72. package/dist/discovery/index.mjs +110 -0
  73. package/dist/discovery/index.mjs.map +1 -0
  74. package/dist/docs/index.d.mts +163 -0
  75. package/dist/docs/index.d.mts.map +1 -0
  76. package/dist/docs/index.mjs +73 -0
  77. package/dist/docs/index.mjs.map +1 -0
  78. package/dist/elevation-BRy3yFWT.mjs +113 -0
  79. package/dist/elevation-BRy3yFWT.mjs.map +1 -0
  80. package/dist/elevation-B_2dRLVP.d.mts +88 -0
  81. package/dist/elevation-B_2dRLVP.d.mts.map +1 -0
  82. package/dist/errorHandler-BbcgBmIH.d.mts +73 -0
  83. package/dist/errorHandler-BbcgBmIH.d.mts.map +1 -0
  84. package/dist/errorHandler-C1okiriz.mjs +109 -0
  85. package/dist/errorHandler-C1okiriz.mjs.map +1 -0
  86. package/dist/errors-B9bZok84.mjs +212 -0
  87. package/dist/errors-B9bZok84.mjs.map +1 -0
  88. package/dist/errors-ChKiFz62.d.mts +125 -0
  89. package/dist/errors-ChKiFz62.d.mts.map +1 -0
  90. package/dist/eventPlugin-CTrLH3mt.d.mts +125 -0
  91. package/dist/eventPlugin-CTrLH3mt.d.mts.map +1 -0
  92. package/dist/eventPlugin-DGR_B2on.mjs +230 -0
  93. package/dist/eventPlugin-DGR_B2on.mjs.map +1 -0
  94. package/dist/events/index.d.mts +54 -0
  95. package/dist/events/index.d.mts.map +1 -0
  96. package/dist/events/index.mjs +52 -0
  97. package/dist/events/index.mjs.map +1 -0
  98. package/dist/events/transports/redis-stream-entry.d.mts +2 -0
  99. package/dist/events/transports/redis-stream-entry.mjs +178 -0
  100. package/dist/events/transports/redis-stream-entry.mjs.map +1 -0
  101. package/dist/events/transports/redis.d.mts +77 -0
  102. package/dist/events/transports/redis.d.mts.map +1 -0
  103. package/dist/events/transports/redis.mjs +125 -0
  104. package/dist/events/transports/redis.mjs.map +1 -0
  105. package/dist/externalPaths-DlINfKbP.d.mts +51 -0
  106. package/dist/externalPaths-DlINfKbP.d.mts.map +1 -0
  107. package/dist/factory/index.d.mts +64 -0
  108. package/dist/factory/index.d.mts.map +1 -0
  109. package/dist/factory/index.mjs +3 -0
  110. package/dist/fastifyAdapter-BkrGrlFi.d.mts +217 -0
  111. package/dist/fastifyAdapter-BkrGrlFi.d.mts.map +1 -0
  112. package/dist/fields-DyaDVX4J.d.mts +110 -0
  113. package/dist/fields-DyaDVX4J.d.mts.map +1 -0
  114. package/dist/fields-iagOozy0.mjs +115 -0
  115. package/dist/fields-iagOozy0.mjs.map +1 -0
  116. package/dist/hooks/index.d.mts +4 -0
  117. package/dist/hooks/index.mjs +3 -0
  118. package/dist/idempotency/index.d.mts +97 -0
  119. package/dist/idempotency/index.d.mts.map +1 -0
  120. package/dist/idempotency/index.mjs +320 -0
  121. package/dist/idempotency/index.mjs.map +1 -0
  122. package/dist/idempotency/mongodb.d.mts +2 -0
  123. package/dist/idempotency/mongodb.mjs +115 -0
  124. package/dist/idempotency/mongodb.mjs.map +1 -0
  125. package/dist/idempotency/redis.d.mts +2 -0
  126. package/dist/idempotency/redis.mjs +104 -0
  127. package/dist/idempotency/redis.mjs.map +1 -0
  128. package/dist/index.d.mts +261 -0
  129. package/dist/index.d.mts.map +1 -0
  130. package/dist/index.mjs +105 -0
  131. package/dist/index.mjs.map +1 -0
  132. package/dist/integrations/event-gateway.d.mts +47 -0
  133. package/dist/integrations/event-gateway.d.mts.map +1 -0
  134. package/dist/integrations/event-gateway.mjs +44 -0
  135. package/dist/integrations/event-gateway.mjs.map +1 -0
  136. package/dist/integrations/index.d.mts +5 -0
  137. package/dist/integrations/index.mjs +1 -0
  138. package/dist/integrations/jobs.d.mts +104 -0
  139. package/dist/integrations/jobs.d.mts.map +1 -0
  140. package/dist/integrations/jobs.mjs +124 -0
  141. package/dist/integrations/jobs.mjs.map +1 -0
  142. package/dist/integrations/streamline.d.mts +61 -0
  143. package/dist/integrations/streamline.d.mts.map +1 -0
  144. package/dist/integrations/streamline.mjs +126 -0
  145. package/dist/integrations/streamline.mjs.map +1 -0
  146. package/dist/integrations/websocket.d.mts +83 -0
  147. package/dist/integrations/websocket.d.mts.map +1 -0
  148. package/dist/integrations/websocket.mjs +289 -0
  149. package/dist/integrations/websocket.mjs.map +1 -0
  150. package/dist/interface-B01JvPVc.d.mts +78 -0
  151. package/dist/interface-B01JvPVc.d.mts.map +1 -0
  152. package/dist/interface-CZe8IkMf.d.mts +55 -0
  153. package/dist/interface-CZe8IkMf.d.mts.map +1 -0
  154. package/dist/interface-Ch8HU9uM.d.mts +1098 -0
  155. package/dist/interface-Ch8HU9uM.d.mts.map +1 -0
  156. package/dist/introspectionPlugin-rFdO8ZUa.mjs +54 -0
  157. package/dist/introspectionPlugin-rFdO8ZUa.mjs.map +1 -0
  158. package/dist/keys-BqNejWup.mjs +43 -0
  159. package/dist/keys-BqNejWup.mjs.map +1 -0
  160. package/dist/logger-Df2O2WsW.mjs +79 -0
  161. package/dist/logger-Df2O2WsW.mjs.map +1 -0
  162. package/dist/memory-cQgelFOj.mjs +144 -0
  163. package/dist/memory-cQgelFOj.mjs.map +1 -0
  164. package/dist/migrations/index.d.mts +157 -0
  165. package/dist/migrations/index.d.mts.map +1 -0
  166. package/dist/migrations/index.mjs +261 -0
  167. package/dist/migrations/index.mjs.map +1 -0
  168. package/dist/mongodb-BfJVlUJH.mjs +94 -0
  169. package/dist/mongodb-BfJVlUJH.mjs.map +1 -0
  170. package/dist/mongodb-CGzRbfAK.d.mts +119 -0
  171. package/dist/mongodb-CGzRbfAK.d.mts.map +1 -0
  172. package/dist/mongodb-JN-9JA7K.d.mts +72 -0
  173. package/dist/mongodb-JN-9JA7K.d.mts.map +1 -0
  174. package/dist/openapi-G3Cw7XuM.mjs +524 -0
  175. package/dist/openapi-G3Cw7XuM.mjs.map +1 -0
  176. package/dist/org/index.d.mts +69 -0
  177. package/dist/org/index.d.mts.map +1 -0
  178. package/dist/org/index.mjs +514 -0
  179. package/dist/org/index.mjs.map +1 -0
  180. package/dist/org/types.d.mts +83 -0
  181. package/dist/org/types.d.mts.map +1 -0
  182. package/dist/org/types.mjs +1 -0
  183. package/dist/permissions/index.d.mts +279 -0
  184. package/dist/permissions/index.d.mts.map +1 -0
  185. package/dist/permissions/index.mjs +579 -0
  186. package/dist/permissions/index.mjs.map +1 -0
  187. package/dist/plugins/index.d.mts +173 -0
  188. package/dist/plugins/index.d.mts.map +1 -0
  189. package/dist/plugins/index.mjs +523 -0
  190. package/dist/plugins/index.mjs.map +1 -0
  191. package/dist/plugins/response-cache.d.mts +88 -0
  192. package/dist/plugins/response-cache.d.mts.map +1 -0
  193. package/dist/plugins/response-cache.mjs +284 -0
  194. package/dist/plugins/response-cache.mjs.map +1 -0
  195. package/dist/plugins/tracing-entry.d.mts +2 -0
  196. package/dist/plugins/tracing-entry.mjs +186 -0
  197. package/dist/plugins/tracing-entry.mjs.map +1 -0
  198. package/dist/pluralize-CEweyOEm.mjs +87 -0
  199. package/dist/pluralize-CEweyOEm.mjs.map +1 -0
  200. package/dist/policies/{index.d.ts → index.d.mts} +204 -169
  201. package/dist/policies/index.d.mts.map +1 -0
  202. package/dist/policies/index.mjs +322 -0
  203. package/dist/policies/index.mjs.map +1 -0
  204. package/dist/presets/{index.d.ts → index.d.mts} +63 -131
  205. package/dist/presets/index.d.mts.map +1 -0
  206. package/dist/presets/index.mjs +144 -0
  207. package/dist/presets/index.mjs.map +1 -0
  208. package/dist/presets/multiTenant.d.mts +25 -0
  209. package/dist/presets/multiTenant.d.mts.map +1 -0
  210. package/dist/presets/multiTenant.mjs +114 -0
  211. package/dist/presets/multiTenant.mjs.map +1 -0
  212. package/dist/presets-BITljm96.mjs +120 -0
  213. package/dist/presets-BITljm96.mjs.map +1 -0
  214. package/dist/presets-DzSMwlKj.d.mts +58 -0
  215. package/dist/presets-DzSMwlKj.d.mts.map +1 -0
  216. package/dist/prisma-DJbMt3yf.mjs +628 -0
  217. package/dist/prisma-DJbMt3yf.mjs.map +1 -0
  218. package/dist/prisma-Dg9GoVdj.d.mts +275 -0
  219. package/dist/prisma-Dg9GoVdj.d.mts.map +1 -0
  220. package/dist/queryCachePlugin-7THaI5mt.d.mts +72 -0
  221. package/dist/queryCachePlugin-7THaI5mt.d.mts.map +1 -0
  222. package/dist/queryCachePlugin-DMBnp2Q0.mjs +139 -0
  223. package/dist/queryCachePlugin-DMBnp2Q0.mjs.map +1 -0
  224. package/dist/redis-D-JAeLtm.d.mts +50 -0
  225. package/dist/redis-D-JAeLtm.d.mts.map +1 -0
  226. package/dist/redis-stream-Bdh_vUU8.d.mts +104 -0
  227. package/dist/redis-stream-Bdh_vUU8.d.mts.map +1 -0
  228. package/dist/registry/index.d.mts +12 -0
  229. package/dist/registry/index.d.mts.map +1 -0
  230. package/dist/registry/index.mjs +4 -0
  231. package/dist/requestContext-QQD6ROJc.mjs +56 -0
  232. package/dist/requestContext-QQD6ROJc.mjs.map +1 -0
  233. package/dist/schemaConverter-BwrmWroW.mjs +99 -0
  234. package/dist/schemaConverter-BwrmWroW.mjs.map +1 -0
  235. package/dist/schemas/index.d.mts +64 -0
  236. package/dist/schemas/index.d.mts.map +1 -0
  237. package/dist/schemas/index.mjs +83 -0
  238. package/dist/schemas/index.mjs.map +1 -0
  239. package/dist/scope/index.d.mts +22 -0
  240. package/dist/scope/index.d.mts.map +1 -0
  241. package/dist/scope/index.mjs +66 -0
  242. package/dist/scope/index.mjs.map +1 -0
  243. package/dist/sessionManager-jPKLbHE0.d.mts +187 -0
  244. package/dist/sessionManager-jPKLbHE0.d.mts.map +1 -0
  245. package/dist/sse-B3c3_yZp.mjs +124 -0
  246. package/dist/sse-B3c3_yZp.mjs.map +1 -0
  247. package/dist/testing/index.d.mts +908 -0
  248. package/dist/testing/index.d.mts.map +1 -0
  249. package/dist/testing/index.mjs +1977 -0
  250. package/dist/testing/index.mjs.map +1 -0
  251. package/dist/tracing-Cc7vVQPp.d.mts +71 -0
  252. package/dist/tracing-Cc7vVQPp.d.mts.map +1 -0
  253. package/dist/typeGuards-DhMNLuvU.mjs +10 -0
  254. package/dist/typeGuards-DhMNLuvU.mjs.map +1 -0
  255. package/dist/types/index.d.mts +947 -0
  256. package/dist/types/index.d.mts.map +1 -0
  257. package/dist/types/index.mjs +15 -0
  258. package/dist/types/index.mjs.map +1 -0
  259. package/dist/types-Beqn1Un7.mjs +39 -0
  260. package/dist/types-Beqn1Un7.mjs.map +1 -0
  261. package/dist/types-CIgB7UUl.d.mts +446 -0
  262. package/dist/types-CIgB7UUl.d.mts.map +1 -0
  263. package/dist/types-aYB4V7uN.d.mts +87 -0
  264. package/dist/types-aYB4V7uN.d.mts.map +1 -0
  265. package/dist/utils/index.d.mts +748 -0
  266. package/dist/utils/index.d.mts.map +1 -0
  267. package/dist/utils/index.mjs +6 -0
  268. package/package.json +194 -68
  269. package/dist/BaseController-DVAiHxEQ.d.ts +0 -233
  270. package/dist/adapters/index.d.ts +0 -237
  271. package/dist/adapters/index.js +0 -668
  272. package/dist/arcCorePlugin-CsShQdyP.d.ts +0 -273
  273. package/dist/audit/index.d.ts +0 -195
  274. package/dist/audit/index.js +0 -319
  275. package/dist/auth/index.d.ts +0 -47
  276. package/dist/auth/index.js +0 -174
  277. package/dist/cli/commands/docs.d.ts +0 -11
  278. package/dist/cli/commands/docs.js +0 -474
  279. package/dist/cli/commands/generate.js +0 -334
  280. package/dist/cli/commands/introspect.d.ts +0 -8
  281. package/dist/cli/commands/introspect.js +0 -338
  282. package/dist/cli/index.d.ts +0 -4
  283. package/dist/cli/index.js +0 -3269
  284. package/dist/core/index.d.ts +0 -220
  285. package/dist/core/index.js +0 -2786
  286. package/dist/createApp-Ce9wl8W9.d.ts +0 -77
  287. package/dist/docs/index.d.ts +0 -166
  288. package/dist/docs/index.js +0 -658
  289. package/dist/errors-8WIxGS_6.d.ts +0 -122
  290. package/dist/events/index.d.ts +0 -117
  291. package/dist/events/index.js +0 -89
  292. package/dist/factory/index.d.ts +0 -38
  293. package/dist/factory/index.js +0 -1652
  294. package/dist/hooks/index.d.ts +0 -4
  295. package/dist/hooks/index.js +0 -199
  296. package/dist/idempotency/index.d.ts +0 -323
  297. package/dist/idempotency/index.js +0 -500
  298. package/dist/index-B4t03KQ0.d.ts +0 -1366
  299. package/dist/index.d.ts +0 -135
  300. package/dist/index.js +0 -4756
  301. package/dist/migrations/index.d.ts +0 -185
  302. package/dist/migrations/index.js +0 -274
  303. package/dist/org/index.d.ts +0 -129
  304. package/dist/org/index.js +0 -220
  305. package/dist/permissions/index.d.ts +0 -144
  306. package/dist/permissions/index.js +0 -103
  307. package/dist/plugins/index.d.ts +0 -46
  308. package/dist/plugins/index.js +0 -1069
  309. package/dist/policies/index.js +0 -196
  310. package/dist/presets/index.js +0 -384
  311. package/dist/presets/multiTenant.d.ts +0 -39
  312. package/dist/presets/multiTenant.js +0 -112
  313. package/dist/registry/index.d.ts +0 -16
  314. package/dist/registry/index.js +0 -253
  315. package/dist/testing/index.d.ts +0 -618
  316. package/dist/testing/index.js +0 -48020
  317. package/dist/types/index.d.ts +0 -4
  318. package/dist/types/index.js +0 -8
  319. package/dist/types-B99TBmFV.d.ts +0 -76
  320. package/dist/types-BvckRbs2.d.ts +0 -143
  321. package/dist/utils/index.d.ts +0 -679
  322. package/dist/utils/index.js +0 -931
@@ -0,0 +1,2197 @@
1
+ import { a as DEFAULT_SORT, d as MAX_REGEX_LENGTH, h as SYSTEM_FIELDS, n as DEFAULT_ID_FIELD, o as DEFAULT_TENANT_FIELD, r as DEFAULT_LIMIT, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-DdXFXQtN.mjs";
2
+ import { c as isElevated, l as isMember, n as PUBLIC_SCOPE, r as getOrgId } from "./types-Beqn1Un7.mjs";
3
+ import { getUserId } from "./types/index.mjs";
4
+ import { i as resolveEffectiveRoles, n as applyFieldWritePermissions, t as applyFieldReadPermissions } from "./fields-iagOozy0.mjs";
5
+ import { C as ArcQueryParser, u as getDefaultCrudSchemas } from "./circuitBreaker-DeY4FCjs.mjs";
6
+ import { t as buildQueryKey } from "./keys-BqNejWup.mjs";
7
+ import { r as ForbiddenError } from "./errors-B9bZok84.mjs";
8
+ import { t as hasEvents } from "./typeGuards-DhMNLuvU.mjs";
9
+ import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-BwrmWroW.mjs";
10
+ import { t as requestContext } from "./requestContext-QQD6ROJc.mjs";
11
+ import { applyPresets, getAvailablePresets } from "./presets/index.mjs";
12
+
13
+ //#region src/core/AccessControl.ts
14
+ var AccessControl = class AccessControl {
15
+ tenantField;
16
+ idField;
17
+ _adapterMatchesFilter;
18
+ /** Patterns that indicate dangerous regex (nested quantifiers, excessive backtracking).
19
+ * Uses [^...] character classes instead of .+ to avoid backtracking in the detector itself. */
20
+ static DANGEROUS_REGEX = /(\{[0-9]+,\}[^{]*\{[0-9]+,\})|(\+[^+]*\+)|(\*[^*]*\*)|(\.\*){3,}|\\1/;
21
+ /** Forbidden paths that could lead to prototype pollution */
22
+ static FORBIDDEN_PATHS = [
23
+ "__proto__",
24
+ "constructor",
25
+ "prototype"
26
+ ];
27
+ constructor(config) {
28
+ this.tenantField = config.tenantField;
29
+ this.idField = config.idField;
30
+ this._adapterMatchesFilter = config.matchesFilter;
31
+ }
32
+ /**
33
+ * Build filter for single-item operations (get/update/delete)
34
+ * Combines ID filter with policy/org filters for proper security enforcement
35
+ */
36
+ buildIdFilter(id, req) {
37
+ const filter = { [this.idField]: id };
38
+ const arcContext = this._meta(req);
39
+ const policyFilters = arcContext?._policyFilters;
40
+ if (policyFilters) Object.assign(filter, policyFilters);
41
+ const scope = arcContext?._scope;
42
+ const orgId = scope ? getOrgId(scope) : void 0;
43
+ if (orgId && !policyFilters?.[this.tenantField]) filter[this.tenantField] = orgId;
44
+ return filter;
45
+ }
46
+ /**
47
+ * Check if item matches policy filters (for get/update/delete operations)
48
+ * Validates that fetched item satisfies all policy constraints
49
+ *
50
+ * Delegates to adapter-provided matchesFilter if available (for SQL, etc.),
51
+ * otherwise falls back to built-in MongoDB-style matching.
52
+ */
53
+ checkPolicyFilters(item, req) {
54
+ const policyFilters = this._meta(req)?._policyFilters;
55
+ if (!policyFilters) return true;
56
+ if (this._adapterMatchesFilter) return this._adapterMatchesFilter(item, policyFilters);
57
+ return this.defaultMatchesPolicyFilters(item, policyFilters);
58
+ }
59
+ /**
60
+ * Check org/tenant scope for a document — uses configurable tenantField.
61
+ *
62
+ * SECURITY: When org scope is active (orgId present), documents that are
63
+ * missing the tenant field are DENIED by default. This prevents legacy or
64
+ * unscoped records from leaking across tenants.
65
+ */
66
+ checkOrgScope(item, arcContext) {
67
+ const scope = arcContext?._scope;
68
+ const orgId = scope ? getOrgId(scope) : void 0;
69
+ if (!item || !orgId) return true;
70
+ if (scope && isElevated(scope) && !orgId) return true;
71
+ const itemOrgId = item[this.tenantField];
72
+ if (!itemOrgId) return false;
73
+ return String(itemOrgId) === String(orgId);
74
+ }
75
+ /** Check ownership for update/delete (ownedByUser preset) */
76
+ checkOwnership(item, req) {
77
+ const ownershipCheck = this._meta(req)?._ownershipCheck;
78
+ if (!item || !ownershipCheck) return true;
79
+ const { field, userId } = ownershipCheck;
80
+ const itemOwnerId = item[field];
81
+ if (!itemOwnerId) return true;
82
+ return String(itemOwnerId) === String(userId);
83
+ }
84
+ /**
85
+ * Fetch a single document with full access control enforcement.
86
+ * Combines compound DB filter (ID + org + policy) with post-hoc fallback.
87
+ *
88
+ * Takes repository as a parameter to avoid coupling.
89
+ *
90
+ * Replaces the duplicated pattern in get/update/delete:
91
+ * buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
92
+ */
93
+ async fetchWithAccessControl(id, req, repository, queryOptions) {
94
+ const compoundFilter = this.buildIdFilter(id, req);
95
+ const hasCompoundFilters = Object.keys(compoundFilter).length > 1;
96
+ try {
97
+ if (hasCompoundFilters && typeof repository.getOne === "function") return await repository.getOne(compoundFilter, queryOptions);
98
+ const item = await repository.getById(id, queryOptions);
99
+ if (!item) return null;
100
+ const arcContext = this._meta(req);
101
+ if (!this.checkOrgScope(item, arcContext) || !this.checkPolicyFilters(item, req)) return null;
102
+ return item;
103
+ } catch (error) {
104
+ if (error instanceof Error && error.message?.includes("not found")) return null;
105
+ throw error;
106
+ }
107
+ }
108
+ /** Extract typed Arc internal metadata from request */
109
+ _meta(req) {
110
+ return req.metadata;
111
+ }
112
+ /**
113
+ * Check if a value matches a MongoDB query operator
114
+ */
115
+ matchesOperator(itemValue, operator, filterValue) {
116
+ const equalsByValue = (a, b) => String(a) === String(b);
117
+ switch (operator) {
118
+ case "$eq": return equalsByValue(itemValue, filterValue);
119
+ case "$ne": return !equalsByValue(itemValue, filterValue);
120
+ case "$gt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue > filterValue;
121
+ case "$gte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue >= filterValue;
122
+ case "$lt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue < filterValue;
123
+ case "$lte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue <= filterValue;
124
+ case "$in":
125
+ if (!Array.isArray(filterValue)) return false;
126
+ if (Array.isArray(itemValue)) return itemValue.some((v) => filterValue.some((fv) => equalsByValue(v, fv)));
127
+ return filterValue.some((fv) => equalsByValue(itemValue, fv));
128
+ case "$nin":
129
+ if (!Array.isArray(filterValue)) return false;
130
+ if (Array.isArray(itemValue)) return itemValue.every((v) => filterValue.every((fv) => !equalsByValue(v, fv)));
131
+ return filterValue.every((fv) => !equalsByValue(itemValue, fv));
132
+ case "$exists": return filterValue ? itemValue !== void 0 : itemValue === void 0;
133
+ case "$regex":
134
+ if (typeof itemValue === "string" && (typeof filterValue === "string" || filterValue instanceof RegExp)) {
135
+ const regex = typeof filterValue === "string" ? AccessControl.safeRegex(filterValue) : filterValue;
136
+ return regex !== null && regex.test(itemValue);
137
+ }
138
+ return false;
139
+ default: return false;
140
+ }
141
+ }
142
+ /**
143
+ * Check if item matches a single filter condition
144
+ * Supports nested paths (e.g., "owner.id", "metadata.status")
145
+ */
146
+ matchesFilter(item, key, filterValue) {
147
+ const itemValue = key.includes(".") ? this.getNestedValue(item, key) : item[key];
148
+ if (filterValue && typeof filterValue === "object" && !Array.isArray(filterValue)) {
149
+ if (Object.keys(filterValue).some((op) => op.startsWith("$"))) {
150
+ for (const [operator, opValue] of Object.entries(filterValue)) if (!this.matchesOperator(itemValue, operator, opValue)) return false;
151
+ return true;
152
+ }
153
+ }
154
+ if (Array.isArray(itemValue)) return itemValue.some((v) => String(v) === String(filterValue));
155
+ return String(itemValue) === String(filterValue);
156
+ }
157
+ /**
158
+ * Built-in MongoDB-style policy filter matching.
159
+ * Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
160
+ */
161
+ defaultMatchesPolicyFilters(item, policyFilters) {
162
+ if (policyFilters.$and && Array.isArray(policyFilters.$and)) return policyFilters.$and.every((condition) => {
163
+ return Object.entries(condition).every(([key, value]) => {
164
+ return this.matchesFilter(item, key, value);
165
+ });
166
+ });
167
+ if (policyFilters.$or && Array.isArray(policyFilters.$or)) return policyFilters.$or.some((condition) => {
168
+ return Object.entries(condition).every(([key, value]) => {
169
+ return this.matchesFilter(item, key, value);
170
+ });
171
+ });
172
+ for (const [key, value] of Object.entries(policyFilters)) {
173
+ if (key.startsWith("$")) continue;
174
+ if (!this.matchesFilter(item, key, value)) return false;
175
+ }
176
+ return true;
177
+ }
178
+ /**
179
+ * Get nested value from object using dot notation (e.g., "owner.id")
180
+ * Security: Validates path against forbidden patterns to prevent prototype pollution
181
+ */
182
+ getNestedValue(obj, path) {
183
+ if (AccessControl.FORBIDDEN_PATHS.some((p) => path.toLowerCase().includes(p))) return;
184
+ const keys = path.split(".");
185
+ let value = obj;
186
+ for (const key of keys) {
187
+ if (value == null) return void 0;
188
+ if (AccessControl.FORBIDDEN_PATHS.includes(key.toLowerCase())) return;
189
+ value = value[key];
190
+ }
191
+ return value;
192
+ }
193
+ /**
194
+ * Create a safe RegExp from a string, guarding against ReDoS.
195
+ * Returns null if the pattern is invalid or dangerous.
196
+ */
197
+ static safeRegex(pattern) {
198
+ if (pattern.length > MAX_REGEX_LENGTH) return null;
199
+ if (AccessControl.DANGEROUS_REGEX.test(pattern)) return null;
200
+ try {
201
+ return new RegExp(pattern);
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+ };
207
+
208
+ //#endregion
209
+ //#region src/core/BodySanitizer.ts
210
+ var BodySanitizer = class {
211
+ schemaOptions;
212
+ constructor(config) {
213
+ this.schemaOptions = config.schemaOptions;
214
+ }
215
+ /**
216
+ * Strip readonly and system-managed fields from request body.
217
+ * Prevents clients from overwriting _id, timestamps, __v, etc.
218
+ *
219
+ * Also applies field-level write permissions when the request has
220
+ * field permission metadata.
221
+ */
222
+ sanitize(body, _operation, req, meta) {
223
+ let sanitized = { ...body };
224
+ for (const field of SYSTEM_FIELDS) delete sanitized[field];
225
+ const fieldRules = this.schemaOptions.fieldRules ?? {};
226
+ for (const [field, rules] of Object.entries(fieldRules)) if (rules.systemManaged || rules.readonly) delete sanitized[field];
227
+ if (req) {
228
+ const arcContext = meta ?? req.metadata;
229
+ const scope = arcContext?._scope ?? PUBLIC_SCOPE;
230
+ if (!isElevated(scope)) {
231
+ const fieldPerms = arcContext?.arc?.fields;
232
+ if (fieldPerms) {
233
+ const effectiveRoles = resolveEffectiveRoles(req.user?.roles ?? [], isMember(scope) ? scope.orgRoles : []);
234
+ sanitized = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
235
+ }
236
+ }
237
+ }
238
+ return sanitized;
239
+ }
240
+ };
241
+
242
+ //#endregion
243
+ //#region src/core/QueryResolver.ts
244
+ const defaultParser = new ArcQueryParser();
245
+ function getDefaultQueryParser() {
246
+ return defaultParser;
247
+ }
248
+ var QueryResolver = class {
249
+ queryParser;
250
+ maxLimit;
251
+ defaultLimit;
252
+ defaultSort;
253
+ schemaOptions;
254
+ tenantField;
255
+ constructor(config = {}) {
256
+ this.queryParser = config.queryParser ?? getDefaultQueryParser();
257
+ this.maxLimit = config.maxLimit ?? 100;
258
+ this.defaultLimit = config.defaultLimit ?? DEFAULT_LIMIT;
259
+ this.defaultSort = config.defaultSort ?? DEFAULT_SORT;
260
+ this.schemaOptions = config.schemaOptions ?? {};
261
+ this.tenantField = config.tenantField ?? DEFAULT_TENANT_FIELD;
262
+ }
263
+ /**
264
+ * Resolve a request into parsed query options -- ONE parse per request.
265
+ * Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
266
+ */
267
+ resolve(req, meta) {
268
+ const parsed = this.queryParser.parse(req.query);
269
+ const arcContext = meta ?? req.metadata;
270
+ delete parsed.filters?._policyFilters;
271
+ const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
272
+ const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
273
+ const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.defaultSort;
274
+ const selectString = this.selectToString(parsed.select) ?? req.query?.select;
275
+ const filters = { ...parsed.filters };
276
+ const policyFilters = arcContext?._policyFilters;
277
+ if (policyFilters) Object.assign(filters, policyFilters);
278
+ const scope = arcContext?._scope;
279
+ const orgId = scope ? getOrgId(scope) : void 0;
280
+ if (orgId && !policyFilters?.[this.tenantField]) filters[this.tenantField] = orgId;
281
+ return {
282
+ page,
283
+ limit,
284
+ sort: sortString,
285
+ select: this.sanitizeSelect(selectString, this.schemaOptions),
286
+ populate: this.sanitizePopulate(parsed.populate, this.schemaOptions),
287
+ populateOptions: parsed.populateOptions,
288
+ filters,
289
+ search: parsed.search,
290
+ after: parsed.after,
291
+ user: req.user,
292
+ context: arcContext
293
+ };
294
+ }
295
+ /**
296
+ * Convert parsed select object to string format
297
+ * Converts { name: 1, email: 1, password: 0 } -> 'name email -password'
298
+ */
299
+ selectToString(select) {
300
+ if (!select) return void 0;
301
+ if (typeof select === "string") return select;
302
+ if (Array.isArray(select)) return select.join(" ");
303
+ if (Object.keys(select).length === 0) return void 0;
304
+ return Object.entries(select).map(([field, include]) => include === 0 ? `-${field}` : field).join(" ");
305
+ }
306
+ /** Sanitize select fields */
307
+ sanitizeSelect(select, schemaOptions) {
308
+ if (!select) return void 0;
309
+ const blockedFields = this.getBlockedFields(schemaOptions);
310
+ if (blockedFields.length === 0) return select;
311
+ const sanitized = select.split(/[\s,]+/).filter(Boolean).filter((f) => {
312
+ const fieldName = f.replace(/^-/, "");
313
+ return !blockedFields.includes(fieldName);
314
+ });
315
+ return sanitized.length > 0 ? sanitized.join(" ") : void 0;
316
+ }
317
+ /** Sanitize populate fields */
318
+ sanitizePopulate(populate, schemaOptions) {
319
+ if (!populate) return void 0;
320
+ const allowedPopulate = schemaOptions.query?.allowedPopulate;
321
+ const requested = typeof populate === "string" ? populate.split(",").map((p) => p.trim()) : Array.isArray(populate) ? populate.map(String) : [];
322
+ if (requested.length === 0) return void 0;
323
+ if (!allowedPopulate) return requested;
324
+ const sanitized = requested.filter((p) => allowedPopulate.includes(p));
325
+ return sanitized.length > 0 ? sanitized : void 0;
326
+ }
327
+ /** Get blocked fields from schema options */
328
+ getBlockedFields(schemaOptions) {
329
+ const fieldRules = schemaOptions.fieldRules ?? {};
330
+ return Object.entries(fieldRules).filter(([, rules]) => rules.systemManaged || rules.hidden).map(([field]) => field);
331
+ }
332
+ };
333
+
334
+ //#endregion
335
+ //#region src/core/BaseController.ts
336
+ /**
337
+ * Framework-agnostic base controller implementing IController.
338
+ *
339
+ * Composes AccessControl, BodySanitizer, and QueryResolver for clean
340
+ * separation of concerns. CRUD methods delegate directly to these
341
+ * composed classes — no intermediate wrapper methods.
342
+ *
343
+ * @template TDoc - The document type
344
+ * @template TRepository - The repository type (defaults to RepositoryLike)
345
+ */
346
+ var BaseController = class {
347
+ repository;
348
+ schemaOptions;
349
+ queryParser;
350
+ maxLimit;
351
+ defaultLimit;
352
+ defaultSort;
353
+ resourceName;
354
+ tenantField;
355
+ idField = DEFAULT_ID_FIELD;
356
+ /** Composable access control (ID filtering, policy checks, org scope, ownership) */
357
+ accessControl;
358
+ /** Composable body sanitization (field permissions, system fields) */
359
+ bodySanitizer;
360
+ /** Composable query resolution (parsing, pagination, sort, select/populate) */
361
+ queryResolver;
362
+ _matchesFilter;
363
+ _presetFields = {};
364
+ _cacheConfig;
365
+ constructor(repository, options = {}) {
366
+ this.repository = repository;
367
+ this.schemaOptions = options.schemaOptions ?? {};
368
+ this.queryParser = options.queryParser ?? getDefaultQueryParser();
369
+ this.maxLimit = options.maxLimit ?? 100;
370
+ this.defaultLimit = options.defaultLimit ?? DEFAULT_LIMIT;
371
+ this.defaultSort = options.defaultSort ?? DEFAULT_SORT;
372
+ this.resourceName = options.resourceName;
373
+ this.tenantField = options.tenantField ?? DEFAULT_TENANT_FIELD;
374
+ this.idField = options.idField ?? DEFAULT_ID_FIELD;
375
+ this._matchesFilter = options.matchesFilter;
376
+ if (options.cache) this._cacheConfig = options.cache;
377
+ if (options.presetFields) this._presetFields = options.presetFields;
378
+ this.accessControl = new AccessControl({
379
+ tenantField: this.tenantField,
380
+ idField: this.idField,
381
+ matchesFilter: this._matchesFilter
382
+ });
383
+ this.bodySanitizer = new BodySanitizer({ schemaOptions: this.schemaOptions });
384
+ this.queryResolver = new QueryResolver({
385
+ queryParser: this.queryParser,
386
+ maxLimit: this.maxLimit,
387
+ defaultLimit: this.defaultLimit,
388
+ defaultSort: this.defaultSort,
389
+ schemaOptions: this.schemaOptions,
390
+ tenantField: this.tenantField
391
+ });
392
+ this.list = this.list.bind(this);
393
+ this.get = this.get.bind(this);
394
+ this.create = this.create.bind(this);
395
+ this.update = this.update.bind(this);
396
+ this.delete = this.delete.bind(this);
397
+ }
398
+ /** Extract typed Arc internal metadata from request */
399
+ meta(req) {
400
+ return req.metadata;
401
+ }
402
+ /** Get hook system from request context (instance-scoped) */
403
+ getHooks(req) {
404
+ return this.meta(req)?.arc?.hooks ?? null;
405
+ }
406
+ /** Resolve cache config for a specific operation, merging per-op overrides */
407
+ resolveCacheConfig(operation) {
408
+ const cfg = this._cacheConfig;
409
+ if (!cfg || cfg.disabled) return null;
410
+ const opOverride = cfg[operation];
411
+ return {
412
+ staleTime: opOverride?.staleTime ?? cfg.staleTime ?? 0,
413
+ gcTime: opOverride?.gcTime ?? cfg.gcTime ?? 60,
414
+ tags: cfg.tags
415
+ };
416
+ }
417
+ /** Extract user/org IDs from request for cache key scoping */
418
+ cacheScope(req) {
419
+ const userId = getUserId(req.user);
420
+ const scope = this.meta(req)?._scope;
421
+ return {
422
+ userId,
423
+ orgId: scope ? getOrgId(scope) : void 0
424
+ };
425
+ }
426
+ async list(req) {
427
+ const options = this.queryResolver.resolve(req, this.meta(req));
428
+ const cacheConfig = this.resolveCacheConfig("list");
429
+ const qc = req.server?.queryCache;
430
+ if (cacheConfig && qc) {
431
+ const version = await qc.getResourceVersion(this.resourceName);
432
+ const { userId, orgId } = this.cacheScope(req);
433
+ const key = buildQueryKey(this.resourceName, "list", version, options, userId, orgId);
434
+ const { data, status } = await qc.get(key);
435
+ if (status === "fresh") return {
436
+ success: true,
437
+ data,
438
+ status: 200,
439
+ headers: { "x-cache": "HIT" }
440
+ };
441
+ if (status === "stale") {
442
+ setImmediate(() => {
443
+ this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
444
+ });
445
+ return {
446
+ success: true,
447
+ data,
448
+ status: 200,
449
+ headers: { "x-cache": "STALE" }
450
+ };
451
+ }
452
+ const result = await this.executeListQuery(options, req);
453
+ await qc.set(key, result, cacheConfig);
454
+ return {
455
+ success: true,
456
+ data: result,
457
+ status: 200,
458
+ headers: { "x-cache": "MISS" }
459
+ };
460
+ }
461
+ return {
462
+ success: true,
463
+ data: await this.executeListQuery(options, req),
464
+ status: 200
465
+ };
466
+ }
467
+ /** Execute list query through hooks (extracted for cache revalidation) */
468
+ async executeListQuery(options, req) {
469
+ const hooks = this.getHooks(req);
470
+ const repoGetAll = async () => this.repository.getAll(options);
471
+ const result = hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "list", options, repoGetAll, {
472
+ user: req.user,
473
+ context: this.meta(req)
474
+ }) : await repoGetAll();
475
+ if (Array.isArray(result)) return {
476
+ docs: result,
477
+ page: 1,
478
+ limit: result.length,
479
+ total: result.length,
480
+ pages: 1,
481
+ hasNext: false,
482
+ hasPrev: false
483
+ };
484
+ return result;
485
+ }
486
+ async get(req) {
487
+ const id = req.params.id;
488
+ if (!id) return {
489
+ success: false,
490
+ error: "ID parameter is required",
491
+ status: 400
492
+ };
493
+ const options = this.queryResolver.resolve(req, this.meta(req));
494
+ const cacheConfig = this.resolveCacheConfig("byId");
495
+ const qc = req.server?.queryCache;
496
+ if (cacheConfig && qc) {
497
+ const version = await qc.getResourceVersion(this.resourceName);
498
+ const { userId, orgId } = this.cacheScope(req);
499
+ const key = buildQueryKey(this.resourceName, "get", version, {
500
+ id,
501
+ ...options
502
+ }, userId, orgId);
503
+ const { data, status } = await qc.get(key);
504
+ if (status === "fresh") return {
505
+ success: true,
506
+ data,
507
+ status: 200,
508
+ headers: { "x-cache": "HIT" }
509
+ };
510
+ if (status === "stale") {
511
+ setImmediate(() => {
512
+ this.executeGetQuery(id, options, req).then((fresh) => {
513
+ if (fresh) qc.set(key, fresh, cacheConfig);
514
+ }).catch(() => {});
515
+ });
516
+ return {
517
+ success: true,
518
+ data,
519
+ status: 200,
520
+ headers: { "x-cache": "STALE" }
521
+ };
522
+ }
523
+ const item = await this.executeGetQuery(id, options, req);
524
+ if (!item) return {
525
+ success: false,
526
+ error: "Resource not found",
527
+ status: 404
528
+ };
529
+ await qc.set(key, item, cacheConfig);
530
+ return {
531
+ success: true,
532
+ data: item,
533
+ status: 200,
534
+ headers: { "x-cache": "MISS" }
535
+ };
536
+ }
537
+ try {
538
+ const item = await this.executeGetQuery(id, options, req);
539
+ if (!item) return {
540
+ success: false,
541
+ error: "Resource not found",
542
+ status: 404
543
+ };
544
+ return {
545
+ success: true,
546
+ data: item,
547
+ status: 200
548
+ };
549
+ } catch (error) {
550
+ if (error instanceof Error && error.message?.includes("not found")) return {
551
+ success: false,
552
+ error: "Resource not found",
553
+ status: 404
554
+ };
555
+ throw error;
556
+ }
557
+ }
558
+ /** Execute get query through hooks (extracted for cache revalidation) */
559
+ async executeGetQuery(id, options, req) {
560
+ const hooks = this.getHooks(req);
561
+ const fetchItem = async () => this.accessControl.fetchWithAccessControl(id, req, this.repository, options);
562
+ return (hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "read", null, fetchItem, {
563
+ user: req.user,
564
+ context: this.meta(req)
565
+ }) : await fetchItem()) ?? null;
566
+ }
567
+ async create(req) {
568
+ const arcContext = this.meta(req);
569
+ const data = this.bodySanitizer.sanitize(req.body ?? {}, "create", req, arcContext);
570
+ const scope = arcContext?._scope;
571
+ const createOrgId = scope ? getOrgId(scope) : void 0;
572
+ if (createOrgId) data[this.tenantField] = createOrgId;
573
+ const userId = getUserId(req.user);
574
+ if (userId) data.createdBy = userId;
575
+ const hooks = this.getHooks(req);
576
+ const user = req.user;
577
+ let processedData = data;
578
+ if (hooks && this.resourceName) try {
579
+ processedData = await hooks.executeBefore(this.resourceName, "create", data, {
580
+ user,
581
+ context: arcContext
582
+ });
583
+ } catch (err) {
584
+ return {
585
+ success: false,
586
+ error: "Hook execution failed",
587
+ details: {
588
+ code: "BEFORE_CREATE_HOOK_ERROR",
589
+ message: err.message
590
+ },
591
+ status: 400
592
+ };
593
+ }
594
+ const repoCreate = async () => this.repository.create(processedData, {
595
+ user,
596
+ context: arcContext
597
+ });
598
+ let item;
599
+ if (hooks && this.resourceName) {
600
+ item = await hooks.executeAround(this.resourceName, "create", processedData, repoCreate, {
601
+ user,
602
+ context: arcContext
603
+ });
604
+ await hooks.executeAfter(this.resourceName, "create", item, {
605
+ user,
606
+ context: arcContext
607
+ });
608
+ } else item = await repoCreate();
609
+ return {
610
+ success: true,
611
+ data: item,
612
+ status: 201,
613
+ meta: { message: "Created successfully" }
614
+ };
615
+ }
616
+ async update(req) {
617
+ const id = req.params.id;
618
+ if (!id) return {
619
+ success: false,
620
+ error: "ID parameter is required",
621
+ status: 400
622
+ };
623
+ const arcContext = this.meta(req);
624
+ const data = this.bodySanitizer.sanitize(req.body ?? {}, "update", req, arcContext);
625
+ const user = req.user;
626
+ const userId = getUserId(user);
627
+ if (userId) data.updatedBy = userId;
628
+ const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
629
+ if (!existing) return {
630
+ success: false,
631
+ error: "Resource not found",
632
+ status: 404
633
+ };
634
+ if (!this.accessControl.checkOwnership(existing, req)) return {
635
+ success: false,
636
+ error: "You do not have permission to modify this resource",
637
+ details: { code: "OWNERSHIP_DENIED" },
638
+ status: 403
639
+ };
640
+ const hooks = this.getHooks(req);
641
+ let processedData = data;
642
+ if (hooks && this.resourceName) try {
643
+ processedData = await hooks.executeBefore(this.resourceName, "update", data, {
644
+ user,
645
+ context: arcContext,
646
+ meta: {
647
+ id,
648
+ existing
649
+ }
650
+ });
651
+ } catch (err) {
652
+ return {
653
+ success: false,
654
+ error: "Hook execution failed",
655
+ details: {
656
+ code: "BEFORE_UPDATE_HOOK_ERROR",
657
+ message: err.message
658
+ },
659
+ status: 400
660
+ };
661
+ }
662
+ const repoUpdate = async () => this.repository.update(id, processedData, {
663
+ user,
664
+ context: arcContext
665
+ });
666
+ let item;
667
+ if (hooks && this.resourceName) {
668
+ item = await hooks.executeAround(this.resourceName, "update", processedData, repoUpdate, {
669
+ user,
670
+ context: arcContext,
671
+ meta: {
672
+ id,
673
+ existing
674
+ }
675
+ });
676
+ if (item) await hooks.executeAfter(this.resourceName, "update", item, {
677
+ user,
678
+ context: arcContext,
679
+ meta: {
680
+ id,
681
+ existing
682
+ }
683
+ });
684
+ } else item = await repoUpdate();
685
+ if (!item) return {
686
+ success: false,
687
+ error: "Resource not found",
688
+ status: 404
689
+ };
690
+ return {
691
+ success: true,
692
+ data: item,
693
+ status: 200,
694
+ meta: { message: "Updated successfully" }
695
+ };
696
+ }
697
+ async delete(req) {
698
+ const id = req.params.id;
699
+ if (!id) return {
700
+ success: false,
701
+ error: "ID parameter is required",
702
+ status: 400
703
+ };
704
+ const arcContext = this.meta(req);
705
+ const user = req.user;
706
+ const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
707
+ if (!existing) return {
708
+ success: false,
709
+ error: "Resource not found",
710
+ status: 404
711
+ };
712
+ if (!this.accessControl.checkOwnership(existing, req)) return {
713
+ success: false,
714
+ error: "You do not have permission to delete this resource",
715
+ details: { code: "OWNERSHIP_DENIED" },
716
+ status: 403
717
+ };
718
+ const hooks = this.getHooks(req);
719
+ if (hooks && this.resourceName) try {
720
+ await hooks.executeBefore(this.resourceName, "delete", existing, {
721
+ user,
722
+ context: arcContext,
723
+ meta: { id }
724
+ });
725
+ } catch (err) {
726
+ return {
727
+ success: false,
728
+ error: "Hook execution failed",
729
+ details: {
730
+ code: "BEFORE_DELETE_HOOK_ERROR",
731
+ message: err.message
732
+ },
733
+ status: 400
734
+ };
735
+ }
736
+ const repoDelete = async () => this.repository.delete(id, {
737
+ user,
738
+ context: arcContext
739
+ });
740
+ let result;
741
+ if (hooks && this.resourceName) result = await hooks.executeAround(this.resourceName, "delete", existing, repoDelete, {
742
+ user,
743
+ context: arcContext,
744
+ meta: { id }
745
+ });
746
+ else result = await repoDelete();
747
+ if (!(typeof result === "object" && result !== null ? result.success : result)) return {
748
+ success: false,
749
+ error: "Resource not found",
750
+ status: 404
751
+ };
752
+ if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "delete", existing, {
753
+ user,
754
+ context: arcContext,
755
+ meta: { id }
756
+ });
757
+ return {
758
+ success: true,
759
+ data: { message: "Deleted successfully" },
760
+ status: 200
761
+ };
762
+ }
763
+ async getBySlug(req) {
764
+ const repo = this.repository;
765
+ if (!repo.getBySlug) return {
766
+ success: false,
767
+ error: "Slug lookup not implemented",
768
+ status: 501
769
+ };
770
+ const slugField = this._presetFields.slugField ?? "slug";
771
+ const slug = req.params[slugField] ?? req.params.slug;
772
+ const options = this.queryResolver.resolve(req, this.meta(req));
773
+ const arcContext = this.meta(req);
774
+ const item = await repo.getBySlug(slug, options);
775
+ if (!item || !this.accessControl.checkOrgScope(item, arcContext)) return {
776
+ success: false,
777
+ error: "Resource not found",
778
+ status: 404
779
+ };
780
+ return {
781
+ success: true,
782
+ data: item,
783
+ status: 200
784
+ };
785
+ }
786
+ async getDeleted(req) {
787
+ const repo = this.repository;
788
+ if (!repo.getDeleted) return {
789
+ success: false,
790
+ error: "Soft delete not implemented",
791
+ status: 501
792
+ };
793
+ const options = this.queryResolver.resolve(req, this.meta(req));
794
+ const result = await repo.getDeleted(options);
795
+ if (Array.isArray(result)) return {
796
+ success: true,
797
+ data: {
798
+ docs: result,
799
+ page: 1,
800
+ limit: result.length,
801
+ total: result.length,
802
+ pages: 1,
803
+ hasNext: false,
804
+ hasPrev: false
805
+ },
806
+ status: 200
807
+ };
808
+ return {
809
+ success: true,
810
+ data: result,
811
+ status: 200
812
+ };
813
+ }
814
+ async restore(req) {
815
+ const repo = this.repository;
816
+ if (!repo.restore) return {
817
+ success: false,
818
+ error: "Restore not implemented",
819
+ status: 501
820
+ };
821
+ const id = req.params.id;
822
+ if (!id) return {
823
+ success: false,
824
+ error: "ID parameter is required",
825
+ status: 400
826
+ };
827
+ const item = await repo.restore(id);
828
+ if (!item) return {
829
+ success: false,
830
+ error: "Resource not found",
831
+ status: 404
832
+ };
833
+ return {
834
+ success: true,
835
+ data: item,
836
+ status: 200,
837
+ meta: { message: "Restored successfully" }
838
+ };
839
+ }
840
+ async getTree(req) {
841
+ const repo = this.repository;
842
+ if (!repo.getTree) return {
843
+ success: false,
844
+ error: "Tree structure not implemented",
845
+ status: 501
846
+ };
847
+ const options = this.queryResolver.resolve(req, this.meta(req));
848
+ return {
849
+ success: true,
850
+ data: await repo.getTree(options),
851
+ status: 200
852
+ };
853
+ }
854
+ async getChildren(req) {
855
+ const repo = this.repository;
856
+ if (!repo.getChildren) return {
857
+ success: false,
858
+ error: "Tree structure not implemented",
859
+ status: 501
860
+ };
861
+ const parentField = this._presetFields.parentField ?? "parent";
862
+ const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
863
+ const options = this.queryResolver.resolve(req, this.meta(req));
864
+ return {
865
+ success: true,
866
+ data: await repo.getChildren(parentId, options),
867
+ status: 200
868
+ };
869
+ }
870
+ };
871
+
872
+ //#endregion
873
+ //#region src/core/fastifyAdapter.ts
874
+ /** Type guard for Mongoose-like documents with toObject() */
875
+ function isMongooseDoc(obj) {
876
+ return !!obj && typeof obj === "object" && "toObject" in obj && typeof obj.toObject === "function";
877
+ }
878
+ /**
879
+ * Apply field mask to a single object
880
+ * Filters fields based on include/exclude rules
881
+ */
882
+ function applyFieldMaskToObject(obj, fieldMask) {
883
+ if (!obj || typeof obj !== "object") return obj;
884
+ const plain = isMongooseDoc(obj) ? obj.toObject() : obj;
885
+ const { include, exclude } = fieldMask;
886
+ if (include && include.length > 0) {
887
+ const filtered = {};
888
+ for (const field of include) if (field in plain) filtered[field] = plain[field];
889
+ return filtered;
890
+ }
891
+ if (exclude && exclude.length > 0) {
892
+ const filtered = { ...plain };
893
+ for (const field of exclude) delete filtered[field];
894
+ return filtered;
895
+ }
896
+ return plain;
897
+ }
898
+ /**
899
+ * Apply field mask to response data (handles both objects and arrays)
900
+ */
901
+ function applyFieldMask(data, fieldMask) {
902
+ if (!fieldMask) return data;
903
+ if (Array.isArray(data)) return data.map((item) => applyFieldMaskToObject(item, fieldMask));
904
+ if (data && typeof data === "object") return applyFieldMaskToObject(data, fieldMask);
905
+ return data;
906
+ }
907
+ /**
908
+ * Create IRequestContext from Fastify request
909
+ *
910
+ * Extracts framework-agnostic context from Fastify-specific request object
911
+ */
912
+ function createRequestContext(req) {
913
+ const reqWithExtras = req;
914
+ const requestContext = reqWithExtras.context ?? {};
915
+ const srv = req.server;
916
+ const serverAccessor = {
917
+ events: srv && "events" in srv ? srv.events : void 0,
918
+ audit: srv && "audit" in srv ? srv.audit : void 0,
919
+ queryCache: srv && "queryCache" in srv ? srv.queryCache : void 0,
920
+ log: req.log
921
+ };
922
+ return {
923
+ query: reqWithExtras.query ?? {},
924
+ body: reqWithExtras.body ?? {},
925
+ params: reqWithExtras.params ?? {},
926
+ headers: reqWithExtras.headers,
927
+ user: reqWithExtras.user ? (() => {
928
+ const user = reqWithExtras.user;
929
+ const rawId = user._id ?? user.id;
930
+ const normalizedId = rawId ? String(rawId) : void 0;
931
+ return {
932
+ ...user,
933
+ id: normalizedId,
934
+ _id: normalizedId
935
+ };
936
+ })() : null,
937
+ context: requestContext,
938
+ metadata: {
939
+ ...reqWithExtras.context,
940
+ arc: reqWithExtras.arc,
941
+ _scope: reqWithExtras.scope,
942
+ _ownershipCheck: reqWithExtras._ownershipCheck,
943
+ _policyFilters: reqWithExtras._policyFilters ?? {},
944
+ log: reqWithExtras.log
945
+ },
946
+ server: serverAccessor
947
+ };
948
+ }
949
+ /**
950
+ * Get typed auth context from an IRequestContext.
951
+ * Use this in controller overrides to access request context.
952
+ *
953
+ * For org scope, use `getControllerScope(req)` instead.
954
+ */
955
+ function getControllerContext(req) {
956
+ return req.context ?? req.metadata ?? {};
957
+ }
958
+ /**
959
+ * Get request scope from an IRequestContext.
960
+ * Returns the RequestScope set by auth adapters.
961
+ */
962
+ function getControllerScope(req) {
963
+ return req.metadata?._scope ?? PUBLIC_SCOPE;
964
+ }
965
+ /**
966
+ * Compute per-field capability metadata for the current user.
967
+ * Only includes fields that have restrictions — unrestricted fields
968
+ * are omitted (frontend defaults to { readable: true, writable: true }).
969
+ */
970
+ function computeFieldCapabilities(fieldPerms, effectiveRoles) {
971
+ const caps = {};
972
+ for (const [field, perm] of Object.entries(fieldPerms)) {
973
+ let readable = true;
974
+ let writable = true;
975
+ switch (perm._type) {
976
+ case "hidden":
977
+ readable = false;
978
+ writable = false;
979
+ break;
980
+ case "visibleTo":
981
+ readable = perm.roles?.some((r) => effectiveRoles.includes(r)) ?? false;
982
+ break;
983
+ case "writableBy":
984
+ writable = perm.roles?.some((r) => effectiveRoles.includes(r)) ?? false;
985
+ break;
986
+ }
987
+ caps[field] = {
988
+ readable,
989
+ writable
990
+ };
991
+ }
992
+ return caps;
993
+ }
994
+ /**
995
+ * Send IControllerResponse via Fastify reply
996
+ *
997
+ * Converts framework-agnostic response to Fastify response
998
+ * Applies field masking if specified in request
999
+ */
1000
+ function sendControllerResponse(reply, response, request) {
1001
+ const reqWithExtras = request;
1002
+ const fieldMaskConfig = reqWithExtras?.fieldMask;
1003
+ const arcMeta = reqWithExtras?.arc;
1004
+ const scope = reqWithExtras?.scope ?? PUBLIC_SCOPE;
1005
+ const fieldPerms = isElevated(scope) ? void 0 : arcMeta?.fields;
1006
+ const effectiveRoles = fieldPerms ? resolveEffectiveRoles((reqWithExtras?.user)?.roles ?? [], isMember(scope) ? scope.orgRoles : []) : [];
1007
+ const fieldCaps = fieldPerms ? computeFieldCapabilities(fieldPerms, effectiveRoles) : void 0;
1008
+ const hasFieldRestrictions = !!(fieldMaskConfig || fieldPerms);
1009
+ /** Apply both field mask and field-level permissions to a data item */
1010
+ const applyPermissions = (data) => {
1011
+ let result = fieldMaskConfig ? applyFieldMask(data, fieldMaskConfig) : data;
1012
+ if (fieldPerms && result && typeof result === "object") if (Array.isArray(result)) result = result.map((item) => applyFieldReadPermissions(item, fieldPerms, effectiveRoles));
1013
+ else result = applyFieldReadPermissions(result, fieldPerms, effectiveRoles);
1014
+ return result;
1015
+ };
1016
+ if (response.headers) for (const [key, value] of Object.entries(response.headers)) reply.header(key, value);
1017
+ if (response.success && response.data && typeof response.data === "object" && "docs" in response.data) {
1018
+ const paginatedData = response.data;
1019
+ const filteredDocs = hasFieldRestrictions ? applyPermissions(paginatedData.docs) : paginatedData.docs;
1020
+ reply.code(response.status ?? 200).send({
1021
+ success: true,
1022
+ docs: filteredDocs,
1023
+ page: paginatedData.page,
1024
+ limit: paginatedData.limit,
1025
+ total: paginatedData.total,
1026
+ pages: paginatedData.pages,
1027
+ hasNext: paginatedData.hasNext,
1028
+ hasPrev: paginatedData.hasPrev,
1029
+ ...response.meta ?? {},
1030
+ ...fieldCaps ? { fieldPermissions: fieldCaps } : {}
1031
+ });
1032
+ return;
1033
+ }
1034
+ const filteredData = hasFieldRestrictions ? applyPermissions(response.data) : response.data;
1035
+ reply.code(response.status ?? (response.success ? 200 : 400)).send({
1036
+ success: response.success,
1037
+ data: filteredData,
1038
+ error: response.error,
1039
+ details: response.details,
1040
+ ...response.meta ?? {},
1041
+ ...fieldCaps ? { fieldPermissions: fieldCaps } : {}
1042
+ });
1043
+ }
1044
+ /**
1045
+ * Create Fastify route handler from IController method
1046
+ *
1047
+ * Wraps framework-agnostic controller method in Fastify-specific handler
1048
+ *
1049
+ * @example
1050
+ * ```typescript
1051
+ * const controller = new BaseController(repository);
1052
+ *
1053
+ * // Create Fastify handler
1054
+ * const listHandler = createFastifyHandler(controller.list.bind(controller));
1055
+ *
1056
+ * // Register route
1057
+ * fastify.get('/products', listHandler);
1058
+ * ```
1059
+ */
1060
+ function createFastifyHandler(controllerMethod) {
1061
+ return async (req, reply) => {
1062
+ sendControllerResponse(reply, await controllerMethod(createRequestContext(req)), req);
1063
+ };
1064
+ }
1065
+ /**
1066
+ * Create Fastify adapters for all CRUD methods of an IController
1067
+ *
1068
+ * Returns Fastify-compatible handlers for each CRUD operation
1069
+ *
1070
+ * @example
1071
+ * ```typescript
1072
+ * const controller = new BaseController(repository);
1073
+ * const handlers = createCrudHandlers(controller);
1074
+ *
1075
+ * fastify.get('/', handlers.list);
1076
+ * fastify.get('/:id', handlers.get);
1077
+ * fastify.post('/', handlers.create);
1078
+ * fastify.patch('/:id', handlers.update);
1079
+ * fastify.delete('/:id', handlers.delete);
1080
+ * ```
1081
+ */
1082
+ function createCrudHandlers(controller) {
1083
+ return {
1084
+ list: createFastifyHandler(controller.list.bind(controller)),
1085
+ get: createFastifyHandler(controller.get.bind(controller)),
1086
+ create: createFastifyHandler(controller.create.bind(controller)),
1087
+ update: createFastifyHandler(controller.update.bind(controller)),
1088
+ delete: createFastifyHandler(controller.delete.bind(controller))
1089
+ };
1090
+ }
1091
+
1092
+ //#endregion
1093
+ //#region src/pipeline/pipe.ts
1094
+ /**
1095
+ * Compose pipeline steps into an ordered array.
1096
+ * Accepts guards, transforms, and interceptors in any order.
1097
+ */
1098
+ function pipe(...steps) {
1099
+ return steps;
1100
+ }
1101
+ /**
1102
+ * Check if a step applies to the given operation.
1103
+ */
1104
+ function appliesTo(step, operation) {
1105
+ if (!step.operations || step.operations.length === 0) return true;
1106
+ return step.operations.includes(operation);
1107
+ }
1108
+ /**
1109
+ * Execute a pipeline against a request context.
1110
+ *
1111
+ * This is the core runtime that createCrudRouter uses to execute pipelines.
1112
+ * External usage is not needed — this is wired automatically when `pipe` is set.
1113
+ *
1114
+ * @param steps - Pipeline steps to execute
1115
+ * @param ctx - The pipeline context (extends IRequestContext)
1116
+ * @param handler - The actual controller method to call
1117
+ * @param operation - The CRUD operation name
1118
+ * @returns The controller response (possibly modified by interceptors)
1119
+ */
1120
+ async function executePipeline(steps, ctx, handler, operation) {
1121
+ const guards = [];
1122
+ const transforms = [];
1123
+ const interceptors = [];
1124
+ for (const step of steps) {
1125
+ if (!appliesTo(step, operation)) continue;
1126
+ switch (step._type) {
1127
+ case "guard":
1128
+ guards.push(step);
1129
+ break;
1130
+ case "transform":
1131
+ transforms.push(step);
1132
+ break;
1133
+ case "interceptor":
1134
+ interceptors.push(step);
1135
+ break;
1136
+ }
1137
+ }
1138
+ for (const g of guards) if (!await g.handler(ctx)) throw new ForbiddenError(`Guard '${g.name}' denied access`);
1139
+ let currentCtx = ctx;
1140
+ for (const t of transforms) {
1141
+ const result = await t.handler(currentCtx);
1142
+ if (result) currentCtx = result;
1143
+ }
1144
+ let chain = () => handler(currentCtx);
1145
+ for (let i = interceptors.length - 1; i >= 0; i--) {
1146
+ const interceptor = interceptors[i];
1147
+ const next = chain;
1148
+ chain = () => interceptor.handler(currentCtx, next);
1149
+ }
1150
+ return chain();
1151
+ }
1152
+
1153
+ //#endregion
1154
+ //#region src/core/createCrudRouter.ts
1155
+ /**
1156
+ * Build per-route rate limit config object.
1157
+ *
1158
+ * Returns a `config` object suitable for Fastify's `route()` options,
1159
+ * or `undefined` if no rate limit is configured for this resource.
1160
+ *
1161
+ * - `RateLimitConfig` object -> apply that limit to the route
1162
+ * - `false` -> explicitly disable rate limiting for the route
1163
+ * - `undefined` -> no override (inherits instance-level config)
1164
+ */
1165
+ function buildRateLimitConfig(rateLimit) {
1166
+ if (rateLimit === void 0) return void 0;
1167
+ if (rateLimit === false) return { rateLimit: false };
1168
+ return { rateLimit: {
1169
+ max: rateLimit.max,
1170
+ timeWindow: rateLimit.timeWindow
1171
+ } };
1172
+ }
1173
+ /**
1174
+ * Check if a permission requires authentication
1175
+ *
1176
+ * A permission requires auth if:
1177
+ * - It exists AND
1178
+ * - It doesn't have _isPublic flag set to true
1179
+ *
1180
+ * This is used to automatically add fastify.authenticate
1181
+ * to the preHandler chain for non-public routes.
1182
+ */
1183
+ function requiresAuthentication(permission) {
1184
+ if (!permission) return false;
1185
+ return !permission._isPublic;
1186
+ }
1187
+ /**
1188
+ * Build authentication middleware
1189
+ *
1190
+ * - Protected routes (requireAuth, requireRoles, etc.): uses fastify.authenticate (fails without token)
1191
+ * - Public routes (allowPublic): uses fastify.optionalAuthenticate (parses token if present, doesn't fail)
1192
+ *
1193
+ * This ensures request.user is populated on public routes when a Bearer token is sent,
1194
+ * enabling downstream middleware (e.g. multiTenant flexible filter) to apply org-scoped queries.
1195
+ */
1196
+ function buildAuthMiddleware(fastify, permission) {
1197
+ if (requiresAuthentication(permission)) return fastify.authenticate ?? null;
1198
+ return fastify.optionalAuthenticate ?? null;
1199
+ }
1200
+ /**
1201
+ * Build permission middleware from PermissionCheck function
1202
+ *
1203
+ * Creates a Fastify preHandler that:
1204
+ * 1. Executes the permission check
1205
+ * 2. Returns 401 if authentication required but user absent
1206
+ * 3. Returns 403 if permission denied
1207
+ * 4. Applies query filters from PermissionResult if present
1208
+ */
1209
+ function buildPermissionMiddleware(permissionCheck, resourceName, action) {
1210
+ if (!permissionCheck) return null;
1211
+ return async (request, reply) => {
1212
+ const reqWithExtras = request;
1213
+ const params = request.params;
1214
+ const context = {
1215
+ user: reqWithExtras.user ?? null,
1216
+ request,
1217
+ resource: resourceName,
1218
+ action,
1219
+ resourceId: params?.id,
1220
+ params,
1221
+ data: request.body
1222
+ };
1223
+ let result;
1224
+ try {
1225
+ result = await permissionCheck(context);
1226
+ } catch (err) {
1227
+ request.log?.warn?.({
1228
+ err,
1229
+ resource: resourceName,
1230
+ action
1231
+ }, "Permission check threw");
1232
+ reply.code(403).send({
1233
+ success: false,
1234
+ error: "Permission denied"
1235
+ });
1236
+ return;
1237
+ }
1238
+ if (typeof result === "boolean") {
1239
+ if (!result) {
1240
+ reply.code(context.user ? 403 : 401).send({
1241
+ success: false,
1242
+ error: context.user ? "Permission denied" : "Authentication required"
1243
+ });
1244
+ return;
1245
+ }
1246
+ return;
1247
+ }
1248
+ const permResult = result;
1249
+ if (!permResult.granted) {
1250
+ reply.code(context.user ? 403 : 401).send({
1251
+ success: false,
1252
+ error: permResult.reason ?? (context.user ? "Permission denied" : "Authentication required")
1253
+ });
1254
+ return;
1255
+ }
1256
+ if (permResult.filters) reqWithExtras._policyFilters = {
1257
+ ...reqWithExtras._policyFilters ?? {},
1258
+ ...permResult.filters
1259
+ };
1260
+ };
1261
+ }
1262
+ /**
1263
+ * Create additional routes from preset/custom definitions
1264
+ */
1265
+ function createAdditionalRoutes(fastify, routes, controller, options) {
1266
+ const { tag, resourceName, arcDecorator, rateLimitConfig, cacheMw, idempotencyMw, pipeline } = options;
1267
+ for (const route of routes) {
1268
+ const opName = route.operation ?? (typeof route.handler === "string" ? route.handler : `${route.method.toLowerCase()}${route.path.replace(/[/:]/g, "_")}`);
1269
+ let handler;
1270
+ if (typeof route.handler === "string") {
1271
+ if (!controller) throw new Error(`Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller. Either provide a controller or use a function handler instead.`);
1272
+ const method = controller[route.handler];
1273
+ if (typeof method !== "function") throw new Error(`Handler '${route.handler}' not found on controller`);
1274
+ const boundMethod = method.bind(controller);
1275
+ if (route.wrapHandler) {
1276
+ const steps = pipeline ? resolvePipelineSteps(pipeline, opName) : [];
1277
+ if (steps.length > 0) handler = createPipelineHandler(boundMethod, steps, opName, resourceName);
1278
+ else handler = createFastifyHandler(boundMethod);
1279
+ } else handler = boundMethod;
1280
+ } else if (route.wrapHandler) {
1281
+ const steps = pipeline ? resolvePipelineSteps(pipeline, opName) : [];
1282
+ if (steps.length > 0) handler = createPipelineHandler(route.handler, steps, opName, resourceName);
1283
+ else handler = createFastifyHandler(route.handler);
1284
+ } else handler = route.handler;
1285
+ const routeTags = route.tags ?? (tag ? [tag] : void 0);
1286
+ const convertedSchema = route.schema ? convertRouteSchema(route.schema) : void 0;
1287
+ const schema = {
1288
+ ...routeTags ? { tags: routeTags } : {},
1289
+ ...route.summary ? { summary: route.summary } : {},
1290
+ ...route.description ? { description: route.description } : {},
1291
+ ...convertedSchema ?? {}
1292
+ };
1293
+ const authMw = buildAuthMiddleware(fastify, route.permissions);
1294
+ const permissionMw = buildPermissionMiddleware(route.permissions, resourceName, opName);
1295
+ const customPreHandlers = typeof route.preHandler === "function" ? route.preHandler(fastify) : route.preHandler ?? [];
1296
+ const preHandler = [
1297
+ arcDecorator,
1298
+ authMw,
1299
+ permissionMw,
1300
+ route.method === "GET" ? cacheMw : [
1301
+ "POST",
1302
+ "PUT",
1303
+ "PATCH"
1304
+ ].includes(route.method) ? idempotencyMw : null,
1305
+ ...customPreHandlers
1306
+ ].filter(Boolean);
1307
+ fastify.route({
1308
+ method: route.method,
1309
+ url: route.path,
1310
+ schema,
1311
+ preHandler: preHandler.length > 0 ? preHandler : void 0,
1312
+ handler,
1313
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
1314
+ });
1315
+ }
1316
+ }
1317
+ /**
1318
+ * Resolve pipeline steps for a specific operation.
1319
+ * If pipeline is a flat array, all steps are returned.
1320
+ * If it's a per-operation map, only matching steps are returned.
1321
+ */
1322
+ function resolvePipelineSteps(pipeline, operation) {
1323
+ if (!pipeline) return [];
1324
+ if (Array.isArray(pipeline)) return pipeline;
1325
+ return pipeline[operation] ?? [];
1326
+ }
1327
+ /**
1328
+ * Create a Fastify handler that wraps a controller method with pipeline execution.
1329
+ */
1330
+ function createPipelineHandler(controllerMethod, steps, operation, resourceName) {
1331
+ return async (req, reply) => {
1332
+ sendControllerResponse(reply, await executePipeline(steps, {
1333
+ ...createRequestContext(req),
1334
+ resource: resourceName,
1335
+ operation
1336
+ }, (ctx) => controllerMethod(ctx), operation), req);
1337
+ };
1338
+ }
1339
+ /**
1340
+ * Create CRUD routes for a controller
1341
+ *
1342
+ * @param fastify - Fastify instance with Arc decorators
1343
+ * @param controller - CRUD controller with handler methods
1344
+ * @param options - Router configuration
1345
+ */
1346
+ function createCrudRouter(fastify, controller, options = {}) {
1347
+ const { tag = "Resource", schemas = {}, permissions = {}, middlewares = {}, additionalRoutes = [], disableDefaultRoutes = false, disabledRoutes = [], resourceName = "unknown", schemaOptions, rateLimit, pipe: pipeline, fields: fieldPermissions, updateMethod = DEFAULT_UPDATE_METHOD } = options;
1348
+ const rateLimitConfig = buildRateLimitConfig(rateLimit);
1349
+ const cacheMw = !(fastify.hasDecorator("queryCache") && controller && typeof controller._cacheConfig !== "undefined" && controller._cacheConfig !== void 0) && fastify.hasDecorator("responseCache") ? fastify.responseCache.middleware : null;
1350
+ const idempotencyMw = fastify.hasDecorator("idempotency") ? fastify.idempotency.middleware : null;
1351
+ const arcMeta = Object.freeze({
1352
+ resourceName,
1353
+ schemaOptions,
1354
+ permissions,
1355
+ hooks: fastify.arc?.hooks,
1356
+ events: fastify.events,
1357
+ fields: fieldPermissions
1358
+ });
1359
+ const arcDecorator = async (req, _reply) => {
1360
+ req.arc = arcMeta;
1361
+ const store = requestContext.get();
1362
+ if (store) store.resourceName = resourceName;
1363
+ };
1364
+ const mw = {
1365
+ list: middlewares.list ?? [],
1366
+ get: middlewares.get ?? [],
1367
+ create: middlewares.create ?? [],
1368
+ update: middlewares.update ?? [],
1369
+ delete: middlewares.delete ?? []
1370
+ };
1371
+ const idParamsSchema = {
1372
+ type: "object",
1373
+ properties: { id: { type: "string" } },
1374
+ required: ["id"]
1375
+ };
1376
+ const defaultSchemas = getDefaultCrudSchemas();
1377
+ /**
1378
+ * Build route schema by merging: base (tags/summary) → defaults (response/querystring) → user overrides.
1379
+ * User-provided schemas always take precedence. Defaults enable fast-json-stringify when no user schema is set.
1380
+ */
1381
+ const buildSchema = (base, defaults, userSchema) => ({
1382
+ ...defaults,
1383
+ ...base,
1384
+ ...userSchema ?? {}
1385
+ });
1386
+ let handlers;
1387
+ if (!disableDefaultRoutes) {
1388
+ if (!controller) throw new Error("Controller is required when disableDefaultRoutes is not true. Provide a controller or use defineResource which auto-creates BaseController.");
1389
+ const ctrl = controller;
1390
+ if (pipeline) {
1391
+ const ops = CRUD_OPERATIONS;
1392
+ const wrapped = {};
1393
+ for (const op of ops) {
1394
+ const steps = resolvePipelineSteps(pipeline, op);
1395
+ if (steps.length > 0) wrapped[op] = createPipelineHandler(ctrl[op].bind(ctrl), steps, op, resourceName);
1396
+ }
1397
+ handlers = {
1398
+ ...createCrudHandlers(ctrl),
1399
+ ...wrapped
1400
+ };
1401
+ } else handlers = createCrudHandlers(ctrl);
1402
+ }
1403
+ if (!disableDefaultRoutes && handlers) {
1404
+ if (!disabledRoutes.includes("list")) {
1405
+ const listPreHandler = [
1406
+ arcDecorator,
1407
+ buildAuthMiddleware(fastify, permissions.list),
1408
+ buildPermissionMiddleware(permissions.list, resourceName, "list"),
1409
+ cacheMw,
1410
+ ...mw.list
1411
+ ].filter(Boolean);
1412
+ fastify.route({
1413
+ method: "GET",
1414
+ url: "/",
1415
+ schema: buildSchema({
1416
+ tags: [tag],
1417
+ summary: `List ${tag}`
1418
+ }, defaultSchemas.list, schemas.list),
1419
+ preHandler: listPreHandler.length > 0 ? listPreHandler : void 0,
1420
+ handler: handlers.list,
1421
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
1422
+ });
1423
+ }
1424
+ if (!disabledRoutes.includes("get")) {
1425
+ const getPreHandler = [
1426
+ arcDecorator,
1427
+ buildAuthMiddleware(fastify, permissions.get),
1428
+ buildPermissionMiddleware(permissions.get, resourceName, "get"),
1429
+ cacheMw,
1430
+ ...mw.get
1431
+ ].filter(Boolean);
1432
+ fastify.route({
1433
+ method: "GET",
1434
+ url: "/:id",
1435
+ schema: buildSchema({
1436
+ tags: [tag],
1437
+ summary: `Get ${tag} by ID`,
1438
+ params: idParamsSchema
1439
+ }, defaultSchemas.get, schemas.get),
1440
+ preHandler: getPreHandler.length > 0 ? getPreHandler : void 0,
1441
+ handler: handlers.get,
1442
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
1443
+ });
1444
+ }
1445
+ if (!disabledRoutes.includes("create")) {
1446
+ const createPreHandler = [
1447
+ arcDecorator,
1448
+ buildAuthMiddleware(fastify, permissions.create),
1449
+ buildPermissionMiddleware(permissions.create, resourceName, "create"),
1450
+ idempotencyMw,
1451
+ ...mw.create
1452
+ ].filter(Boolean);
1453
+ fastify.route({
1454
+ method: "POST",
1455
+ url: "/",
1456
+ schema: buildSchema({
1457
+ tags: [tag],
1458
+ summary: `Create ${tag}`
1459
+ }, defaultSchemas.create, schemas.create),
1460
+ preHandler: createPreHandler.length > 0 ? createPreHandler : void 0,
1461
+ handler: handlers.create,
1462
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
1463
+ });
1464
+ }
1465
+ if (!disabledRoutes.includes("update")) {
1466
+ const updateMethods = updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod];
1467
+ const updatePreHandler = [
1468
+ arcDecorator,
1469
+ buildAuthMiddleware(fastify, permissions.update),
1470
+ buildPermissionMiddleware(permissions.update, resourceName, "update"),
1471
+ idempotencyMw,
1472
+ ...mw.update
1473
+ ].filter(Boolean);
1474
+ for (const method of updateMethods) fastify.route({
1475
+ method,
1476
+ url: "/:id",
1477
+ schema: buildSchema({
1478
+ tags: [tag],
1479
+ summary: `${method === "PUT" ? "Replace" : "Update"} ${tag}`,
1480
+ params: idParamsSchema
1481
+ }, defaultSchemas.update, schemas.update),
1482
+ preHandler: updatePreHandler.length > 0 ? updatePreHandler : void 0,
1483
+ handler: handlers.update,
1484
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
1485
+ });
1486
+ }
1487
+ if (!disabledRoutes.includes("delete")) {
1488
+ const deletePreHandler = [
1489
+ arcDecorator,
1490
+ buildAuthMiddleware(fastify, permissions.delete),
1491
+ buildPermissionMiddleware(permissions.delete, resourceName, "delete"),
1492
+ ...mw.delete
1493
+ ].filter(Boolean);
1494
+ fastify.route({
1495
+ method: "DELETE",
1496
+ url: "/:id",
1497
+ schema: buildSchema({
1498
+ tags: [tag],
1499
+ summary: `Delete ${tag}`,
1500
+ params: idParamsSchema
1501
+ }, defaultSchemas.delete, schemas.delete),
1502
+ preHandler: deletePreHandler.length > 0 ? deletePreHandler : void 0,
1503
+ handler: handlers.delete,
1504
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
1505
+ });
1506
+ }
1507
+ }
1508
+ if (additionalRoutes.length > 0) createAdditionalRoutes(fastify, additionalRoutes, controller, {
1509
+ tag,
1510
+ resourceName,
1511
+ arcDecorator,
1512
+ rateLimitConfig,
1513
+ cacheMw,
1514
+ idempotencyMw,
1515
+ pipeline
1516
+ });
1517
+ }
1518
+ /**
1519
+ * Create permission middleware from PermissionCheck
1520
+ * Useful for custom route registration
1521
+ */
1522
+ function createPermissionMiddleware(permission, resourceName, action) {
1523
+ return buildPermissionMiddleware(permission, resourceName, action);
1524
+ }
1525
+
1526
+ //#endregion
1527
+ //#region src/core/createActionRouter.ts
1528
+ /**
1529
+ * Create action-based state transition endpoint
1530
+ *
1531
+ * Registers: POST /:id/action
1532
+ * Body: { action: string, ...actionData }
1533
+ *
1534
+ * @param fastify - Fastify instance
1535
+ * @param config - Action router configuration
1536
+ */
1537
+ function createActionRouter(fastify, config) {
1538
+ const { tag, actions, actionPermissions = {}, actionSchemas = {}, globalAuth, idempotencyService, onError } = config;
1539
+ const actionEnum = Object.keys(actions);
1540
+ if (actionEnum.length === 0) {
1541
+ fastify.log.warn("[createActionRouter] No actions defined, skipping route creation");
1542
+ return;
1543
+ }
1544
+ const bodyProperties = { action: {
1545
+ type: "string",
1546
+ enum: actionEnum,
1547
+ description: `Action to perform: ${actionEnum.join(" | ")}`
1548
+ } };
1549
+ Object.entries(actionSchemas).forEach(([actionName, schema]) => {
1550
+ if (schema && typeof schema === "object") Object.entries(schema).forEach(([propName, propSchema]) => {
1551
+ bodyProperties[propName] = {
1552
+ ...propSchema,
1553
+ description: `${propSchema.description || ""} (for ${actionName} action)`.trim()
1554
+ };
1555
+ });
1556
+ });
1557
+ const routeSchema = {
1558
+ tags: tag ? [tag] : void 0,
1559
+ summary: `Perform action (${actionEnum.join("/")})`,
1560
+ description: buildActionDescription(actions, actionPermissions),
1561
+ params: {
1562
+ type: "object",
1563
+ properties: { id: {
1564
+ type: "string",
1565
+ description: "Resource ID"
1566
+ } },
1567
+ required: ["id"]
1568
+ },
1569
+ body: {
1570
+ type: "object",
1571
+ properties: bodyProperties,
1572
+ required: ["action"]
1573
+ },
1574
+ response: {
1575
+ 200: {
1576
+ type: "object",
1577
+ properties: {
1578
+ success: { type: "boolean" },
1579
+ data: { type: "object" }
1580
+ }
1581
+ },
1582
+ 400: {
1583
+ type: "object",
1584
+ properties: {
1585
+ success: { type: "boolean" },
1586
+ error: { type: "string" }
1587
+ }
1588
+ },
1589
+ 403: {
1590
+ type: "object",
1591
+ properties: {
1592
+ success: { type: "boolean" },
1593
+ error: { type: "string" }
1594
+ }
1595
+ }
1596
+ }
1597
+ };
1598
+ const preHandler = [];
1599
+ const hasPublicActions = Object.entries(actionPermissions).some(([, p]) => p?._isPublic) || globalAuth && globalAuth?._isPublic;
1600
+ const hasProtectedActions = Object.entries(actionPermissions).some(([, p]) => !p?._isPublic) || globalAuth && !globalAuth?._isPublic;
1601
+ if (hasProtectedActions && !hasPublicActions && fastify.authenticate) preHandler.push(fastify.authenticate);
1602
+ fastify.post("/:id/action", {
1603
+ schema: routeSchema,
1604
+ preHandler: preHandler.length ? preHandler : void 0
1605
+ }, async (req, reply) => {
1606
+ const { action, ...data } = req.body;
1607
+ const { id } = req.params;
1608
+ const rawIdempotencyKey = req.headers["idempotency-key"];
1609
+ const idempotencyKey = Array.isArray(rawIdempotencyKey) ? rawIdempotencyKey[0] : rawIdempotencyKey;
1610
+ const handler = actions[action];
1611
+ if (!handler) return reply.code(400).send({
1612
+ success: false,
1613
+ error: `Invalid action '${action}'. Valid actions: ${actionEnum.join(", ")}`,
1614
+ validActions: actionEnum
1615
+ });
1616
+ const permissionCheck = actionPermissions[action] ?? globalAuth;
1617
+ if (hasPublicActions && hasProtectedActions && permissionCheck) {
1618
+ if (!permissionCheck?._isPublic && fastify.authenticate) {
1619
+ try {
1620
+ await fastify.authenticate(req, reply);
1621
+ } catch {
1622
+ if (!reply.sent) return reply.code(401).send({
1623
+ success: false,
1624
+ error: "Authentication required"
1625
+ });
1626
+ return;
1627
+ }
1628
+ if (reply.sent) return;
1629
+ }
1630
+ }
1631
+ if (permissionCheck) {
1632
+ const context = {
1633
+ user: req.user ?? null,
1634
+ request: req,
1635
+ resource: tag ?? "action",
1636
+ action,
1637
+ resourceId: id,
1638
+ params: req.params,
1639
+ data
1640
+ };
1641
+ let result;
1642
+ try {
1643
+ result = await permissionCheck(context);
1644
+ } catch (err) {
1645
+ req.log?.warn?.({
1646
+ err,
1647
+ resource: tag ?? "action",
1648
+ action
1649
+ }, "Permission check threw");
1650
+ return reply.code(403).send({
1651
+ success: false,
1652
+ error: "Permission denied"
1653
+ });
1654
+ }
1655
+ if (typeof result === "boolean") {
1656
+ if (!result) return reply.code(context.user ? 403 : 401).send({
1657
+ success: false,
1658
+ error: context.user ? `Permission denied for '${action}'` : "Authentication required"
1659
+ });
1660
+ } else {
1661
+ const permResult = result;
1662
+ if (!permResult.granted) return reply.code(context.user ? 403 : 401).send({
1663
+ success: false,
1664
+ error: permResult.reason ?? (context.user ? `Permission denied for '${action}'` : "Authentication required")
1665
+ });
1666
+ }
1667
+ }
1668
+ try {
1669
+ if (idempotencyKey && idempotencyService) {
1670
+ const user = req.user;
1671
+ const payloadForHash = {
1672
+ action,
1673
+ id,
1674
+ data,
1675
+ userId: (user?._id)?.toString?.() || user?.id || null
1676
+ };
1677
+ const idempotencyResult = await idempotencyService.check(idempotencyKey, payloadForHash);
1678
+ if (!idempotencyResult.isNew && "existingResult" in idempotencyResult) return reply.send({
1679
+ success: true,
1680
+ data: idempotencyResult.existingResult,
1681
+ cached: true
1682
+ });
1683
+ }
1684
+ const result = await handler(id, data, req);
1685
+ if (idempotencyService) await idempotencyService.complete(idempotencyKey, result);
1686
+ return reply.send({
1687
+ success: true,
1688
+ data: result
1689
+ });
1690
+ } catch (error) {
1691
+ if (idempotencyService) await idempotencyService.fail(idempotencyKey, error);
1692
+ if (onError) {
1693
+ const { statusCode, error: errorMsg, code } = onError(error, action, id);
1694
+ return reply.code(statusCode).send({
1695
+ success: false,
1696
+ error: errorMsg,
1697
+ code
1698
+ });
1699
+ }
1700
+ const err = error;
1701
+ const statusCode = err.statusCode || err.status || 500;
1702
+ const errorCode = err.code || "ACTION_FAILED";
1703
+ if (statusCode >= 500) req.log.error({
1704
+ err: error,
1705
+ action,
1706
+ id
1707
+ }, "Action handler error");
1708
+ return reply.code(statusCode).send({
1709
+ success: false,
1710
+ error: err.message || `Failed to execute '${action}' action`,
1711
+ code: errorCode
1712
+ });
1713
+ }
1714
+ });
1715
+ fastify.log.debug({
1716
+ actions: actionEnum,
1717
+ tag
1718
+ }, "[createActionRouter] Registered action endpoint: POST /:id/action");
1719
+ }
1720
+ /**
1721
+ * Build description with action details
1722
+ * Uses _roles metadata from PermissionCheck functions for OpenAPI docs
1723
+ */
1724
+ function buildActionDescription(actions, actionPermissions) {
1725
+ const lines = ["Unified action endpoint for state transitions.\n\n**Available actions:**"];
1726
+ Object.keys(actions).forEach((action) => {
1727
+ const roles = actionPermissions[action]?._roles;
1728
+ const roleStr = roles?.length ? ` (requires: ${roles.join(" or ")})` : "";
1729
+ lines.push(`- \`${action}\`${roleStr}`);
1730
+ });
1731
+ return lines.join("\n");
1732
+ }
1733
+
1734
+ //#endregion
1735
+ //#region src/core/validateResourceConfig.ts
1736
+ /**
1737
+ * Validate a resource configuration
1738
+ */
1739
+ function validateResourceConfig(config, options = {}) {
1740
+ const errors = [];
1741
+ const warnings = [];
1742
+ if (!config.name) errors.push({
1743
+ field: "name",
1744
+ message: "Resource name is required",
1745
+ suggestion: "Add a unique resource name (e.g., \"product\", \"user\")"
1746
+ });
1747
+ else if (!/^[a-z][a-z0-9-]*$/i.test(config.name)) errors.push({
1748
+ field: "name",
1749
+ message: `Invalid resource name "${config.name}"`,
1750
+ suggestion: "Use alphanumeric characters and hyphens, starting with a letter"
1751
+ });
1752
+ const crudRoutes = CRUD_OPERATIONS;
1753
+ const disabledRoutes = new Set(config.disabledRoutes ?? []);
1754
+ const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
1755
+ if (!config.disableDefaultRoutes && enabledCrudRoutes.length > 0) {
1756
+ if (!config.adapter) errors.push({
1757
+ field: "adapter",
1758
+ message: "Data adapter is required when CRUD routes are enabled",
1759
+ suggestion: "Provide an adapter: createMongooseAdapter({ model, repository })"
1760
+ });
1761
+ else if (!config.adapter.repository) errors.push({
1762
+ field: "adapter.repository",
1763
+ message: "Adapter must provide a repository",
1764
+ suggestion: "Ensure your adapter returns a valid CrudRepository"
1765
+ });
1766
+ } else if (!config.adapter && !config.additionalRoutes?.length) warnings.push({
1767
+ field: "config",
1768
+ message: "Resource has no adapter and no additionalRoutes",
1769
+ suggestion: "Provide either adapter for CRUD or additionalRoutes for custom logic"
1770
+ });
1771
+ if (config.controller && !options.skipControllerCheck && !config.disableDefaultRoutes) {
1772
+ const ctrl = config.controller;
1773
+ const requiredMethods = CRUD_OPERATIONS;
1774
+ for (const method of requiredMethods) if (typeof ctrl[method] !== "function") errors.push({
1775
+ field: `controller.${method}`,
1776
+ message: `Missing required CRUD method "${method}"`,
1777
+ suggestion: "Extend BaseController which implements IController interface"
1778
+ });
1779
+ }
1780
+ if (config.controller && config.additionalRoutes) validateAdditionalRouteHandlers(config.controller, config.additionalRoutes, errors);
1781
+ if (config.permissions) validatePermissionKeys(config, options, errors, warnings);
1782
+ if (config.presets && !options.allowUnknownPresets) validatePresets(config.presets, errors, warnings);
1783
+ if (config.prefix) {
1784
+ if (!config.prefix.startsWith("/")) errors.push({
1785
+ field: "prefix",
1786
+ message: `Prefix must start with "/" (got "${config.prefix}")`,
1787
+ suggestion: `Change to "/${config.prefix}"`
1788
+ });
1789
+ if (config.prefix.endsWith("/") && config.prefix !== "/") warnings.push({
1790
+ field: "prefix",
1791
+ message: `Prefix should not end with "/" (got "${config.prefix}")`,
1792
+ suggestion: `Change to "${config.prefix.slice(0, -1)}"`
1793
+ });
1794
+ }
1795
+ if (config.additionalRoutes) validateAdditionalRoutes(config.additionalRoutes, errors);
1796
+ return {
1797
+ valid: errors.length === 0,
1798
+ errors,
1799
+ warnings
1800
+ };
1801
+ }
1802
+ function validateAdditionalRouteHandlers(controller, routes, errors) {
1803
+ const ctrl = controller;
1804
+ for (const route of routes) if (typeof route.handler === "string") {
1805
+ if (typeof ctrl[route.handler] !== "function") errors.push({
1806
+ field: `additionalRoutes[${route.method} ${route.path}]`,
1807
+ message: `Handler "${route.handler}" not found on controller`,
1808
+ suggestion: `Add method "${route.handler}" to controller or use a function handler`
1809
+ });
1810
+ }
1811
+ }
1812
+ function validatePermissionKeys(config, options, errors, warnings) {
1813
+ const validKeys = new Set([...CRUD_OPERATIONS, ...options.additionalPermissionKeys ?? []]);
1814
+ for (const route of config.additionalRoutes ?? []) if (typeof route.handler === "string") validKeys.add(route.handler);
1815
+ for (const preset of config.presets ?? []) {
1816
+ const presetName = typeof preset === "string" ? preset : preset.name;
1817
+ if (presetName === "softDelete") {
1818
+ validKeys.add("deleted");
1819
+ validKeys.add("restore");
1820
+ }
1821
+ if (presetName === "slugLookup") validKeys.add("getBySlug");
1822
+ if (presetName === "tree") {
1823
+ validKeys.add("tree");
1824
+ validKeys.add("children");
1825
+ validKeys.add("getTree");
1826
+ validKeys.add("getChildren");
1827
+ }
1828
+ }
1829
+ for (const key of Object.keys(config.permissions ?? {})) if (!validKeys.has(key)) warnings.push({
1830
+ field: `permissions.${key}`,
1831
+ message: `Unknown permission key "${key}"`,
1832
+ suggestion: `Valid keys: ${Array.from(validKeys).join(", ")}`
1833
+ });
1834
+ }
1835
+ function validatePresets(presets, errors, warnings) {
1836
+ const availablePresets = getAvailablePresets();
1837
+ for (const preset of presets) {
1838
+ if (typeof preset === "object" && ("middlewares" in preset || "additionalRoutes" in preset)) continue;
1839
+ const presetName = typeof preset === "string" ? preset : preset.name;
1840
+ if (!availablePresets.includes(presetName)) errors.push({
1841
+ field: "presets",
1842
+ message: `Unknown preset "${presetName}"`,
1843
+ suggestion: `Available presets: ${availablePresets.join(", ")}`
1844
+ });
1845
+ if (typeof preset === "object") validatePresetOptions(preset, warnings);
1846
+ }
1847
+ }
1848
+ function validatePresetOptions(preset, warnings) {
1849
+ const validOptions = {
1850
+ slugLookup: ["slugField"],
1851
+ tree: ["parentField"],
1852
+ softDelete: ["deletedField"],
1853
+ ownedByUser: ["ownerField"],
1854
+ multiTenant: ["tenantField", "allowPublic"]
1855
+ }[preset.name] ?? [];
1856
+ const providedOptions = Object.keys(preset).filter((k) => k !== "name");
1857
+ for (const opt of providedOptions) if (!validOptions.includes(opt)) warnings.push({
1858
+ field: `presets[${preset.name}].${opt}`,
1859
+ message: `Unknown option "${opt}" for preset "${preset.name}"`,
1860
+ suggestion: validOptions.length > 0 ? `Valid options: ${validOptions.join(", ")}` : `Preset "${preset.name}" has no configurable options`
1861
+ });
1862
+ }
1863
+ function validateAdditionalRoutes(routes, errors) {
1864
+ const validMethods = [
1865
+ "GET",
1866
+ "POST",
1867
+ "PUT",
1868
+ "PATCH",
1869
+ "DELETE",
1870
+ "OPTIONS",
1871
+ "HEAD"
1872
+ ];
1873
+ const seenRoutes = /* @__PURE__ */ new Set();
1874
+ for (const [i, route] of routes.entries()) {
1875
+ if (!validMethods.includes(route.method)) errors.push({
1876
+ field: `additionalRoutes[${i}].method`,
1877
+ message: `Invalid HTTP method "${route.method}"`,
1878
+ suggestion: `Valid methods: ${validMethods.join(", ")}`
1879
+ });
1880
+ if (!route.path) errors.push({
1881
+ field: `additionalRoutes[${i}].path`,
1882
+ message: "Route path is required"
1883
+ });
1884
+ else if (!route.path.startsWith("/")) errors.push({
1885
+ field: `additionalRoutes[${i}].path`,
1886
+ message: `Route path must start with "/" (got "${route.path}")`,
1887
+ suggestion: `Change to "/${route.path}"`
1888
+ });
1889
+ if (!route.handler) errors.push({
1890
+ field: `additionalRoutes[${i}].handler`,
1891
+ message: "Route handler is required"
1892
+ });
1893
+ const routeKey = `${route.method} ${route.path}`;
1894
+ if (seenRoutes.has(routeKey)) errors.push({
1895
+ field: `additionalRoutes[${i}]`,
1896
+ message: `Duplicate route "${routeKey}"`
1897
+ });
1898
+ seenRoutes.add(routeKey);
1899
+ }
1900
+ }
1901
+ /**
1902
+ * Format validation errors for display
1903
+ */
1904
+ function formatValidationErrors(resourceName, result) {
1905
+ const lines = [];
1906
+ if (result.errors.length > 0) {
1907
+ lines.push(`Resource "${resourceName}" validation failed:`);
1908
+ lines.push("");
1909
+ lines.push("ERRORS:");
1910
+ for (const err of result.errors) {
1911
+ lines.push(` ✗ ${err.field}: ${err.message}`);
1912
+ if (err.suggestion) lines.push(` → ${err.suggestion}`);
1913
+ }
1914
+ }
1915
+ if (result.warnings.length > 0) {
1916
+ if (lines.length > 0) lines.push("");
1917
+ lines.push("WARNINGS:");
1918
+ for (const warn of result.warnings) {
1919
+ lines.push(` ⚠ ${warn.field}: ${warn.message}`);
1920
+ if (warn.suggestion) lines.push(` → ${warn.suggestion}`);
1921
+ }
1922
+ }
1923
+ return lines.join("\n");
1924
+ }
1925
+ /**
1926
+ * Validate and throw if invalid
1927
+ */
1928
+ function assertValidConfig(config, options) {
1929
+ const result = validateResourceConfig(config, options);
1930
+ if (!result.valid) {
1931
+ const errorMsg = formatValidationErrors(config.name ?? "unknown", result);
1932
+ throw new Error(errorMsg);
1933
+ }
1934
+ if (result.warnings.length > 0 && process.env.NODE_ENV !== "production") console.warn(formatValidationErrors(config.name ?? "unknown", {
1935
+ valid: true,
1936
+ errors: [],
1937
+ warnings: result.warnings
1938
+ }));
1939
+ }
1940
+
1941
+ //#endregion
1942
+ //#region src/core/defineResource.ts
1943
+ /**
1944
+ * Define a resource with database adapter
1945
+ *
1946
+ * This is the MAIN entry point for creating Arc resources.
1947
+ * The adapter provides both repository and schema metadata.
1948
+ */
1949
+ function defineResource(config) {
1950
+ if (!config.skipValidation) {
1951
+ assertValidConfig(config, { skipControllerCheck: true });
1952
+ if (config.permissions) {
1953
+ for (const [key, value] of Object.entries(config.permissions)) if (value !== void 0 && typeof value !== "function") throw new Error(`[Arc] Resource '${config.name}': permissions.${key} must be a PermissionCheck function.\nUse allowPublic(), requireAuth(), or requireRoles(['role']) from @classytic/arc/permissions.`);
1954
+ }
1955
+ for (const route of config.additionalRoutes ?? []) {
1956
+ if (typeof route.permissions !== "function") throw new Error(`[Arc] Resource '${config.name}' route ${route.method} ${route.path}: permissions is required and must be a PermissionCheck function.\nUse allowPublic() or requireAuth() from @classytic/arc/permissions.`);
1957
+ if (typeof route.wrapHandler !== "boolean") throw new Error(`[Arc] Resource '${config.name}' route ${route.method} ${route.path}: wrapHandler is required.\nSet true for ControllerHandler (context object) or false for FastifyHandler (req, reply).`);
1958
+ }
1959
+ }
1960
+ const repository = config.adapter?.repository;
1961
+ const crudRoutes = CRUD_OPERATIONS;
1962
+ const disabledRoutes = new Set(config.disabledRoutes ?? []);
1963
+ const hasCrudRoutes = !config.disableDefaultRoutes && crudRoutes.some((route) => !disabledRoutes.has(route));
1964
+ const originalPresets = (config.presets ?? []).map((p) => typeof p === "string" ? p : p.name);
1965
+ const resolvedConfig = config.presets?.length ? applyPresets(config, config.presets) : config;
1966
+ resolvedConfig._appliedPresets = originalPresets;
1967
+ let controller = resolvedConfig.controller;
1968
+ if (!controller && hasCrudRoutes && repository) controller = new BaseController(repository, {
1969
+ resourceName: resolvedConfig.name,
1970
+ schemaOptions: resolvedConfig.schemaOptions,
1971
+ queryParser: resolvedConfig.queryParser,
1972
+ tenantField: resolvedConfig.tenantField,
1973
+ idField: resolvedConfig.idField,
1974
+ matchesFilter: config.adapter?.matchesFilter,
1975
+ cache: resolvedConfig.cache,
1976
+ presetFields: resolvedConfig._controllerOptions ? {
1977
+ slugField: resolvedConfig._controllerOptions.slugField,
1978
+ parentField: resolvedConfig._controllerOptions.parentField
1979
+ } : void 0
1980
+ });
1981
+ const resource = new ResourceDefinition({
1982
+ ...resolvedConfig,
1983
+ adapter: config.adapter,
1984
+ controller
1985
+ });
1986
+ if (!config.skipValidation && controller) resource._validateControllerMethods();
1987
+ if (resolvedConfig._hooks?.length) resource._pendingHooks.push(...resolvedConfig._hooks.map((hook) => ({
1988
+ operation: hook.operation,
1989
+ phase: hook.phase,
1990
+ handler: hook.handler,
1991
+ priority: hook.priority ?? 10
1992
+ })));
1993
+ if (!config.skipRegistry) try {
1994
+ let openApiSchemas = config.openApiSchemas;
1995
+ if (!openApiSchemas && config.adapter?.generateSchemas) {
1996
+ const generated = config.adapter.generateSchemas(config.schemaOptions);
1997
+ if (generated) openApiSchemas = generated;
1998
+ }
1999
+ const queryParser = config.queryParser;
2000
+ if (!openApiSchemas?.listQuery && queryParser?.getQuerySchema) {
2001
+ const querySchema = queryParser.getQuerySchema();
2002
+ if (querySchema) openApiSchemas = {
2003
+ ...openApiSchemas,
2004
+ listQuery: querySchema
2005
+ };
2006
+ }
2007
+ if (openApiSchemas) openApiSchemas = convertOpenApiSchemas(openApiSchemas);
2008
+ resource._registryMeta = {
2009
+ module: config.module,
2010
+ openApiSchemas
2011
+ };
2012
+ } catch {}
2013
+ return resource;
2014
+ }
2015
+ var ResourceDefinition = class {
2016
+ name;
2017
+ displayName;
2018
+ tag;
2019
+ prefix;
2020
+ adapter;
2021
+ controller;
2022
+ schemaOptions;
2023
+ customSchemas;
2024
+ permissions;
2025
+ additionalRoutes;
2026
+ middlewares;
2027
+ disableDefaultRoutes;
2028
+ disabledRoutes;
2029
+ events;
2030
+ rateLimit;
2031
+ updateMethod;
2032
+ pipe;
2033
+ fields;
2034
+ cache;
2035
+ _appliedPresets;
2036
+ _pendingHooks;
2037
+ _registryMeta;
2038
+ constructor(config) {
2039
+ this.name = config.name;
2040
+ this.displayName = config.displayName ?? capitalize(config.name) + "s";
2041
+ this.tag = config.tag ?? this.displayName;
2042
+ this.prefix = config.prefix ?? `/${config.name}s`;
2043
+ this.adapter = config.adapter;
2044
+ this.controller = config.controller;
2045
+ this.schemaOptions = config.schemaOptions ?? {};
2046
+ this.customSchemas = config.customSchemas ?? {};
2047
+ this.permissions = config.permissions ?? {};
2048
+ this.additionalRoutes = config.additionalRoutes ?? [];
2049
+ this.middlewares = config.middlewares ?? {};
2050
+ this.disableDefaultRoutes = config.disableDefaultRoutes ?? false;
2051
+ this.disabledRoutes = config.disabledRoutes ?? [];
2052
+ this.events = config.events ?? {};
2053
+ this.rateLimit = config.rateLimit;
2054
+ this.updateMethod = config.updateMethod;
2055
+ this.pipe = config.pipe;
2056
+ this.fields = config.fields;
2057
+ this.cache = config.cache;
2058
+ this._appliedPresets = config._appliedPresets ?? [];
2059
+ this._pendingHooks = config._pendingHooks ?? [];
2060
+ }
2061
+ /** Get repository from adapter (if available) */
2062
+ get repository() {
2063
+ return this.adapter?.repository;
2064
+ }
2065
+ _validateControllerMethods() {
2066
+ const errors = [];
2067
+ const crudRoutes = CRUD_OPERATIONS;
2068
+ const disabledRoutes = new Set(this.disabledRoutes ?? []);
2069
+ const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
2070
+ if (!this.disableDefaultRoutes && enabledCrudRoutes.length > 0) if (!this.controller) errors.push("Controller is required when CRUD routes are enabled");
2071
+ else {
2072
+ const ctrl = this.controller;
2073
+ for (const method of enabledCrudRoutes) if (typeof ctrl[method] !== "function") errors.push(`CRUD method '${method}' not found on controller`);
2074
+ }
2075
+ for (const route of this.additionalRoutes) if (typeof route.handler === "string") {
2076
+ if (!this.controller) errors.push(`Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller`);
2077
+ else if (typeof this.controller[route.handler] !== "function") errors.push(`Route ${route.method} ${route.path}: handler '${route.handler}' not found`);
2078
+ }
2079
+ if (errors.length > 0) {
2080
+ const errorMsg = [
2081
+ `Resource '${this.name}' validation failed:`,
2082
+ ...errors.map((e) => ` - ${e}`),
2083
+ "",
2084
+ "Ensure controller implements IController<TDoc> interface.",
2085
+ "For preset routes (softDelete, tree), add corresponding methods to controller."
2086
+ ].join("\n");
2087
+ throw new Error(errorMsg);
2088
+ }
2089
+ }
2090
+ toPlugin() {
2091
+ const self = this;
2092
+ return async function resourcePlugin(fastify, _opts) {
2093
+ const arc = fastify.arc;
2094
+ if (arc?.registry && self._registryMeta) try {
2095
+ arc.registry.register(self, self._registryMeta);
2096
+ } catch (err) {
2097
+ fastify.log?.warn?.(`Failed to register resource '${self.name}' in registry: ${err instanceof Error ? err.message : err}`);
2098
+ }
2099
+ if (self._pendingHooks.length > 0) {
2100
+ const arc = fastify.arc;
2101
+ if (arc?.hooks) for (const hook of self._pendingHooks) arc.hooks.register({
2102
+ resource: self.name,
2103
+ operation: hook.operation,
2104
+ phase: hook.phase,
2105
+ handler: hook.handler,
2106
+ priority: hook.priority
2107
+ });
2108
+ }
2109
+ const registerRule = fastify.registerCacheInvalidationRule;
2110
+ if (self.cache?.invalidateOn && typeof registerRule === "function") for (const [pattern, tags] of Object.entries(self.cache.invalidateOn)) registerRule({
2111
+ pattern,
2112
+ tags
2113
+ });
2114
+ await fastify.register(async (instance) => {
2115
+ const typedInstance = instance;
2116
+ let schemas = null;
2117
+ if (self.customSchemas && Object.keys(self.customSchemas).length > 0) {
2118
+ schemas = schemas ?? {};
2119
+ for (const [op, customSchema] of Object.entries(self.customSchemas)) {
2120
+ const key = op;
2121
+ const converted = convertRouteSchema(customSchema);
2122
+ schemas[key] = schemas[key] ? deepMergeSchemas(schemas[key], converted) : converted;
2123
+ }
2124
+ }
2125
+ const resolvedRoutes = self.additionalRoutes;
2126
+ createCrudRouter(typedInstance, self.controller, {
2127
+ tag: self.tag,
2128
+ schemas: schemas ?? void 0,
2129
+ permissions: self.permissions,
2130
+ middlewares: self.middlewares,
2131
+ additionalRoutes: resolvedRoutes,
2132
+ disableDefaultRoutes: self.disableDefaultRoutes,
2133
+ disabledRoutes: self.disabledRoutes,
2134
+ resourceName: self.name,
2135
+ schemaOptions: self.schemaOptions,
2136
+ rateLimit: self.rateLimit,
2137
+ updateMethod: self.updateMethod,
2138
+ pipe: self.pipe,
2139
+ fields: self.fields
2140
+ });
2141
+ if (self.events && Object.keys(self.events).length > 0) typedInstance.log?.debug?.(`Resource '${self.name}' defined ${Object.keys(self.events).length} events`);
2142
+ }, { prefix: self.prefix });
2143
+ if (hasEvents(fastify)) try {
2144
+ await fastify.events.publish("arc.resource.registered", {
2145
+ resource: self.name,
2146
+ prefix: self.prefix,
2147
+ presets: self._appliedPresets,
2148
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2149
+ });
2150
+ } catch {}
2151
+ };
2152
+ }
2153
+ /**
2154
+ * Get event definitions for registry
2155
+ */
2156
+ getEvents() {
2157
+ return Object.entries(this.events).map(([action, meta]) => ({
2158
+ name: `${this.name}:${action}`,
2159
+ module: this.name,
2160
+ schema: meta.schema,
2161
+ description: meta.description
2162
+ }));
2163
+ }
2164
+ /**
2165
+ * Get resource metadata
2166
+ */
2167
+ getMetadata() {
2168
+ return {
2169
+ name: this.name,
2170
+ displayName: this.displayName,
2171
+ tag: this.tag,
2172
+ prefix: this.prefix,
2173
+ presets: this._appliedPresets,
2174
+ permissions: this.permissions,
2175
+ additionalRoutes: this.additionalRoutes,
2176
+ routes: [],
2177
+ events: Object.keys(this.events)
2178
+ };
2179
+ }
2180
+ };
2181
+ function deepMergeSchemas(base, override) {
2182
+ if (!override) return base;
2183
+ if (!base) return override;
2184
+ const result = { ...base };
2185
+ for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [...new Set([...result[key], ...value])];
2186
+ else if (value && typeof value === "object" && !Array.isArray(value)) result[key] = deepMergeSchemas(result[key], value);
2187
+ else result[key] = value;
2188
+ return result;
2189
+ }
2190
+ function capitalize(str) {
2191
+ if (!str) return "";
2192
+ return str.charAt(0).toUpperCase() + str.slice(1);
2193
+ }
2194
+
2195
+ //#endregion
2196
+ export { QueryResolver as _, validateResourceConfig as a, createPermissionMiddleware as c, createFastifyHandler as d, createRequestContext as f, BaseController as g, sendControllerResponse as h, formatValidationErrors as i, pipe as l, getControllerScope as m, defineResource as n, createActionRouter as o, getControllerContext as p, assertValidConfig as r, createCrudRouter as s, ResourceDefinition as t, createCrudHandlers as u, BodySanitizer as v, AccessControl as y };
2197
+ //# sourceMappingURL=defineResource-k0_BDn8v.mjs.map