@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
@@ -1,2786 +0,0 @@
1
- // src/types/index.ts
2
- function getUserId(user) {
3
- if (!user) return void 0;
4
- const id = user.id ?? user._id;
5
- return id ? String(id) : void 0;
6
- }
7
-
8
- // src/hooks/HookSystem.ts
9
- var HookSystem = class {
10
- hooks;
11
- logger;
12
- constructor(options) {
13
- this.hooks = /* @__PURE__ */ new Map();
14
- this.logger = options?.logger ?? { error: (...args) => console.error(...args) };
15
- }
16
- /**
17
- * Generate hook key
18
- */
19
- getKey(resource, operation, phase) {
20
- return `${resource}:${operation}:${phase}`;
21
- }
22
- /**
23
- * Register a hook
24
- * Supports both object parameter and positional arguments
25
- */
26
- register(resourceOrOptions, operation, phase, handler, priority = 10) {
27
- let resource;
28
- let finalOperation;
29
- let finalPhase;
30
- let finalHandler;
31
- let finalPriority;
32
- if (typeof resourceOrOptions === "object") {
33
- resource = resourceOrOptions.resource;
34
- finalOperation = resourceOrOptions.operation;
35
- finalPhase = resourceOrOptions.phase;
36
- finalHandler = resourceOrOptions.handler;
37
- finalPriority = resourceOrOptions.priority ?? 10;
38
- } else {
39
- resource = resourceOrOptions;
40
- finalOperation = operation;
41
- finalPhase = phase;
42
- finalHandler = handler;
43
- finalPriority = priority;
44
- }
45
- const key = this.getKey(resource, finalOperation, finalPhase);
46
- if (!this.hooks.has(key)) {
47
- this.hooks.set(key, []);
48
- }
49
- const registration = {
50
- resource,
51
- operation: finalOperation,
52
- phase: finalPhase,
53
- handler: finalHandler,
54
- priority: finalPriority
55
- };
56
- const hooks = this.hooks.get(key);
57
- hooks.push(registration);
58
- hooks.sort((a, b) => a.priority - b.priority);
59
- return () => {
60
- const idx = hooks.indexOf(registration);
61
- if (idx !== -1) {
62
- hooks.splice(idx, 1);
63
- }
64
- };
65
- }
66
- /**
67
- * Register before hook
68
- */
69
- before(resource, operation, handler, priority = 10) {
70
- return this.register(resource, operation, "before", handler, priority);
71
- }
72
- /**
73
- * Register after hook
74
- */
75
- after(resource, operation, handler, priority = 10) {
76
- return this.register(resource, operation, "after", handler, priority);
77
- }
78
- /**
79
- * Execute hooks for a given context
80
- */
81
- async execute(ctx) {
82
- const key = this.getKey(ctx.resource, ctx.operation, ctx.phase);
83
- const hooks = this.hooks.get(key) ?? [];
84
- const wildcardKey = this.getKey("*", ctx.operation, ctx.phase);
85
- const wildcardHooks = this.hooks.get(wildcardKey) ?? [];
86
- const allHooks = [...wildcardHooks, ...hooks];
87
- allHooks.sort((a, b) => a.priority - b.priority);
88
- let result = ctx.data;
89
- for (const hook of allHooks) {
90
- const handlerContext = {
91
- resource: ctx.resource,
92
- operation: ctx.operation,
93
- phase: ctx.phase,
94
- data: result,
95
- result: ctx.result,
96
- user: ctx.user,
97
- context: ctx.context,
98
- meta: ctx.meta
99
- };
100
- const hookResult = await hook.handler(handlerContext);
101
- if (hookResult !== void 0 && hookResult !== null) {
102
- result = hookResult;
103
- }
104
- }
105
- return result;
106
- }
107
- /**
108
- * Execute before hooks
109
- */
110
- async executeBefore(resource, operation, data, options) {
111
- const result = await this.execute({
112
- resource,
113
- operation,
114
- phase: "before",
115
- data,
116
- user: options?.user,
117
- context: options?.context,
118
- meta: options?.meta
119
- });
120
- return result ?? data;
121
- }
122
- /**
123
- * Execute after hooks
124
- * Errors in after hooks are logged but don't fail the request
125
- */
126
- async executeAfter(resource, operation, result, options) {
127
- try {
128
- await this.execute({
129
- resource,
130
- operation,
131
- phase: "after",
132
- result,
133
- user: options?.user,
134
- context: options?.context,
135
- meta: options?.meta
136
- });
137
- } catch (error) {
138
- this.logger.error(
139
- `[HookSystem] Error in after hook for ${resource}:${operation}:`,
140
- error
141
- );
142
- }
143
- }
144
- /**
145
- * Get all registered hooks
146
- */
147
- getAll() {
148
- const all = [];
149
- for (const hooks of this.hooks.values()) {
150
- all.push(...hooks);
151
- }
152
- return all;
153
- }
154
- /**
155
- * Get hooks for a specific resource
156
- */
157
- getForResource(resource) {
158
- const all = [];
159
- for (const [key, hooks] of this.hooks.entries()) {
160
- if (key.startsWith(`${resource}:`)) {
161
- all.push(...hooks);
162
- }
163
- }
164
- return all;
165
- }
166
- /**
167
- * Clear all hooks
168
- */
169
- clear() {
170
- this.hooks.clear();
171
- }
172
- /**
173
- * Clear hooks for a specific resource
174
- */
175
- clearResource(resource) {
176
- for (const key of this.hooks.keys()) {
177
- if (key.startsWith(`${resource}:`)) {
178
- this.hooks.delete(key);
179
- }
180
- }
181
- }
182
- };
183
- var hookSystem = new HookSystem();
184
-
185
- // src/utils/queryParser.ts
186
- var DANGEROUS_REGEX_PATTERNS = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?\:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
187
- var MAX_REGEX_LENGTH = 500;
188
- var MAX_SEARCH_LENGTH = 200;
189
- var MAX_FILTER_DEPTH = 10;
190
- var MAX_LIMIT = 1e3;
191
- var DEFAULT_LIMIT = 20;
192
- var ArcQueryParser = class {
193
- maxLimit;
194
- defaultLimit;
195
- maxRegexLength;
196
- maxSearchLength;
197
- maxFilterDepth;
198
- /** Supported filter operators */
199
- operators = {
200
- eq: "$eq",
201
- ne: "$ne",
202
- gt: "$gt",
203
- gte: "$gte",
204
- lt: "$lt",
205
- lte: "$lte",
206
- in: "$in",
207
- nin: "$nin",
208
- like: "$regex",
209
- contains: "$regex",
210
- regex: "$regex",
211
- exists: "$exists"
212
- };
213
- constructor(options = {}) {
214
- this.maxLimit = options.maxLimit ?? MAX_LIMIT;
215
- this.defaultLimit = options.defaultLimit ?? DEFAULT_LIMIT;
216
- this.maxRegexLength = options.maxRegexLength ?? MAX_REGEX_LENGTH;
217
- this.maxSearchLength = options.maxSearchLength ?? MAX_SEARCH_LENGTH;
218
- this.maxFilterDepth = options.maxFilterDepth ?? MAX_FILTER_DEPTH;
219
- }
220
- /**
221
- * Parse URL query parameters into structured query options
222
- */
223
- parse(query) {
224
- const q = query ?? {};
225
- const page = this.parseNumber(q.page, 1);
226
- const limit = Math.min(this.parseNumber(q.limit, this.defaultLimit), this.maxLimit);
227
- const after = this.parseString(q.after ?? q.cursor);
228
- const sort = this.parseSort(q.sort);
229
- const populate = this.parseString(q.populate);
230
- const search = this.parseSearch(q.search);
231
- const select = this.parseSelect(q.select);
232
- const filters = this.parseFilters(q);
233
- return {
234
- filters,
235
- limit,
236
- sort,
237
- populate,
238
- search,
239
- page: after ? void 0 : page,
240
- after,
241
- select
242
- };
243
- }
244
- // ============================================================================
245
- // Parse Helpers
246
- // ============================================================================
247
- parseNumber(value, defaultValue) {
248
- if (value === void 0 || value === null) return defaultValue;
249
- const num = parseInt(String(value), 10);
250
- return Number.isNaN(num) ? defaultValue : Math.max(1, num);
251
- }
252
- parseString(value) {
253
- if (value === void 0 || value === null) return void 0;
254
- const str = String(value).trim();
255
- return str.length > 0 ? str : void 0;
256
- }
257
- parseSort(value) {
258
- if (!value) return void 0;
259
- const sortStr = String(value);
260
- const result = {};
261
- for (const field of sortStr.split(",")) {
262
- const trimmed = field.trim();
263
- if (!trimmed) continue;
264
- if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
265
- if (trimmed.startsWith("-")) {
266
- result[trimmed.slice(1)] = -1;
267
- } else {
268
- result[trimmed] = 1;
269
- }
270
- }
271
- return Object.keys(result).length > 0 ? result : void 0;
272
- }
273
- parseSearch(value) {
274
- if (!value) return void 0;
275
- const search = String(value).trim();
276
- if (search.length === 0) return void 0;
277
- if (search.length > this.maxSearchLength) {
278
- return search.slice(0, this.maxSearchLength);
279
- }
280
- return search;
281
- }
282
- parseSelect(value) {
283
- if (!value) return void 0;
284
- const selectStr = String(value);
285
- const result = {};
286
- for (const field of selectStr.split(",")) {
287
- const trimmed = field.trim();
288
- if (!trimmed) continue;
289
- if (!/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(trimmed)) continue;
290
- if (trimmed.startsWith("-")) {
291
- result[trimmed.slice(1)] = 0;
292
- } else {
293
- result[trimmed] = 1;
294
- }
295
- }
296
- return Object.keys(result).length > 0 ? result : void 0;
297
- }
298
- parseFilters(query) {
299
- const reservedKeys = /* @__PURE__ */ new Set([
300
- "page",
301
- "limit",
302
- "sort",
303
- "populate",
304
- "search",
305
- "select",
306
- "after",
307
- "cursor",
308
- "lean",
309
- "_policyFilters"
310
- ]);
311
- const filters = {};
312
- for (const [key, value] of Object.entries(query)) {
313
- if (reservedKeys.has(key)) continue;
314
- if (value === void 0 || value === null) continue;
315
- if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(key)) continue;
316
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
317
- const operatorObj = value;
318
- const operatorKeys = Object.keys(operatorObj);
319
- const allOperators = operatorKeys.every((op) => this.operators[op]);
320
- if (allOperators && operatorKeys.length > 0) {
321
- const mongoFilters = {};
322
- for (const [op, opValue] of Object.entries(operatorObj)) {
323
- const mongoOp = this.operators[op];
324
- if (mongoOp) {
325
- mongoFilters[mongoOp] = this.parseFilterValue(opValue, op);
326
- }
327
- }
328
- filters[key] = mongoFilters;
329
- continue;
330
- }
331
- }
332
- const match = key.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)(?:\[([a-z]+)\])?$/);
333
- if (!match) continue;
334
- const [, fieldName, operator] = match;
335
- if (!fieldName) continue;
336
- if (operator && this.operators[operator]) {
337
- const mongoOp = this.operators[operator];
338
- const parsedValue = this.parseFilterValue(value, operator);
339
- if (!filters[fieldName]) {
340
- filters[fieldName] = {};
341
- }
342
- filters[fieldName][mongoOp] = parsedValue;
343
- } else if (!operator) {
344
- filters[fieldName] = this.parseFilterValue(value);
345
- }
346
- }
347
- return filters;
348
- }
349
- parseFilterValue(value, operator) {
350
- if (operator === "in" || operator === "nin") {
351
- if (Array.isArray(value)) {
352
- return value.map((v) => this.coerceValue(v));
353
- }
354
- if (typeof value === "string" && value.includes(",")) {
355
- return value.split(",").map((v) => this.coerceValue(v.trim()));
356
- }
357
- return [this.coerceValue(value)];
358
- }
359
- if (operator === "like" || operator === "contains" || operator === "regex") {
360
- return this.sanitizeRegex(String(value));
361
- }
362
- if (operator === "exists") {
363
- const str = String(value).toLowerCase();
364
- return str === "true" || str === "1";
365
- }
366
- return this.coerceValue(value);
367
- }
368
- coerceValue(value) {
369
- if (value === "true") return true;
370
- if (value === "false") return false;
371
- if (value === "null") return null;
372
- if (typeof value === "string") {
373
- const num = Number(value);
374
- if (!Number.isNaN(num) && value.trim() !== "") {
375
- return num;
376
- }
377
- }
378
- return value;
379
- }
380
- sanitizeRegex(pattern) {
381
- let sanitized = pattern.slice(0, this.maxRegexLength);
382
- if (DANGEROUS_REGEX_PATTERNS.test(sanitized)) {
383
- sanitized = sanitized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
384
- }
385
- return sanitized;
386
- }
387
- };
388
-
389
- // src/core/BaseController.ts
390
- var defaultParser = new ArcQueryParser();
391
- function getDefaultQueryParser() {
392
- return defaultParser;
393
- }
394
- var BaseController = class _BaseController {
395
- repository;
396
- schemaOptions;
397
- queryParser;
398
- maxLimit;
399
- defaultLimit;
400
- defaultSort;
401
- resourceName;
402
- disableEvents;
403
- /** Preset field names for dynamic param reading */
404
- _presetFields = {};
405
- constructor(repository, options = {}) {
406
- this.repository = repository;
407
- this.schemaOptions = options.schemaOptions ?? {};
408
- this.queryParser = options.queryParser ?? getDefaultQueryParser();
409
- this.maxLimit = options.maxLimit ?? 100;
410
- this.defaultLimit = options.defaultLimit ?? 20;
411
- this.defaultSort = options.defaultSort ?? "-createdAt";
412
- this.resourceName = options.resourceName;
413
- this.disableEvents = options.disableEvents ?? false;
414
- this.list = this.list.bind(this);
415
- this.get = this.get.bind(this);
416
- this.create = this.create.bind(this);
417
- this.update = this.update.bind(this);
418
- this.delete = this.delete.bind(this);
419
- }
420
- /**
421
- * Inject resource options from defineResource
422
- */
423
- _setResourceOptions(options) {
424
- if (options.schemaOptions) {
425
- this.schemaOptions = { ...this.schemaOptions, ...options.schemaOptions };
426
- }
427
- if (options.presetFields) {
428
- this._presetFields = { ...this._presetFields, ...options.presetFields };
429
- }
430
- if (options.resourceName) {
431
- this.resourceName = options.resourceName;
432
- }
433
- if (options.queryParser) {
434
- this.queryParser = options.queryParser;
435
- }
436
- }
437
- // ============================================================================
438
- // Context & Query Parsing
439
- // ============================================================================
440
- /**
441
- * Build service context from IRequestContext
442
- */
443
- _buildContext(req) {
444
- const parsed = this.queryParser.parse(req.query);
445
- const arcContext = req.metadata;
446
- const selectString = this._selectToString(parsed.select) ?? req.query?.select;
447
- const sanitizedSelect = this._sanitizeSelect(selectString, this.schemaOptions);
448
- return {
449
- user: req.user,
450
- organizationId: arcContext?.organizationId ?? req.organizationId ?? void 0,
451
- select: sanitizedSelect ? sanitizedSelect.split(/\s+/) : void 0,
452
- populate: this._sanitizePopulate(parsed.populate, this.schemaOptions),
453
- lean: this._parseLean(req.query?.lean)
454
- };
455
- }
456
- /**
457
- * Parse query into QueryOptions using queryParser
458
- */
459
- _parseQueryOptions(req) {
460
- const parsed = this.queryParser.parse(req.query);
461
- const arcContext = req.metadata;
462
- delete parsed.filters._policyFilters;
463
- const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
464
- const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
465
- const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.defaultSort;
466
- const selectString = this._selectToString(parsed.select) ?? req.query?.select;
467
- return {
468
- page,
469
- limit,
470
- sort: sortString,
471
- select: this._sanitizeSelect(selectString, this.schemaOptions),
472
- populate: this._sanitizePopulate(parsed.populate, this.schemaOptions),
473
- // Advanced populate options from MongoKit QueryParser (takes precedence over simple populate)
474
- populateOptions: parsed.populateOptions,
475
- filters: parsed.filters,
476
- // MongoKit features
477
- search: parsed.search,
478
- after: parsed.after,
479
- user: req.user,
480
- organizationId: arcContext?.organizationId ?? req.organizationId,
481
- context: arcContext
482
- };
483
- }
484
- /**
485
- * Apply org and policy filters
486
- */
487
- _applyFilters(options, req) {
488
- const filters = { ...options.filters };
489
- const arcContext = req.metadata;
490
- const policyFilters = arcContext?._policyFilters;
491
- if (policyFilters) {
492
- Object.assign(filters, policyFilters);
493
- }
494
- const orgId = arcContext?.organizationId ?? req.organizationId;
495
- if (orgId) {
496
- filters.organizationId = orgId;
497
- }
498
- return { ...options, filters };
499
- }
500
- /**
501
- * Build filter for single-item operations (get/update/delete)
502
- * Combines ID filter with policy/org filters for proper security enforcement
503
- */
504
- _buildIdFilter(id, req) {
505
- const filter = { _id: id };
506
- const arcContext = req.metadata;
507
- const policyFilters = arcContext?._policyFilters;
508
- if (policyFilters) {
509
- Object.assign(filter, policyFilters);
510
- }
511
- const orgId = arcContext?.organizationId ?? req.organizationId;
512
- if (orgId) {
513
- filter.organizationId = orgId;
514
- }
515
- return filter;
516
- }
517
- /**
518
- * Check if a value matches a MongoDB query operator
519
- */
520
- _matchesOperator(itemValue, operator, filterValue) {
521
- switch (operator) {
522
- case "$eq":
523
- return itemValue === filterValue;
524
- case "$ne":
525
- return itemValue !== filterValue;
526
- case "$gt":
527
- return typeof itemValue === "number" && typeof filterValue === "number" && itemValue > filterValue;
528
- case "$gte":
529
- return typeof itemValue === "number" && typeof filterValue === "number" && itemValue >= filterValue;
530
- case "$lt":
531
- return typeof itemValue === "number" && typeof filterValue === "number" && itemValue < filterValue;
532
- case "$lte":
533
- return typeof itemValue === "number" && typeof filterValue === "number" && itemValue <= filterValue;
534
- case "$in":
535
- return Array.isArray(filterValue) && filterValue.includes(itemValue);
536
- case "$nin":
537
- return Array.isArray(filterValue) && !filterValue.includes(itemValue);
538
- case "$exists":
539
- return filterValue ? itemValue !== void 0 : itemValue === void 0;
540
- case "$regex":
541
- if (typeof itemValue === "string" && (typeof filterValue === "string" || filterValue instanceof RegExp)) {
542
- const regex = typeof filterValue === "string" ? new RegExp(filterValue) : filterValue;
543
- return regex.test(itemValue);
544
- }
545
- return false;
546
- default:
547
- return false;
548
- }
549
- }
550
- /**
551
- * Forbidden paths that could lead to prototype pollution
552
- */
553
- static FORBIDDEN_PATHS = ["__proto__", "constructor", "prototype"];
554
- /**
555
- * Get nested value from object using dot notation (e.g., "owner.id")
556
- * Security: Validates path against forbidden patterns to prevent prototype pollution
557
- */
558
- _getNestedValue(obj, path) {
559
- if (_BaseController.FORBIDDEN_PATHS.some((p) => path.toLowerCase().includes(p))) {
560
- return void 0;
561
- }
562
- const keys = path.split(".");
563
- let value = obj;
564
- for (const key of keys) {
565
- if (value == null) return void 0;
566
- if (_BaseController.FORBIDDEN_PATHS.includes(key.toLowerCase())) {
567
- return void 0;
568
- }
569
- value = value[key];
570
- }
571
- return value;
572
- }
573
- /**
574
- * Check if item matches a single filter condition
575
- * Supports nested paths (e.g., "owner.id", "metadata.status")
576
- */
577
- _matchesFilter(item, key, filterValue) {
578
- const itemValue = key.includes(".") ? this._getNestedValue(item, key) : item[key];
579
- if (filterValue && typeof filterValue === "object" && !Array.isArray(filterValue)) {
580
- const operators = Object.keys(filterValue);
581
- if (operators.some((op) => op.startsWith("$"))) {
582
- for (const [operator, opValue] of Object.entries(filterValue)) {
583
- if (!this._matchesOperator(itemValue, operator, opValue)) {
584
- return false;
585
- }
586
- }
587
- return true;
588
- }
589
- }
590
- return String(itemValue) === String(filterValue);
591
- }
592
- /**
593
- * Check if item matches policy filters (for get/update/delete operations)
594
- * Validates that fetched item satisfies all policy constraints
595
- * Supports MongoDB query operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
596
- */
597
- _checkPolicyFilters(item, req) {
598
- const arcContext = req.metadata;
599
- const policyFilters = arcContext?._policyFilters;
600
- if (!policyFilters) return true;
601
- if (policyFilters.$and && Array.isArray(policyFilters.$and)) {
602
- return policyFilters.$and.every((condition) => {
603
- return Object.entries(condition).every(([key, value]) => {
604
- return this._matchesFilter(item, key, value);
605
- });
606
- });
607
- }
608
- if (policyFilters.$or && Array.isArray(policyFilters.$or)) {
609
- return policyFilters.$or.some((condition) => {
610
- return Object.entries(condition).every(([key, value]) => {
611
- return this._matchesFilter(item, key, value);
612
- });
613
- });
614
- }
615
- for (const [key, value] of Object.entries(policyFilters)) {
616
- if (key.startsWith("$")) continue;
617
- if (!this._matchesFilter(item, key, value)) {
618
- return false;
619
- }
620
- }
621
- return true;
622
- }
623
- // ============================================================================
624
- // Sanitization Helpers
625
- // ============================================================================
626
- /** Parse lean option (default: true for performance) */
627
- _parseLean(leanValue) {
628
- if (typeof leanValue === "boolean") return leanValue;
629
- if (typeof leanValue === "string") return leanValue.toLowerCase() !== "false";
630
- return true;
631
- }
632
- /** Get blocked fields from schema options */
633
- _getBlockedFields(schemaOptions) {
634
- const fieldRules = schemaOptions.fieldRules ?? {};
635
- return Object.entries(fieldRules).filter(([, rules]) => rules.systemManaged || rules.hidden).map(([field]) => field);
636
- }
637
- /**
638
- * Convert parsed select object to string format
639
- * Converts { name: 1, email: 1, password: 0 } → 'name email -password'
640
- */
641
- _selectToString(select) {
642
- if (!select) return void 0;
643
- if (typeof select === "string") return select;
644
- if (Array.isArray(select)) return select.join(" ");
645
- if (Object.keys(select).length === 0) return void 0;
646
- return Object.entries(select).map(([field, include]) => include === 0 ? `-${field}` : field).join(" ");
647
- }
648
- /** Sanitize select fields */
649
- _sanitizeSelect(select, schemaOptions) {
650
- if (!select) return void 0;
651
- const blockedFields = this._getBlockedFields(schemaOptions);
652
- if (blockedFields.length === 0) return select;
653
- const fields = select.split(/[\s,]+/).filter(Boolean);
654
- const sanitized = fields.filter((f) => {
655
- const fieldName = f.replace(/^-/, "");
656
- return !blockedFields.includes(fieldName);
657
- });
658
- return sanitized.length > 0 ? sanitized.join(" ") : void 0;
659
- }
660
- /** Sanitize populate fields */
661
- _sanitizePopulate(populate, schemaOptions) {
662
- if (!populate) return void 0;
663
- const allowedPopulate = schemaOptions.query?.allowedPopulate;
664
- const requested = typeof populate === "string" ? populate.split(",").map((p) => p.trim()) : Array.isArray(populate) ? populate.map(String) : [];
665
- if (requested.length === 0) return void 0;
666
- if (!allowedPopulate) return requested;
667
- const sanitized = requested.filter((p) => allowedPopulate.includes(p));
668
- return sanitized.length > 0 ? sanitized : void 0;
669
- }
670
- // ============================================================================
671
- // Access Control Helpers
672
- // ============================================================================
673
- /** Check org scope for a document */
674
- _checkOrgScope(item, arcContext) {
675
- if (!item || !arcContext?.organizationId) return true;
676
- const itemOrgId = item.organizationId;
677
- if (!itemOrgId) return true;
678
- return String(itemOrgId) === String(arcContext.organizationId);
679
- }
680
- /** Check ownership for update/delete (ownedByUser preset) */
681
- _checkOwnership(item, req) {
682
- const ownershipCheck = req.metadata?._ownershipCheck;
683
- if (!item || !ownershipCheck) return true;
684
- const { field, userId } = ownershipCheck;
685
- const itemOwnerId = item[field];
686
- if (!itemOwnerId) return true;
687
- return String(itemOwnerId) === String(userId);
688
- }
689
- /**
690
- * Get hook system from context (instance-scoped) or fall back to global singleton
691
- * This allows proper isolation when running multiple app instances (e.g., in tests)
692
- */
693
- _getHooks(req) {
694
- const arcMeta = req.metadata?.arc;
695
- return arcMeta?.hooks ?? hookSystem;
696
- }
697
- // ============================================================================
698
- // IController Implementation - CRUD Operations
699
- // ============================================================================
700
- /**
701
- * List resources with filtering, pagination, sorting
702
- * Implements IController.list()
703
- */
704
- async list(req) {
705
- const options = this._parseQueryOptions(req);
706
- const filteredOptions = this._applyFilters(options, req);
707
- const result = await this.repository.getAll(filteredOptions);
708
- if (Array.isArray(result)) {
709
- return {
710
- success: true,
711
- data: {
712
- docs: result,
713
- page: 1,
714
- limit: result.length,
715
- total: result.length,
716
- pages: 1,
717
- hasNext: false,
718
- hasPrev: false
719
- },
720
- status: 200
721
- };
722
- }
723
- return {
724
- success: true,
725
- data: result,
726
- status: 200
727
- };
728
- }
729
- /**
730
- * Get single resource by ID
731
- * Implements IController.get()
732
- */
733
- async get(req) {
734
- const id = req.params.id;
735
- if (!id) {
736
- return {
737
- success: false,
738
- error: "ID parameter is required",
739
- status: 400
740
- };
741
- }
742
- const options = this._parseQueryOptions(req);
743
- const arcContext = req.metadata;
744
- try {
745
- const item = await this.repository.getById(id, options);
746
- const hasItem = !!item;
747
- const orgScopeOk = this._checkOrgScope(item, arcContext);
748
- const policyFiltersOk = this._checkPolicyFilters(item, req);
749
- if (!hasItem || !orgScopeOk || !policyFiltersOk) {
750
- return {
751
- success: false,
752
- error: "Resource not found",
753
- status: 404
754
- };
755
- }
756
- return {
757
- success: true,
758
- data: item,
759
- status: 200
760
- };
761
- } catch (error) {
762
- if (error instanceof Error && error.message?.includes("not found")) {
763
- return {
764
- success: false,
765
- error: "Resource not found",
766
- status: 404
767
- };
768
- }
769
- throw error;
770
- }
771
- }
772
- /**
773
- * Create new resource
774
- * Implements IController.create()
775
- */
776
- async create(req) {
777
- const data = { ...req.body };
778
- const arcContext = req.metadata;
779
- if (arcContext?.organizationId) {
780
- data.organizationId = arcContext.organizationId;
781
- }
782
- const userId = getUserId(req.user);
783
- if (userId) {
784
- data.createdBy = userId;
785
- }
786
- const hooks = this._getHooks(req);
787
- const user = req.user;
788
- let processedData = data;
789
- if (this.resourceName) {
790
- processedData = await hooks.executeBefore(this.resourceName, "create", data, {
791
- user,
792
- context: arcContext
793
- });
794
- }
795
- const item = await this.repository.create(processedData, {
796
- user,
797
- context: arcContext
798
- });
799
- if (this.resourceName) {
800
- await hooks.executeAfter(this.resourceName, "create", item, {
801
- user,
802
- context: arcContext
803
- });
804
- }
805
- return {
806
- success: true,
807
- data: item,
808
- status: 201,
809
- meta: { message: "Created successfully" }
810
- };
811
- }
812
- /**
813
- * Update existing resource
814
- * Implements IController.update()
815
- */
816
- async update(req) {
817
- const id = req.params.id;
818
- if (!id) {
819
- return {
820
- success: false,
821
- error: "ID parameter is required",
822
- status: 400
823
- };
824
- }
825
- const data = { ...req.body };
826
- const arcContext = req.metadata;
827
- const user = req.user;
828
- const userId = getUserId(user);
829
- if (userId) {
830
- data.updatedBy = userId;
831
- }
832
- const existing = await this.repository.getById(id);
833
- if (!existing) {
834
- return {
835
- success: false,
836
- error: "Resource not found",
837
- status: 404
838
- };
839
- }
840
- if (!this._checkOrgScope(existing, arcContext) || !this._checkPolicyFilters(existing, req)) {
841
- return {
842
- success: false,
843
- error: "Resource not found",
844
- status: 404
845
- };
846
- }
847
- if (!this._checkOwnership(existing, req)) {
848
- return {
849
- success: false,
850
- error: "You do not have permission to modify this resource",
851
- details: { code: "OWNERSHIP_DENIED" },
852
- status: 403
853
- };
854
- }
855
- const hooks = this._getHooks(req);
856
- let processedData = data;
857
- if (this.resourceName) {
858
- processedData = await hooks.executeBefore(this.resourceName, "update", data, {
859
- user,
860
- context: arcContext,
861
- meta: { id, existing }
862
- });
863
- }
864
- const item = await this.repository.update(id, processedData, {
865
- user,
866
- context: arcContext
867
- });
868
- if (!item) {
869
- return {
870
- success: false,
871
- error: "Resource not found",
872
- status: 404
873
- };
874
- }
875
- if (this.resourceName) {
876
- await hooks.executeAfter(this.resourceName, "update", item, {
877
- user,
878
- context: arcContext,
879
- meta: { id, existing }
880
- });
881
- }
882
- return {
883
- success: true,
884
- data: item,
885
- status: 200,
886
- meta: { message: "Updated successfully" }
887
- };
888
- }
889
- /**
890
- * Delete resource
891
- * Implements IController.delete()
892
- */
893
- async delete(req) {
894
- const id = req.params.id;
895
- if (!id) {
896
- return {
897
- success: false,
898
- error: "ID parameter is required",
899
- status: 400
900
- };
901
- }
902
- const arcContext = req.metadata;
903
- const user = req.user;
904
- const existing = await this.repository.getById(id);
905
- if (!existing) {
906
- return {
907
- success: false,
908
- error: "Resource not found",
909
- status: 404
910
- };
911
- }
912
- if (!this._checkOrgScope(existing, arcContext) || !this._checkPolicyFilters(existing, req)) {
913
- return {
914
- success: false,
915
- error: "Resource not found",
916
- status: 404
917
- };
918
- }
919
- if (!this._checkOwnership(existing, req)) {
920
- return {
921
- success: false,
922
- error: "You do not have permission to delete this resource",
923
- details: { code: "OWNERSHIP_DENIED" },
924
- status: 403
925
- };
926
- }
927
- const hooks = this._getHooks(req);
928
- if (this.resourceName) {
929
- await hooks.executeBefore(this.resourceName, "delete", existing, {
930
- user,
931
- context: arcContext,
932
- meta: { id }
933
- });
934
- }
935
- const result = await this.repository.delete(id, {
936
- user,
937
- context: arcContext
938
- });
939
- const deleteSuccess = typeof result === "object" ? result?.success : result;
940
- if (!deleteSuccess) {
941
- return {
942
- success: false,
943
- error: "Resource not found",
944
- status: 404
945
- };
946
- }
947
- if (this.resourceName) {
948
- await hooks.executeAfter(this.resourceName, "delete", existing, {
949
- user,
950
- context: arcContext,
951
- meta: { id }
952
- });
953
- }
954
- return {
955
- success: true,
956
- data: { message: "Deleted successfully" },
957
- status: 200
958
- };
959
- }
960
- // ============================================================================
961
- // Preset Methods (framework-agnostic versions)
962
- // ============================================================================
963
- /** Get resource by slug (slugLookup preset) */
964
- async getBySlug(req) {
965
- const repo = this.repository;
966
- if (!repo.getBySlug) {
967
- return {
968
- success: false,
969
- error: "Slug lookup not implemented",
970
- status: 501
971
- };
972
- }
973
- const slugField = this._presetFields.slugField ?? "slug";
974
- const slug = req.params[slugField] ?? req.params.slug;
975
- const options = this._parseQueryOptions(req);
976
- const arcContext = req.metadata;
977
- const item = await repo.getBySlug(slug, options);
978
- if (!item || !this._checkOrgScope(item, arcContext)) {
979
- return {
980
- success: false,
981
- error: "Resource not found",
982
- status: 404
983
- };
984
- }
985
- return {
986
- success: true,
987
- data: item,
988
- status: 200
989
- };
990
- }
991
- /** Get soft-deleted resources (softDelete preset) */
992
- async getDeleted(req) {
993
- const repo = this.repository;
994
- if (!repo.getDeleted) {
995
- return {
996
- success: false,
997
- error: "Soft delete not implemented",
998
- status: 501
999
- };
1000
- }
1001
- const options = this._parseQueryOptions(req);
1002
- const filteredOptions = this._applyFilters(options, req);
1003
- const result = await repo.getDeleted(filteredOptions);
1004
- if (Array.isArray(result)) {
1005
- return {
1006
- success: true,
1007
- data: {
1008
- docs: result,
1009
- page: 1,
1010
- limit: result.length,
1011
- total: result.length,
1012
- pages: 1,
1013
- hasNext: false,
1014
- hasPrev: false
1015
- },
1016
- status: 200
1017
- };
1018
- }
1019
- return {
1020
- success: true,
1021
- data: result,
1022
- status: 200
1023
- };
1024
- }
1025
- /** Restore soft-deleted resource (softDelete preset) */
1026
- async restore(req) {
1027
- const repo = this.repository;
1028
- if (!repo.restore) {
1029
- return {
1030
- success: false,
1031
- error: "Restore not implemented",
1032
- status: 501
1033
- };
1034
- }
1035
- const id = req.params.id;
1036
- if (!id) {
1037
- return {
1038
- success: false,
1039
- error: "ID parameter is required",
1040
- status: 400
1041
- };
1042
- }
1043
- const item = await repo.restore(id);
1044
- if (!item) {
1045
- return {
1046
- success: false,
1047
- error: "Resource not found",
1048
- status: 404
1049
- };
1050
- }
1051
- return {
1052
- success: true,
1053
- data: item,
1054
- status: 200,
1055
- meta: { message: "Restored successfully" }
1056
- };
1057
- }
1058
- /** Get hierarchical tree (tree preset) */
1059
- async getTree(req) {
1060
- const repo = this.repository;
1061
- if (!repo.getTree) {
1062
- return {
1063
- success: false,
1064
- error: "Tree structure not implemented",
1065
- status: 501
1066
- };
1067
- }
1068
- const options = this._parseQueryOptions(req);
1069
- const filteredOptions = this._applyFilters(options, req);
1070
- const tree = await repo.getTree(filteredOptions);
1071
- return {
1072
- success: true,
1073
- data: tree,
1074
- status: 200
1075
- };
1076
- }
1077
- /** Get children of parent (tree preset) */
1078
- async getChildren(req) {
1079
- const repo = this.repository;
1080
- if (!repo.getChildren) {
1081
- return {
1082
- success: false,
1083
- error: "Tree structure not implemented",
1084
- status: 501
1085
- };
1086
- }
1087
- const parentField = this._presetFields.parentField ?? "parent";
1088
- const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
1089
- const options = this._parseQueryOptions(req);
1090
- const filteredOptions = this._applyFilters(options, req);
1091
- const children = await repo.getChildren(parentId, filteredOptions);
1092
- return {
1093
- success: true,
1094
- data: children,
1095
- status: 200
1096
- };
1097
- }
1098
- };
1099
-
1100
- // src/core/fastifyAdapter.ts
1101
- function applyFieldMaskToObject(obj, fieldMask) {
1102
- if (!obj || typeof obj !== "object") return obj;
1103
- const { include, exclude } = fieldMask;
1104
- if (include && include.length > 0) {
1105
- const filtered = {};
1106
- for (const field of include) {
1107
- if (field in obj) {
1108
- filtered[field] = obj[field];
1109
- }
1110
- }
1111
- return filtered;
1112
- }
1113
- if (exclude && exclude.length > 0) {
1114
- const filtered = { ...obj };
1115
- for (const field of exclude) {
1116
- delete filtered[field];
1117
- }
1118
- return filtered;
1119
- }
1120
- return obj;
1121
- }
1122
- function applyFieldMask(data, fieldMask) {
1123
- if (!fieldMask) return data;
1124
- if (Array.isArray(data)) {
1125
- return data.map((item) => applyFieldMaskToObject(item, fieldMask));
1126
- }
1127
- if (data && typeof data === "object") {
1128
- return applyFieldMaskToObject(data, fieldMask);
1129
- }
1130
- return data;
1131
- }
1132
- function createRequestContext(req) {
1133
- const reqWithExtras = req;
1134
- return {
1135
- query: reqWithExtras.query ?? {},
1136
- body: reqWithExtras.body ?? {},
1137
- params: reqWithExtras.params ?? {},
1138
- headers: reqWithExtras.headers,
1139
- user: reqWithExtras.user ? (() => {
1140
- const user = reqWithExtras.user;
1141
- return {
1142
- ...user,
1143
- // Normalize ID for MongoDB compatibility
1144
- id: String(user._id ?? user.id),
1145
- _id: user._id ?? user.id
1146
- // Preserve original role/roles/permissions as-is
1147
- // Devs can define their own authorization structure
1148
- };
1149
- })() : void 0,
1150
- organizationId: reqWithExtras.organizationId,
1151
- metadata: {
1152
- ...reqWithExtras.context,
1153
- // Include Arc metadata for hook execution
1154
- arc: reqWithExtras.arc,
1155
- // Include ownership check for access control
1156
- _ownershipCheck: reqWithExtras._ownershipCheck,
1157
- // Merge policy filters - TRUSTED sources override user input
1158
- // Order matters: query (can be user-injected) FIRST, then trusted middleware LAST
1159
- _policyFilters: {
1160
- ...reqWithExtras.query?._policyFilters ?? {},
1161
- ...reqWithExtras._policyFilters ?? {}
1162
- },
1163
- // Include logger for logging
1164
- log: reqWithExtras.log
1165
- }
1166
- };
1167
- }
1168
- function sendControllerResponse(reply, response, request) {
1169
- const reqWithExtras = request;
1170
- const fieldMask = reqWithExtras?.fieldMask;
1171
- const fieldMaskConfig = fieldMask ? { include: fieldMask } : void 0;
1172
- if (response.success && response.data && typeof response.data === "object" && "docs" in response.data) {
1173
- const paginatedData = response.data;
1174
- const filteredDocs = fieldMaskConfig ? applyFieldMask(paginatedData.docs, fieldMaskConfig) : paginatedData.docs;
1175
- reply.code(response.status ?? 200).send({
1176
- success: true,
1177
- docs: filteredDocs,
1178
- page: paginatedData.page,
1179
- limit: paginatedData.limit,
1180
- total: paginatedData.total,
1181
- pages: paginatedData.pages,
1182
- hasNext: paginatedData.hasNext,
1183
- hasPrev: paginatedData.hasPrev,
1184
- ...response.meta ?? {}
1185
- });
1186
- return;
1187
- }
1188
- const filteredData = fieldMaskConfig ? applyFieldMask(response.data, fieldMaskConfig) : response.data;
1189
- reply.code(response.status ?? (response.success ? 200 : 400)).send({
1190
- success: response.success,
1191
- data: filteredData,
1192
- error: response.error,
1193
- details: response.details,
1194
- ...response.meta ?? {}
1195
- });
1196
- }
1197
- function createFastifyHandler(controllerMethod) {
1198
- return async (req, reply) => {
1199
- const requestContext = createRequestContext(req);
1200
- const response = await controllerMethod(requestContext);
1201
- sendControllerResponse(reply, response, req);
1202
- };
1203
- }
1204
- function createCrudHandlers(controller) {
1205
- return {
1206
- list: createFastifyHandler(controller.list.bind(controller)),
1207
- get: createFastifyHandler(controller.get.bind(controller)),
1208
- create: createFastifyHandler(controller.create.bind(controller)),
1209
- update: createFastifyHandler(controller.update.bind(controller)),
1210
- delete: createFastifyHandler(controller.delete.bind(controller))
1211
- };
1212
- }
1213
-
1214
- // src/core/createCrudRouter.ts
1215
- function requiresAuthentication(permission) {
1216
- if (!permission) return false;
1217
- return !permission._isPublic;
1218
- }
1219
- function buildAuthMiddleware(fastify, permission) {
1220
- if (!requiresAuthentication(permission)) return null;
1221
- if (!fastify.authenticate) return null;
1222
- return fastify.authenticate;
1223
- }
1224
- function buildPermissionMiddleware(permissionCheck, resourceName, action) {
1225
- if (!permissionCheck) return null;
1226
- return async (request, reply) => {
1227
- const reqWithExtras = request;
1228
- const context = {
1229
- user: reqWithExtras.user ?? null,
1230
- request,
1231
- resource: resourceName,
1232
- action,
1233
- resourceId: request.params?.id,
1234
- organizationId: reqWithExtras.organizationId,
1235
- data: request.body
1236
- };
1237
- const result = await permissionCheck(context);
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) {
1257
- reqWithExtras._policyFilters = {
1258
- ...reqWithExtras._policyFilters ?? {},
1259
- ...permResult.filters
1260
- };
1261
- }
1262
- };
1263
- }
1264
- function buildOrgScopedMiddleware(fastify, orgScoped, globalOrgScoped) {
1265
- const shouldApplyOrgScoped = orgScoped ?? globalOrgScoped;
1266
- if (!shouldApplyOrgScoped) return [];
1267
- if (!fastify.organizationScoped) {
1268
- throw new Error(
1269
- "Organization scoping is enabled but fastify.organizationScoped decorator is not registered.\nRegister the org scope plugin before mounting resources:\nawait app.register(orgScopePlugin);\nDocs: https://github.com/classytic/arc#multi-tenant"
1270
- );
1271
- }
1272
- return [fastify.organizationScoped()];
1273
- }
1274
- function createAdditionalRoutes(fastify, routes, controller, options) {
1275
- const { tag, resourceName, orgMw, arcDecorator } = options;
1276
- for (const route of routes) {
1277
- let handler;
1278
- if (typeof route.handler === "string") {
1279
- if (!controller) {
1280
- throw new Error(
1281
- `Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller. Either provide a controller or use a function handler instead.`
1282
- );
1283
- }
1284
- const ctrl = controller;
1285
- const method = ctrl[route.handler];
1286
- if (typeof method !== "function") {
1287
- throw new Error(`Handler '${route.handler}' not found on controller`);
1288
- }
1289
- const boundMethod = method.bind(controller);
1290
- handler = route.wrapHandler ? createFastifyHandler(boundMethod) : boundMethod;
1291
- } else {
1292
- handler = route.wrapHandler ? createFastifyHandler(route.handler) : route.handler;
1293
- }
1294
- const routeTags = route.tags ?? (tag ? [tag] : void 0);
1295
- const schema = {
1296
- ...routeTags ? { tags: routeTags } : {},
1297
- ...route.summary ? { summary: route.summary } : {},
1298
- ...route.description ? { description: route.description } : {},
1299
- ...route.schema ?? {}
1300
- };
1301
- const authMw = buildAuthMiddleware(fastify, route.permissions);
1302
- const permissionMw = buildPermissionMiddleware(route.permissions, resourceName, route.method.toLowerCase());
1303
- const customPreHandlers = typeof route.preHandler === "function" ? route.preHandler(fastify) : route.preHandler ?? [];
1304
- const preHandler = [
1305
- arcDecorator,
1306
- authMw,
1307
- // Authenticate first (populates request.user)
1308
- permissionMw,
1309
- // Then check permissions
1310
- ...orgMw(),
1311
- ...customPreHandlers
1312
- ].filter(Boolean);
1313
- fastify.route({
1314
- method: route.method,
1315
- url: route.path,
1316
- schema,
1317
- // Fastify schema is flexible - allow any valid JSON schema
1318
- preHandler: preHandler.length > 0 ? preHandler : void 0,
1319
- handler
1320
- });
1321
- }
1322
- }
1323
- function createCrudRouter(fastify, controller, options = {}) {
1324
- const {
1325
- tag = "Resource",
1326
- schemas = {},
1327
- permissions = {},
1328
- middlewares = {},
1329
- additionalRoutes = [],
1330
- disableDefaultRoutes = false,
1331
- disabledRoutes = [],
1332
- organizationScoped = false,
1333
- resourceName = "unknown",
1334
- schemaOptions
1335
- } = options;
1336
- const orgMw = (orgScoped) => {
1337
- return buildOrgScopedMiddleware(fastify, orgScoped, organizationScoped);
1338
- };
1339
- const arcDecorator = async (req, _reply) => {
1340
- req.arc = {
1341
- resourceName,
1342
- schemaOptions,
1343
- permissions,
1344
- // Include instance-scoped hooks if available (for proper isolation)
1345
- hooks: fastify.arc?.hooks,
1346
- // Include events emitter if available
1347
- events: fastify.events
1348
- };
1349
- };
1350
- const mw = {
1351
- list: middlewares.list ?? [],
1352
- get: middlewares.get ?? [],
1353
- create: middlewares.create ?? [],
1354
- update: middlewares.update ?? [],
1355
- delete: middlewares.delete ?? []
1356
- };
1357
- const idParamsSchema = {
1358
- type: "object",
1359
- properties: { id: { type: "string" } },
1360
- required: ["id"]
1361
- };
1362
- let handlers;
1363
- if (!disableDefaultRoutes) {
1364
- if (!controller) {
1365
- throw new Error(
1366
- "Controller is required when disableDefaultRoutes is not true. Provide a controller or use defineResource which auto-creates BaseController."
1367
- );
1368
- }
1369
- handlers = createCrudHandlers(controller);
1370
- }
1371
- if (!disableDefaultRoutes && handlers) {
1372
- if (!disabledRoutes.includes("list")) {
1373
- const authMw = buildAuthMiddleware(fastify, permissions.list);
1374
- const permMw = buildPermissionMiddleware(permissions.list, resourceName, "list");
1375
- const listPreHandler = [arcDecorator, authMw, permMw, ...orgMw(), ...mw.list].filter(Boolean);
1376
- fastify.route({
1377
- method: "GET",
1378
- url: "/",
1379
- schema: {
1380
- tags: [tag],
1381
- summary: `List ${tag}`,
1382
- ...schemas.list ?? {}
1383
- },
1384
- preHandler: listPreHandler.length > 0 ? listPreHandler : void 0,
1385
- handler: handlers.list
1386
- });
1387
- }
1388
- if (!disabledRoutes.includes("get")) {
1389
- const authMw = buildAuthMiddleware(fastify, permissions.get);
1390
- const permMw = buildPermissionMiddleware(permissions.get, resourceName, "get");
1391
- const getPreHandler = [arcDecorator, authMw, permMw, ...orgMw(), ...mw.get].filter(Boolean);
1392
- fastify.route({
1393
- method: "GET",
1394
- url: "/:id",
1395
- schema: {
1396
- tags: [tag],
1397
- summary: `Get ${tag} by ID`,
1398
- params: idParamsSchema,
1399
- ...schemas.get ?? {}
1400
- },
1401
- preHandler: getPreHandler.length > 0 ? getPreHandler : void 0,
1402
- handler: handlers.get
1403
- });
1404
- }
1405
- if (!disabledRoutes.includes("create")) {
1406
- const authMw = buildAuthMiddleware(fastify, permissions.create);
1407
- const permMw = buildPermissionMiddleware(permissions.create, resourceName, "create");
1408
- const createPreHandler = [arcDecorator, authMw, permMw, ...orgMw(), ...mw.create].filter(Boolean);
1409
- fastify.route({
1410
- method: "POST",
1411
- url: "/",
1412
- schema: {
1413
- tags: [tag],
1414
- summary: `Create ${tag}`,
1415
- ...schemas.create ?? {}
1416
- },
1417
- preHandler: createPreHandler.length > 0 ? createPreHandler : void 0,
1418
- handler: handlers.create
1419
- });
1420
- }
1421
- if (!disabledRoutes.includes("update")) {
1422
- const authMw = buildAuthMiddleware(fastify, permissions.update);
1423
- const permMw = buildPermissionMiddleware(permissions.update, resourceName, "update");
1424
- const updatePreHandler = [arcDecorator, authMw, permMw, ...orgMw(), ...mw.update].filter(Boolean);
1425
- fastify.route({
1426
- method: "PATCH",
1427
- url: "/:id",
1428
- schema: {
1429
- tags: [tag],
1430
- summary: `Update ${tag}`,
1431
- params: idParamsSchema,
1432
- ...schemas.update ?? {}
1433
- },
1434
- preHandler: updatePreHandler.length > 0 ? updatePreHandler : void 0,
1435
- handler: handlers.update
1436
- });
1437
- }
1438
- if (!disabledRoutes.includes("delete")) {
1439
- const authMw = buildAuthMiddleware(fastify, permissions.delete);
1440
- const permMw = buildPermissionMiddleware(permissions.delete, resourceName, "delete");
1441
- const deletePreHandler = [arcDecorator, authMw, permMw, ...orgMw(), ...mw.delete].filter(Boolean);
1442
- fastify.route({
1443
- method: "DELETE",
1444
- url: "/:id",
1445
- schema: {
1446
- tags: [tag],
1447
- summary: `Delete ${tag}`,
1448
- params: idParamsSchema,
1449
- ...schemas.delete ?? {}
1450
- },
1451
- preHandler: deletePreHandler.length > 0 ? deletePreHandler : void 0,
1452
- handler: handlers.delete
1453
- });
1454
- }
1455
- }
1456
- if (additionalRoutes.length > 0) {
1457
- createAdditionalRoutes(fastify, additionalRoutes, controller, { tag, resourceName, orgMw, arcDecorator });
1458
- }
1459
- }
1460
- function createOrgScopedMiddleware(instance) {
1461
- return instance.organizationScoped ? [instance.organizationScoped()] : [];
1462
- }
1463
- function createPermissionMiddleware(permission, resourceName, action) {
1464
- return buildPermissionMiddleware(permission, resourceName, action);
1465
- }
1466
-
1467
- // src/core/createActionRouter.ts
1468
- function createActionRouter(fastify, config) {
1469
- const {
1470
- tag,
1471
- actions,
1472
- actionPermissions = {},
1473
- actionSchemas = {},
1474
- globalAuth,
1475
- idempotencyService,
1476
- onError
1477
- } = config;
1478
- const actionEnum = Object.keys(actions);
1479
- if (actionEnum.length === 0) {
1480
- fastify.log.warn("[createActionRouter] No actions defined, skipping route creation");
1481
- return;
1482
- }
1483
- const bodyProperties = {
1484
- action: {
1485
- type: "string",
1486
- enum: actionEnum,
1487
- description: `Action to perform: ${actionEnum.join(" | ")}`
1488
- }
1489
- };
1490
- Object.entries(actionSchemas).forEach(([actionName, schema]) => {
1491
- if (schema && typeof schema === "object") {
1492
- Object.entries(schema).forEach(([propName, propSchema]) => {
1493
- bodyProperties[propName] = {
1494
- ...propSchema,
1495
- description: `${propSchema.description || ""} (for ${actionName} action)`.trim()
1496
- };
1497
- });
1498
- }
1499
- });
1500
- const routeSchema = {
1501
- tags: tag ? [tag] : void 0,
1502
- summary: `Perform action (${actionEnum.join("/")})`,
1503
- description: buildActionDescription(actions, actionPermissions),
1504
- params: {
1505
- type: "object",
1506
- properties: {
1507
- id: { type: "string", description: "Resource ID" }
1508
- },
1509
- required: ["id"]
1510
- },
1511
- body: {
1512
- type: "object",
1513
- properties: bodyProperties,
1514
- required: ["action"]
1515
- },
1516
- response: {
1517
- 200: {
1518
- type: "object",
1519
- properties: {
1520
- success: { type: "boolean" },
1521
- data: { type: "object" }
1522
- }
1523
- },
1524
- 400: {
1525
- type: "object",
1526
- properties: {
1527
- success: { type: "boolean" },
1528
- error: { type: "string" }
1529
- }
1530
- },
1531
- 403: {
1532
- type: "object",
1533
- properties: {
1534
- success: { type: "boolean" },
1535
- error: { type: "string" }
1536
- }
1537
- }
1538
- }
1539
- };
1540
- const preHandler = [];
1541
- const allPermissions = Object.values(actionPermissions);
1542
- const needsAuth = allPermissions.some(
1543
- (p) => !p?._isPublic
1544
- ) || globalAuth && !globalAuth?._isPublic;
1545
- if (needsAuth && fastify.authenticate) {
1546
- preHandler.push(fastify.authenticate);
1547
- }
1548
- fastify.post(
1549
- "/:id/action",
1550
- {
1551
- schema: routeSchema,
1552
- preHandler: preHandler.length ? preHandler : void 0
1553
- },
1554
- async (req, reply) => {
1555
- const { action, ...data } = req.body;
1556
- const { id } = req.params;
1557
- const rawIdempotencyKey = req.headers["idempotency-key"];
1558
- const idempotencyKey = Array.isArray(rawIdempotencyKey) ? rawIdempotencyKey[0] : rawIdempotencyKey;
1559
- const handler = actions[action];
1560
- if (!handler) {
1561
- return reply.code(400).send({
1562
- success: false,
1563
- error: `Invalid action '${action}'. Valid actions: ${actionEnum.join(", ")}`,
1564
- validActions: actionEnum
1565
- });
1566
- }
1567
- const permissionCheck = actionPermissions[action] ?? globalAuth;
1568
- if (permissionCheck) {
1569
- const reqWithExtras = req;
1570
- const context = {
1571
- user: reqWithExtras.user ?? null,
1572
- request: req,
1573
- resource: tag ?? "action",
1574
- action,
1575
- resourceId: id,
1576
- data
1577
- };
1578
- const result = await permissionCheck(context);
1579
- if (typeof result === "boolean") {
1580
- if (!result) {
1581
- return reply.code(context.user ? 403 : 401).send({
1582
- success: false,
1583
- error: context.user ? `Permission denied for '${action}'` : "Authentication required"
1584
- });
1585
- }
1586
- } else {
1587
- const permResult = result;
1588
- if (!permResult.granted) {
1589
- return reply.code(context.user ? 403 : 401).send({
1590
- success: false,
1591
- error: permResult.reason ?? (context.user ? `Permission denied for '${action}'` : "Authentication required")
1592
- });
1593
- }
1594
- }
1595
- }
1596
- try {
1597
- if (idempotencyKey && idempotencyService) {
1598
- const user = req.user;
1599
- const payloadForHash = {
1600
- action,
1601
- id,
1602
- data,
1603
- userId: user?._id?.toString?.() || user?.id || null
1604
- };
1605
- const { isNew, existingResult } = await idempotencyService.check(
1606
- idempotencyKey,
1607
- payloadForHash
1608
- );
1609
- if (!isNew && existingResult) {
1610
- return reply.send({
1611
- success: true,
1612
- data: existingResult,
1613
- cached: true
1614
- });
1615
- }
1616
- }
1617
- const result = await handler(id, data, req);
1618
- if (idempotencyService) {
1619
- await idempotencyService.complete(idempotencyKey, result);
1620
- }
1621
- return reply.send({
1622
- success: true,
1623
- data: result
1624
- });
1625
- } catch (error) {
1626
- if (idempotencyService) {
1627
- await idempotencyService.fail(idempotencyKey, error);
1628
- }
1629
- if (onError) {
1630
- const { statusCode: statusCode2, error: errorMsg, code } = onError(error, action, id);
1631
- return reply.code(statusCode2).send({
1632
- success: false,
1633
- error: errorMsg,
1634
- code
1635
- });
1636
- }
1637
- const err = error;
1638
- const statusCode = err.statusCode || err.status || 500;
1639
- const errorCode = err.code || "ACTION_FAILED";
1640
- if (statusCode >= 500) {
1641
- req.log.error({ err: error, action, id }, "Action handler error");
1642
- }
1643
- return reply.code(statusCode).send({
1644
- success: false,
1645
- error: err.message || `Failed to execute '${action}' action`,
1646
- code: errorCode
1647
- });
1648
- }
1649
- }
1650
- );
1651
- fastify.log.info(
1652
- { actions: actionEnum, tag },
1653
- "[createActionRouter] Registered action endpoint: POST /:id/action"
1654
- );
1655
- }
1656
- function buildActionDescription(actions, actionPermissions) {
1657
- const lines = ["Unified action endpoint for state transitions.\n\n**Available actions:**"];
1658
- Object.keys(actions).forEach((action) => {
1659
- const perm = actionPermissions[action];
1660
- const roles = perm?._roles;
1661
- const roleStr = roles?.length ? ` (requires: ${roles.join(" or ")})` : "";
1662
- lines.push(`- \`${action}\`${roleStr}`);
1663
- });
1664
- return lines.join("\n");
1665
- }
1666
-
1667
- // src/permissions/index.ts
1668
- function allowPublic() {
1669
- const check = () => true;
1670
- check._isPublic = true;
1671
- return check;
1672
- }
1673
- function requireRoles(roles, options) {
1674
- const check = (ctx) => {
1675
- if (!ctx.user) {
1676
- return { granted: false, reason: "Authentication required" };
1677
- }
1678
- const userRoles = ctx.user.roles ?? [];
1679
- if (roles.some((r) => userRoles.includes(r))) {
1680
- return true;
1681
- }
1682
- return {
1683
- granted: false,
1684
- reason: `Required roles: ${roles.join(", ")}`
1685
- };
1686
- };
1687
- check._roles = roles;
1688
- return check;
1689
- }
1690
-
1691
- // src/presets/softDelete.ts
1692
- function softDeletePreset(options = {}) {
1693
- const { deletedField: _deletedField = "deletedAt" } = options;
1694
- return {
1695
- name: "softDelete",
1696
- additionalRoutes: (permissions) => [
1697
- {
1698
- method: "GET",
1699
- path: "/deleted",
1700
- handler: "getDeleted",
1701
- summary: "Get soft-deleted items",
1702
- permissions: permissions.list ?? requireRoles(["admin"]),
1703
- wrapHandler: true
1704
- },
1705
- {
1706
- method: "POST",
1707
- path: "/:id/restore",
1708
- handler: "restore",
1709
- summary: "Restore soft-deleted item",
1710
- permissions: permissions.update ?? requireRoles(["admin"]),
1711
- wrapHandler: true
1712
- }
1713
- ]
1714
- };
1715
- }
1716
-
1717
- // src/presets/slugLookup.ts
1718
- function slugLookupPreset(options = {}) {
1719
- const { slugField = "slug" } = options;
1720
- return {
1721
- name: "slugLookup",
1722
- additionalRoutes: (permissions) => [
1723
- {
1724
- method: "GET",
1725
- path: `/slug/:${slugField}`,
1726
- handler: "getBySlug",
1727
- summary: "Get by slug",
1728
- permissions: permissions.get ?? allowPublic(),
1729
- wrapHandler: true
1730
- // Handler is a ControllerHandler
1731
- }
1732
- ],
1733
- // Pass to controller so it knows which param to read
1734
- controllerOptions: {
1735
- slugField
1736
- }
1737
- };
1738
- }
1739
-
1740
- // src/presets/ownedByUser.ts
1741
- function createOwnershipCheck(ownerField, bypassRoles) {
1742
- return async (request, _reply) => {
1743
- const user = request.user;
1744
- if (!user) return;
1745
- const userWithRoles = user;
1746
- if (userWithRoles.roles && bypassRoles.some((r) => userWithRoles.roles.includes(r))) return;
1747
- const userWithId = user;
1748
- const userId = userWithId._id ?? userWithId.id;
1749
- if (userId) {
1750
- request._ownershipCheck = {
1751
- field: ownerField,
1752
- userId
1753
- };
1754
- }
1755
- };
1756
- }
1757
- function ownedByUserPreset(options = {}) {
1758
- const {
1759
- ownerField = "userId",
1760
- bypassRoles = ["admin", "superadmin"]
1761
- } = options;
1762
- const ownershipMiddleware = createOwnershipCheck(ownerField, bypassRoles);
1763
- return {
1764
- name: "ownedByUser",
1765
- middlewares: {
1766
- update: [ownershipMiddleware],
1767
- delete: [ownershipMiddleware]
1768
- }
1769
- };
1770
- }
1771
-
1772
- // src/presets/multiTenant.ts
1773
- function defaultExtractOrganizationId(request) {
1774
- const context = request.context;
1775
- if (context?.organizationId) {
1776
- return context.organizationId;
1777
- }
1778
- const user = request.user;
1779
- if (user?.organizationId) {
1780
- return user.organizationId;
1781
- }
1782
- if (user?.organization) {
1783
- const org = user.organization;
1784
- return org._id || org.id || org;
1785
- }
1786
- return null;
1787
- }
1788
- function createTenantFilter(tenantField, bypassRoles, extractOrganizationId) {
1789
- return async (request, reply) => {
1790
- const user = request.user;
1791
- if (!user) {
1792
- reply.code(401).send({
1793
- success: false,
1794
- error: "Unauthorized",
1795
- message: "Authentication required for multi-tenant resources"
1796
- });
1797
- return;
1798
- }
1799
- const userWithRoles = user;
1800
- if (userWithRoles.roles && bypassRoles.some((r) => userWithRoles.roles.includes(r))) return;
1801
- const orgId = extractOrganizationId(request);
1802
- if (!orgId) {
1803
- reply.code(403).send({
1804
- success: false,
1805
- error: "Forbidden",
1806
- message: "Organization context required for this operation"
1807
- });
1808
- return;
1809
- }
1810
- request.query = request.query ?? {};
1811
- request.query._policyFilters = {
1812
- ...request.query._policyFilters ?? {},
1813
- [tenantField]: orgId
1814
- };
1815
- };
1816
- }
1817
- function createFlexibleTenantFilter(tenantField, bypassRoles, extractOrganizationId) {
1818
- return async (request, reply) => {
1819
- const user = request.user;
1820
- const orgId = extractOrganizationId(request);
1821
- if (!orgId) {
1822
- return;
1823
- }
1824
- if (!user) {
1825
- reply.code(401).send({
1826
- success: false,
1827
- error: "Unauthorized",
1828
- message: "Authentication required for organization-scoped data"
1829
- });
1830
- return;
1831
- }
1832
- const userWithRoles = user;
1833
- if (userWithRoles.roles && bypassRoles.some((r) => userWithRoles.roles.includes(r))) {
1834
- return;
1835
- }
1836
- request.query = request.query ?? {};
1837
- request.query._policyFilters = {
1838
- ...request.query._policyFilters ?? {},
1839
- [tenantField]: orgId
1840
- };
1841
- };
1842
- }
1843
- function createTenantInjection(tenantField, extractOrganizationId) {
1844
- return async (request, reply) => {
1845
- const orgId = extractOrganizationId(request);
1846
- if (!orgId) {
1847
- reply.code(403).send({
1848
- success: false,
1849
- error: "Forbidden",
1850
- message: "Organization context required to create resources"
1851
- });
1852
- return;
1853
- }
1854
- if (request.body) {
1855
- request.body[tenantField] = orgId;
1856
- }
1857
- };
1858
- }
1859
- function multiTenantPreset(options = {}) {
1860
- const {
1861
- tenantField = "organizationId",
1862
- bypassRoles = ["superadmin"],
1863
- extractOrganizationId = defaultExtractOrganizationId,
1864
- allowPublic: allowPublic2 = []
1865
- } = options;
1866
- const strictTenantFilter = createTenantFilter(tenantField, bypassRoles, extractOrganizationId);
1867
- const flexibleTenantFilter = createFlexibleTenantFilter(tenantField, bypassRoles, extractOrganizationId);
1868
- const tenantInjection = createTenantInjection(tenantField, extractOrganizationId);
1869
- const getFilter = (route) => allowPublic2.includes(route) ? flexibleTenantFilter : strictTenantFilter;
1870
- return {
1871
- name: "multiTenant",
1872
- middlewares: {
1873
- list: [getFilter("list")],
1874
- get: [getFilter("get")],
1875
- create: [tenantInjection],
1876
- update: [getFilter("update")],
1877
- delete: [getFilter("delete")]
1878
- }
1879
- };
1880
- }
1881
-
1882
- // src/presets/tree.ts
1883
- function treePreset(options = {}) {
1884
- const { parentField = "parent" } = options;
1885
- return {
1886
- name: "tree",
1887
- additionalRoutes: (permissions) => [
1888
- {
1889
- method: "GET",
1890
- path: "/tree",
1891
- handler: "getTree",
1892
- summary: "Get hierarchical tree",
1893
- permissions: permissions.list ?? allowPublic(),
1894
- wrapHandler: true
1895
- },
1896
- {
1897
- method: "GET",
1898
- path: `/:${parentField}/children`,
1899
- handler: "getChildren",
1900
- summary: "Get children of parent",
1901
- permissions: permissions.list ?? allowPublic(),
1902
- wrapHandler: true
1903
- }
1904
- ],
1905
- // Pass to controller so it knows which param to read
1906
- controllerOptions: {
1907
- parentField
1908
- }
1909
- };
1910
- }
1911
-
1912
- // src/presets/audited.ts
1913
- function auditedPreset(options = {}) {
1914
- const { createdByField = "createdBy", updatedByField = "updatedBy" } = options;
1915
- const injectCreatedBy = async (request, _reply) => {
1916
- const userWithId = request.user;
1917
- if (userWithId?._id || userWithId?.id) {
1918
- const userId = userWithId._id ?? userWithId.id;
1919
- request.body[createdByField] = userId;
1920
- request.body[updatedByField] = userId;
1921
- }
1922
- return void 0;
1923
- };
1924
- const injectUpdatedBy = async (request, _reply) => {
1925
- const userWithId = request.user;
1926
- if (userWithId?._id || userWithId?.id) {
1927
- request.body[updatedByField] = userWithId._id ?? userWithId.id;
1928
- }
1929
- return void 0;
1930
- };
1931
- return {
1932
- name: "audited",
1933
- schemaOptions: {
1934
- fieldRules: {
1935
- [createdByField]: { systemManaged: true },
1936
- [updatedByField]: { systemManaged: true },
1937
- createdAt: { systemManaged: true },
1938
- updatedAt: { systemManaged: true }
1939
- }
1940
- },
1941
- middlewares: {
1942
- create: [injectCreatedBy],
1943
- update: [injectUpdatedBy]
1944
- }
1945
- };
1946
- }
1947
-
1948
- // src/presets/index.ts
1949
- var presetRegistry = {
1950
- softDelete: softDeletePreset,
1951
- slugLookup: slugLookupPreset,
1952
- ownedByUser: ownedByUserPreset,
1953
- multiTenant: multiTenantPreset,
1954
- tree: treePreset,
1955
- audited: auditedPreset
1956
- };
1957
- function resolvePreset(name, options = {}) {
1958
- const factory = presetRegistry[name];
1959
- if (!factory) {
1960
- const available = Object.keys(presetRegistry).join(", ");
1961
- throw new Error(
1962
- `Unknown preset: '${name}'
1963
- Available presets: ${available}
1964
- Docs: https://github.com/classytic/arc#presets`
1965
- );
1966
- }
1967
- return factory(options);
1968
- }
1969
- function getAvailablePresets() {
1970
- return Object.keys(presetRegistry);
1971
- }
1972
- function applyPresets(config, presets = []) {
1973
- let result = { ...config };
1974
- for (const preset of presets) {
1975
- const resolved = resolvePresetInput(preset);
1976
- result = mergePreset(result, resolved);
1977
- }
1978
- return result;
1979
- }
1980
- function resolvePresetInput(preset) {
1981
- if (typeof preset === "object" && ("middlewares" in preset || "additionalRoutes" in preset)) {
1982
- return preset;
1983
- }
1984
- if (typeof preset === "object" && "name" in preset) {
1985
- const { name, ...options } = preset;
1986
- return resolvePreset(name, options);
1987
- }
1988
- return resolvePreset(preset);
1989
- }
1990
- function mergePreset(config, preset) {
1991
- const result = { ...config };
1992
- if (preset.additionalRoutes) {
1993
- const routes = typeof preset.additionalRoutes === "function" ? preset.additionalRoutes(config.permissions ?? {}) : preset.additionalRoutes;
1994
- result.additionalRoutes = [
1995
- ...result.additionalRoutes ?? [],
1996
- ...routes
1997
- ];
1998
- }
1999
- if (preset.middlewares) {
2000
- result.middlewares = result.middlewares ?? {};
2001
- for (const [op, mws] of Object.entries(preset.middlewares)) {
2002
- const key = op;
2003
- result.middlewares[key] = [
2004
- ...result.middlewares[key] ?? [],
2005
- ...mws ?? []
2006
- ];
2007
- }
2008
- }
2009
- if (preset.schemaOptions) {
2010
- result.schemaOptions = {
2011
- ...result.schemaOptions,
2012
- ...preset.schemaOptions
2013
- };
2014
- }
2015
- if (preset.controllerOptions) {
2016
- result._controllerOptions = {
2017
- ...result._controllerOptions,
2018
- ...preset.controllerOptions
2019
- };
2020
- }
2021
- if (preset.hooks && preset.hooks.length > 0) {
2022
- result._hooks = result._hooks ?? [];
2023
- for (const hook of preset.hooks) {
2024
- result._hooks.push({
2025
- presetName: preset.name,
2026
- operation: hook.operation,
2027
- phase: hook.phase,
2028
- handler: hook.handler,
2029
- priority: hook.priority
2030
- });
2031
- }
2032
- }
2033
- return result;
2034
- }
2035
-
2036
- // src/registry/ResourceRegistry.ts
2037
- var ResourceRegistry = class {
2038
- _resources;
2039
- _frozen;
2040
- constructor() {
2041
- this._resources = /* @__PURE__ */ new Map();
2042
- this._frozen = false;
2043
- }
2044
- /**
2045
- * Register a resource
2046
- */
2047
- register(resource, options = {}) {
2048
- if (this._frozen) {
2049
- throw new Error(
2050
- `Registry frozen. Cannot register '${resource.name}' after startup.`
2051
- );
2052
- }
2053
- if (this._resources.has(resource.name)) {
2054
- throw new Error(`Resource '${resource.name}' already registered.`);
2055
- }
2056
- const entry = {
2057
- name: resource.name,
2058
- displayName: resource.displayName,
2059
- tag: resource.tag,
2060
- prefix: resource.prefix,
2061
- module: options.module ?? void 0,
2062
- adapter: resource.adapter ? {
2063
- type: resource.adapter.type,
2064
- name: resource.adapter.name
2065
- } : null,
2066
- permissions: resource.permissions,
2067
- presets: resource._appliedPresets ?? [],
2068
- routes: [],
2069
- // Populated later by getIntrospection()
2070
- additionalRoutes: resource.additionalRoutes.map((r) => ({
2071
- method: r.method,
2072
- path: r.path,
2073
- handler: typeof r.handler === "string" ? r.handler : r.handler.name || "anonymous",
2074
- summary: r.summary,
2075
- description: r.description,
2076
- permissions: r.permissions,
2077
- wrapHandler: r.wrapHandler,
2078
- schema: r.schema
2079
- // Include schema for OpenAPI docs
2080
- })),
2081
- events: Object.keys(resource.events ?? {}),
2082
- registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
2083
- disableDefaultRoutes: resource.disableDefaultRoutes,
2084
- openApiSchemas: options.openApiSchemas,
2085
- plugin: resource.toPlugin()
2086
- // Store plugin factory
2087
- };
2088
- this._resources.set(resource.name, entry);
2089
- return this;
2090
- }
2091
- /**
2092
- * Get resource by name
2093
- */
2094
- get(name) {
2095
- return this._resources.get(name);
2096
- }
2097
- /**
2098
- * Get all resources
2099
- */
2100
- getAll() {
2101
- return Array.from(this._resources.values());
2102
- }
2103
- /**
2104
- * Get resources by module
2105
- */
2106
- getByModule(moduleName) {
2107
- return this.getAll().filter((r) => r.module === moduleName);
2108
- }
2109
- /**
2110
- * Get resources by preset
2111
- */
2112
- getByPreset(presetName) {
2113
- return this.getAll().filter((r) => r.presets.includes(presetName));
2114
- }
2115
- /**
2116
- * Check if resource exists
2117
- */
2118
- has(name) {
2119
- return this._resources.has(name);
2120
- }
2121
- /**
2122
- * Get registry statistics
2123
- */
2124
- getStats() {
2125
- const resources = this.getAll();
2126
- const presetCounts = {};
2127
- for (const r of resources) {
2128
- for (const preset of r.presets) {
2129
- presetCounts[preset] = (presetCounts[preset] ?? 0) + 1;
2130
- }
2131
- }
2132
- return {
2133
- totalResources: resources.length,
2134
- byModule: this._groupBy(resources, "module"),
2135
- presetUsage: presetCounts,
2136
- totalRoutes: resources.reduce((sum, r) => {
2137
- const defaultRouteCount = r.disableDefaultRoutes ? 0 : 5;
2138
- return sum + (r.additionalRoutes?.length ?? 0) + defaultRouteCount;
2139
- }, 0),
2140
- totalEvents: resources.reduce((sum, r) => sum + (r.events?.length ?? 0), 0)
2141
- };
2142
- }
2143
- /**
2144
- * Get full introspection data
2145
- */
2146
- getIntrospection() {
2147
- return {
2148
- resources: this.getAll().map((r) => {
2149
- const defaultRoutes = r.disableDefaultRoutes ? [] : [
2150
- { method: "GET", path: r.prefix, operation: "list" },
2151
- { method: "GET", path: `${r.prefix}/:id`, operation: "get" },
2152
- { method: "POST", path: r.prefix, operation: "create" },
2153
- { method: "PATCH", path: `${r.prefix}/:id`, operation: "update" },
2154
- { method: "DELETE", path: `${r.prefix}/:id`, operation: "delete" }
2155
- ];
2156
- return {
2157
- name: r.name,
2158
- displayName: r.displayName,
2159
- prefix: r.prefix,
2160
- module: r.module,
2161
- presets: r.presets,
2162
- permissions: r.permissions,
2163
- routes: [
2164
- ...defaultRoutes,
2165
- ...r.additionalRoutes?.map((ar) => ({
2166
- method: ar.method,
2167
- path: `${r.prefix}${ar.path}`,
2168
- operation: typeof ar.handler === "string" ? ar.handler : "custom",
2169
- handler: typeof ar.handler === "string" ? ar.handler : void 0,
2170
- summary: ar.summary
2171
- })) ?? []
2172
- ],
2173
- events: r.events
2174
- };
2175
- }),
2176
- stats: this.getStats(),
2177
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
2178
- };
2179
- }
2180
- /**
2181
- * Freeze registry (prevent further registrations)
2182
- */
2183
- freeze() {
2184
- this._frozen = true;
2185
- }
2186
- /**
2187
- * Check if frozen
2188
- */
2189
- isFrozen() {
2190
- return this._frozen;
2191
- }
2192
- /**
2193
- * Unfreeze registry (for testing)
2194
- */
2195
- _unfreeze() {
2196
- this._frozen = false;
2197
- }
2198
- /**
2199
- * Clear all resources (for testing)
2200
- */
2201
- _clear() {
2202
- this._resources.clear();
2203
- this._frozen = false;
2204
- }
2205
- /**
2206
- * Group by key
2207
- */
2208
- _groupBy(arr, key) {
2209
- const result = {};
2210
- for (const item of arr) {
2211
- const k = String(item[key] ?? "uncategorized");
2212
- result[k] = (result[k] ?? 0) + 1;
2213
- }
2214
- return result;
2215
- }
2216
- };
2217
- var registryKey = /* @__PURE__ */ Symbol.for("arc.resourceRegistry");
2218
- var globalScope = globalThis;
2219
- var resourceRegistry = globalScope[registryKey] ?? new ResourceRegistry();
2220
- if (!globalScope[registryKey]) {
2221
- globalScope[registryKey] = resourceRegistry;
2222
- }
2223
-
2224
- // src/core/validateResourceConfig.ts
2225
- function validateResourceConfig(config, options = {}) {
2226
- const errors = [];
2227
- const warnings = [];
2228
- if (!config.name) {
2229
- errors.push({
2230
- field: "name",
2231
- message: "Resource name is required",
2232
- suggestion: 'Add a unique resource name (e.g., "product", "user")'
2233
- });
2234
- } else if (!/^[a-z][a-z0-9-]*$/i.test(config.name)) {
2235
- errors.push({
2236
- field: "name",
2237
- message: `Invalid resource name "${config.name}"`,
2238
- suggestion: "Use alphanumeric characters and hyphens, starting with a letter"
2239
- });
2240
- }
2241
- const crudRoutes = ["list", "get", "create", "update", "delete"];
2242
- const disabledRoutes = new Set(config.disabledRoutes ?? []);
2243
- const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
2244
- const hasCrudRoutes = !config.disableDefaultRoutes && enabledCrudRoutes.length > 0;
2245
- if (hasCrudRoutes) {
2246
- if (!config.adapter) {
2247
- errors.push({
2248
- field: "adapter",
2249
- message: "Data adapter is required when CRUD routes are enabled",
2250
- suggestion: "Provide an adapter: createMongooseAdapter({ model, repository })"
2251
- });
2252
- } else if (!config.adapter.repository) {
2253
- errors.push({
2254
- field: "adapter.repository",
2255
- message: "Adapter must provide a repository",
2256
- suggestion: "Ensure your adapter returns a valid CrudRepository"
2257
- });
2258
- }
2259
- if (!config.controller) {
2260
- warnings.push({
2261
- field: "controller",
2262
- message: "No controller provided, will auto-create BaseController"
2263
- });
2264
- }
2265
- } else {
2266
- if (!config.adapter && !config.additionalRoutes?.length) {
2267
- warnings.push({
2268
- field: "config",
2269
- message: "Resource has no adapter and no additionalRoutes",
2270
- suggestion: "Provide either adapter for CRUD or additionalRoutes for custom logic"
2271
- });
2272
- }
2273
- }
2274
- if (config.controller && !options.skipControllerCheck && !config.disableDefaultRoutes) {
2275
- const ctrl = config.controller;
2276
- const requiredMethods = ["list", "get", "create", "update", "delete"];
2277
- for (const method of requiredMethods) {
2278
- if (typeof ctrl[method] !== "function") {
2279
- errors.push({
2280
- field: `controller.${method}`,
2281
- message: `Missing required CRUD method "${method}"`,
2282
- suggestion: "Extend BaseController which implements IController interface"
2283
- });
2284
- }
2285
- }
2286
- }
2287
- if (config.controller && config.additionalRoutes) {
2288
- validateAdditionalRouteHandlers(config.controller, config.additionalRoutes, errors);
2289
- }
2290
- if (config.permissions) {
2291
- validatePermissionKeys(config, options, errors, warnings);
2292
- }
2293
- if (config.presets && !options.allowUnknownPresets) {
2294
- validatePresets(config.presets, errors, warnings);
2295
- }
2296
- if (config.prefix) {
2297
- if (!config.prefix.startsWith("/")) {
2298
- errors.push({
2299
- field: "prefix",
2300
- message: `Prefix must start with "/" (got "${config.prefix}")`,
2301
- suggestion: `Change to "/${config.prefix}"`
2302
- });
2303
- }
2304
- if (config.prefix.endsWith("/") && config.prefix !== "/") {
2305
- warnings.push({
2306
- field: "prefix",
2307
- message: `Prefix should not end with "/" (got "${config.prefix}")`,
2308
- suggestion: `Change to "${config.prefix.slice(0, -1)}"`
2309
- });
2310
- }
2311
- }
2312
- if (config.additionalRoutes) {
2313
- validateAdditionalRoutes(config.additionalRoutes, errors);
2314
- }
2315
- return {
2316
- valid: errors.length === 0,
2317
- errors,
2318
- warnings
2319
- };
2320
- }
2321
- function validateAdditionalRouteHandlers(controller, routes, errors) {
2322
- const ctrl = controller;
2323
- for (const route of routes) {
2324
- if (typeof route.handler === "string") {
2325
- if (typeof ctrl[route.handler] !== "function") {
2326
- errors.push({
2327
- field: `additionalRoutes[${route.method} ${route.path}]`,
2328
- message: `Handler "${route.handler}" not found on controller`,
2329
- suggestion: `Add method "${route.handler}" to controller or use a function handler`
2330
- });
2331
- }
2332
- }
2333
- }
2334
- }
2335
- function validatePermissionKeys(config, options, errors, warnings) {
2336
- const validKeys = /* @__PURE__ */ new Set([
2337
- "list",
2338
- "get",
2339
- "create",
2340
- "update",
2341
- "delete",
2342
- ...options.additionalPermissionKeys ?? []
2343
- ]);
2344
- for (const route of config.additionalRoutes ?? []) {
2345
- if (typeof route.handler === "string") {
2346
- validKeys.add(route.handler);
2347
- }
2348
- }
2349
- for (const preset of config.presets ?? []) {
2350
- const presetName = typeof preset === "string" ? preset : preset.name;
2351
- if (presetName === "softDelete") {
2352
- validKeys.add("deleted");
2353
- validKeys.add("restore");
2354
- }
2355
- if (presetName === "slugLookup") {
2356
- validKeys.add("getBySlug");
2357
- }
2358
- if (presetName === "tree") {
2359
- validKeys.add("tree");
2360
- validKeys.add("children");
2361
- validKeys.add("getTree");
2362
- validKeys.add("getChildren");
2363
- }
2364
- }
2365
- for (const key of Object.keys(config.permissions ?? {})) {
2366
- if (!validKeys.has(key)) {
2367
- warnings.push({
2368
- field: `permissions.${key}`,
2369
- message: `Unknown permission key "${key}"`,
2370
- suggestion: `Valid keys: ${Array.from(validKeys).join(", ")}`
2371
- });
2372
- }
2373
- }
2374
- }
2375
- function validatePresets(presets, errors, warnings) {
2376
- const availablePresets = getAvailablePresets();
2377
- for (const preset of presets) {
2378
- if (typeof preset === "object" && ("middlewares" in preset || "additionalRoutes" in preset)) {
2379
- continue;
2380
- }
2381
- const presetName = typeof preset === "string" ? preset : preset.name;
2382
- if (!availablePresets.includes(presetName)) {
2383
- errors.push({
2384
- field: "presets",
2385
- message: `Unknown preset "${presetName}"`,
2386
- suggestion: `Available presets: ${availablePresets.join(", ")}`
2387
- });
2388
- }
2389
- if (typeof preset === "object") {
2390
- validatePresetOptions(preset, warnings);
2391
- }
2392
- }
2393
- }
2394
- function validatePresetOptions(preset, warnings) {
2395
- const knownOptions = {
2396
- slugLookup: ["slugField"],
2397
- tree: ["parentField"],
2398
- softDelete: ["deletedField"],
2399
- ownedByUser: ["ownerField", "bypassRoles"],
2400
- multiTenant: ["tenantField", "bypassRoles"]
2401
- };
2402
- const validOptions = knownOptions[preset.name] ?? [];
2403
- const providedOptions = Object.keys(preset).filter((k) => k !== "name");
2404
- for (const opt of providedOptions) {
2405
- if (!validOptions.includes(opt)) {
2406
- warnings.push({
2407
- field: `presets[${preset.name}].${opt}`,
2408
- message: `Unknown option "${opt}" for preset "${preset.name}"`,
2409
- suggestion: validOptions.length > 0 ? `Valid options: ${validOptions.join(", ")}` : `Preset "${preset.name}" has no configurable options`
2410
- });
2411
- }
2412
- }
2413
- }
2414
- function validateAdditionalRoutes(routes, errors) {
2415
- const validMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"];
2416
- const seenRoutes = /* @__PURE__ */ new Set();
2417
- for (const [i, route] of routes.entries()) {
2418
- if (!validMethods.includes(route.method)) {
2419
- errors.push({
2420
- field: `additionalRoutes[${i}].method`,
2421
- message: `Invalid HTTP method "${route.method}"`,
2422
- suggestion: `Valid methods: ${validMethods.join(", ")}`
2423
- });
2424
- }
2425
- if (!route.path) {
2426
- errors.push({
2427
- field: `additionalRoutes[${i}].path`,
2428
- message: "Route path is required"
2429
- });
2430
- } else if (!route.path.startsWith("/")) {
2431
- errors.push({
2432
- field: `additionalRoutes[${i}].path`,
2433
- message: `Route path must start with "/" (got "${route.path}")`,
2434
- suggestion: `Change to "/${route.path}"`
2435
- });
2436
- }
2437
- if (!route.handler) {
2438
- errors.push({
2439
- field: `additionalRoutes[${i}].handler`,
2440
- message: "Route handler is required"
2441
- });
2442
- }
2443
- const routeKey = `${route.method} ${route.path}`;
2444
- if (seenRoutes.has(routeKey)) {
2445
- errors.push({
2446
- field: `additionalRoutes[${i}]`,
2447
- message: `Duplicate route "${routeKey}"`
2448
- });
2449
- }
2450
- seenRoutes.add(routeKey);
2451
- }
2452
- }
2453
- function formatValidationErrors(resourceName, result) {
2454
- const lines = [];
2455
- if (result.errors.length > 0) {
2456
- lines.push(`Resource "${resourceName}" validation failed:`);
2457
- lines.push("");
2458
- lines.push("ERRORS:");
2459
- for (const err of result.errors) {
2460
- lines.push(` ✗ ${err.field}: ${err.message}`);
2461
- if (err.suggestion) {
2462
- lines.push(` → ${err.suggestion}`);
2463
- }
2464
- }
2465
- }
2466
- if (result.warnings.length > 0) {
2467
- if (lines.length > 0) lines.push("");
2468
- lines.push("WARNINGS:");
2469
- for (const warn of result.warnings) {
2470
- lines.push(` ⚠ ${warn.field}: ${warn.message}`);
2471
- if (warn.suggestion) {
2472
- lines.push(` → ${warn.suggestion}`);
2473
- }
2474
- }
2475
- }
2476
- return lines.join("\n");
2477
- }
2478
- function assertValidConfig(config, options) {
2479
- const result = validateResourceConfig(config, options);
2480
- if (!result.valid) {
2481
- const errorMsg = formatValidationErrors(config.name ?? "unknown", result);
2482
- throw new Error(errorMsg);
2483
- }
2484
- if (result.warnings.length > 0 && process.env.NODE_ENV !== "production") {
2485
- console.warn(formatValidationErrors(config.name ?? "unknown", {
2486
- errors: [],
2487
- warnings: result.warnings
2488
- }));
2489
- }
2490
- }
2491
-
2492
- // src/core/defineResource.ts
2493
- function defineResource(config) {
2494
- if (!config.skipValidation) {
2495
- assertValidConfig(config, { skipControllerCheck: true });
2496
- if (config.permissions) {
2497
- for (const [key, value] of Object.entries(config.permissions)) {
2498
- if (value !== void 0 && typeof value !== "function") {
2499
- throw new Error(
2500
- `[Arc] Resource '${config.name}': permissions.${key} must be a PermissionCheck function.
2501
- Use allowPublic(), requireAuth(), or requireRoles(['role']) from @classytic/arc/permissions.`
2502
- );
2503
- }
2504
- }
2505
- }
2506
- for (const route of config.additionalRoutes ?? []) {
2507
- if (typeof route.permissions !== "function") {
2508
- throw new Error(
2509
- `[Arc] Resource '${config.name}' route ${route.method} ${route.path}: permissions is required and must be a PermissionCheck function.
2510
- Use allowPublic() or requireAuth() from @classytic/arc/permissions.`
2511
- );
2512
- }
2513
- if (typeof route.wrapHandler !== "boolean") {
2514
- throw new Error(
2515
- `[Arc] Resource '${config.name}' route ${route.method} ${route.path}: wrapHandler is required.
2516
- Set true for ControllerHandler (context object) or false for FastifyHandler (req, reply).`
2517
- );
2518
- }
2519
- }
2520
- }
2521
- const repository = config.adapter?.repository;
2522
- const crudRoutes = ["list", "get", "create", "update", "delete"];
2523
- const disabledRoutes = new Set(config.disabledRoutes ?? []);
2524
- const hasCrudRoutes = !config.disableDefaultRoutes && crudRoutes.some((route) => !disabledRoutes.has(route));
2525
- let controller = config.controller;
2526
- if (!controller && hasCrudRoutes && repository) {
2527
- controller = new BaseController(repository, {
2528
- resourceName: config.name,
2529
- schemaOptions: config.schemaOptions,
2530
- queryParser: config.queryParser
2531
- });
2532
- }
2533
- const originalPresets = (config.presets ?? []).map(
2534
- (p) => typeof p === "string" ? p : p.name
2535
- );
2536
- const resolvedConfig = config.presets?.length ? applyPresets(config, config.presets) : config;
2537
- resolvedConfig._appliedPresets = originalPresets;
2538
- if (controller) {
2539
- const ctrl = controller;
2540
- if (typeof ctrl._setResourceOptions === "function") {
2541
- ctrl._setResourceOptions({
2542
- schemaOptions: resolvedConfig.schemaOptions,
2543
- presetFields: resolvedConfig._controllerOptions ? {
2544
- slugField: resolvedConfig._controllerOptions.slugField,
2545
- parentField: resolvedConfig._controllerOptions.parentField
2546
- } : void 0,
2547
- resourceName: resolvedConfig.name,
2548
- queryParser: resolvedConfig.queryParser
2549
- });
2550
- }
2551
- }
2552
- const resource = new ResourceDefinition({
2553
- ...resolvedConfig,
2554
- adapter: config.adapter,
2555
- controller
2556
- });
2557
- if (!config.skipValidation && controller) {
2558
- resource._validateControllerMethods();
2559
- }
2560
- if (resolvedConfig._hooks?.length) {
2561
- for (const hook of resolvedConfig._hooks) {
2562
- hookSystem.register({
2563
- resource: resolvedConfig.name,
2564
- operation: hook.operation,
2565
- phase: hook.phase,
2566
- handler: hook.handler,
2567
- priority: hook.priority ?? 10
2568
- });
2569
- }
2570
- }
2571
- if (!config.skipRegistry) {
2572
- try {
2573
- let openApiSchemas = config.openApiSchemas;
2574
- if (!openApiSchemas && config.adapter?.generateSchemas) {
2575
- const generated = config.adapter.generateSchemas(config.schemaOptions);
2576
- if (generated) {
2577
- openApiSchemas = generated;
2578
- }
2579
- }
2580
- const queryParser = config.queryParser;
2581
- if (!openApiSchemas?.listQuery && queryParser?.getQuerySchema) {
2582
- const querySchema = queryParser.getQuerySchema();
2583
- if (querySchema) {
2584
- openApiSchemas = {
2585
- ...openApiSchemas,
2586
- listQuery: querySchema
2587
- };
2588
- }
2589
- }
2590
- resourceRegistry.register(resource, {
2591
- module: config.module,
2592
- openApiSchemas
2593
- });
2594
- } catch {
2595
- }
2596
- }
2597
- return resource;
2598
- }
2599
- var ResourceDefinition = class {
2600
- // Identity
2601
- name;
2602
- displayName;
2603
- tag;
2604
- prefix;
2605
- // Adapter (database abstraction) - optional for service resources
2606
- adapter;
2607
- // Controller
2608
- controller;
2609
- // Schema & Validation
2610
- schemaOptions;
2611
- customSchemas;
2612
- // Security
2613
- permissions;
2614
- // Customization
2615
- additionalRoutes;
2616
- middlewares;
2617
- disableDefaultRoutes;
2618
- disabledRoutes;
2619
- organizationScoped;
2620
- // Events
2621
- events;
2622
- // Presets tracking
2623
- _appliedPresets;
2624
- constructor(config) {
2625
- this.name = config.name;
2626
- this.displayName = config.displayName ?? capitalize(config.name) + "s";
2627
- this.tag = config.tag ?? this.displayName;
2628
- this.prefix = config.prefix ?? `/${config.name}s`;
2629
- this.adapter = config.adapter;
2630
- this.controller = config.controller;
2631
- this.schemaOptions = config.schemaOptions ?? {};
2632
- this.customSchemas = config.customSchemas ?? {};
2633
- this.permissions = config.permissions ?? {};
2634
- this.additionalRoutes = config.additionalRoutes ?? [];
2635
- this.middlewares = config.middlewares ?? {};
2636
- this.disableDefaultRoutes = config.disableDefaultRoutes ?? false;
2637
- this.disabledRoutes = config.disabledRoutes ?? [];
2638
- this.organizationScoped = config.organizationScoped ?? false;
2639
- this.events = config.events ?? {};
2640
- this._appliedPresets = config._appliedPresets ?? [];
2641
- }
2642
- /** Get repository from adapter (if available) */
2643
- get repository() {
2644
- return this.adapter?.repository;
2645
- }
2646
- /** Get model from adapter (if available) */
2647
- get model() {
2648
- if (!this.adapter) return void 0;
2649
- return this.adapter.getSchemaMetadata?.() ? this.adapter.model : void 0;
2650
- }
2651
- _validateControllerMethods() {
2652
- const errors = [];
2653
- const crudRoutes = ["list", "get", "create", "update", "delete"];
2654
- const disabledRoutes = new Set(this.disabledRoutes ?? []);
2655
- const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
2656
- const hasCrudRoutes = !this.disableDefaultRoutes && enabledCrudRoutes.length > 0;
2657
- if (hasCrudRoutes) {
2658
- if (!this.controller) {
2659
- errors.push("Controller is required when CRUD routes are enabled");
2660
- } else {
2661
- const ctrl = this.controller;
2662
- for (const method of enabledCrudRoutes) {
2663
- if (typeof ctrl[method] !== "function") {
2664
- errors.push(`CRUD method '${method}' not found on controller`);
2665
- }
2666
- }
2667
- }
2668
- }
2669
- for (const route of this.additionalRoutes) {
2670
- if (typeof route.handler === "string") {
2671
- if (!this.controller) {
2672
- errors.push(
2673
- `Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller`
2674
- );
2675
- } else {
2676
- const ctrl = this.controller;
2677
- if (typeof ctrl[route.handler] !== "function") {
2678
- errors.push(
2679
- `Route ${route.method} ${route.path}: handler '${route.handler}' not found`
2680
- );
2681
- }
2682
- }
2683
- }
2684
- }
2685
- if (errors.length > 0) {
2686
- const errorMsg = [
2687
- `Resource '${this.name}' validation failed:`,
2688
- ...errors.map((e) => ` - ${e}`),
2689
- "",
2690
- "Ensure controller implements IController<TDoc> interface.",
2691
- "For preset routes (softDelete, tree), add corresponding methods to controller."
2692
- ].join("\n");
2693
- throw new Error(errorMsg);
2694
- }
2695
- }
2696
- toPlugin() {
2697
- const self = this;
2698
- return async function resourcePlugin(fastify, _opts) {
2699
- await fastify.register(async (instance) => {
2700
- const typedInstance = instance;
2701
- let schemas = null;
2702
- if (self.adapter) {
2703
- const metadata = self.adapter.getSchemaMetadata?.();
2704
- if (metadata && typedInstance.generateSchemas) {
2705
- const model = self.adapter.model;
2706
- if (model && typeof typedInstance.generateSchemas === "function") {
2707
- schemas = typedInstance.generateSchemas(model, self.schemaOptions);
2708
- }
2709
- }
2710
- }
2711
- if (self.customSchemas && Object.keys(self.customSchemas).length > 0) {
2712
- schemas = schemas ?? {};
2713
- for (const [op, customSchema] of Object.entries(self.customSchemas)) {
2714
- const key = op;
2715
- schemas[key] = schemas[key] ? deepMergeSchemas(schemas[key], customSchema) : customSchema;
2716
- }
2717
- }
2718
- const resolvedRoutes = self.additionalRoutes;
2719
- createCrudRouter(typedInstance, self.controller, {
2720
- tag: self.tag,
2721
- schemas: schemas ?? void 0,
2722
- permissions: self.permissions,
2723
- middlewares: self.middlewares,
2724
- additionalRoutes: resolvedRoutes,
2725
- disableDefaultRoutes: self.disableDefaultRoutes,
2726
- disabledRoutes: self.disabledRoutes,
2727
- organizationScoped: self.organizationScoped,
2728
- resourceName: self.name,
2729
- schemaOptions: self.schemaOptions
2730
- });
2731
- if (self.events && Object.keys(self.events).length > 0) {
2732
- typedInstance.log?.info?.(
2733
- `Resource '${self.name}' defined ${Object.keys(self.events).length} events`
2734
- );
2735
- }
2736
- }, { prefix: self.prefix });
2737
- };
2738
- }
2739
- /**
2740
- * Get event definitions for registry
2741
- */
2742
- getEvents() {
2743
- return Object.entries(this.events).map(([action, meta]) => ({
2744
- name: `${this.name}:${action}`,
2745
- module: this.name,
2746
- schema: meta.schema,
2747
- description: meta.description
2748
- }));
2749
- }
2750
- /**
2751
- * Get resource metadata
2752
- */
2753
- getMetadata() {
2754
- return {
2755
- name: this.name,
2756
- displayName: this.displayName,
2757
- tag: this.tag,
2758
- prefix: this.prefix,
2759
- presets: this._appliedPresets,
2760
- permissions: this.permissions,
2761
- additionalRoutes: this.additionalRoutes,
2762
- routes: [],
2763
- // Populated at runtime during registration
2764
- events: Object.keys(this.events)
2765
- };
2766
- }
2767
- };
2768
- function deepMergeSchemas(base, override) {
2769
- if (!override) return base;
2770
- if (!base) return override;
2771
- const result = { ...base };
2772
- for (const [key, value] of Object.entries(override)) {
2773
- if (value && typeof value === "object" && !Array.isArray(value)) {
2774
- result[key] = deepMergeSchemas(result[key], value);
2775
- } else {
2776
- result[key] = value;
2777
- }
2778
- }
2779
- return result;
2780
- }
2781
- function capitalize(str) {
2782
- if (!str) return "";
2783
- return str.charAt(0).toUpperCase() + str.slice(1);
2784
- }
2785
-
2786
- export { BaseController, ResourceDefinition, createActionRouter, createCrudHandlers, createCrudRouter, createFastifyHandler, createOrgScopedMiddleware, createPermissionMiddleware, createRequestContext, defineResource, sendControllerResponse };