@classytic/arc 2.41.1 → 2.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/dist/{EventTransport-CbUKDzZZ.d.mts → EventTransport-CiicKz2Z.d.mts} +1 -1
  2. package/dist/{EventTransport-DSqD_Xsg.mjs → EventTransport-tzA7CAB4.mjs} +22 -17
  3. package/dist/{HookSystem-CMF94SEA.mjs → HookSystem-B6UaKRbF.mjs} +2 -2
  4. package/dist/{QueryCache-DFA-QrU9.d.mts → QueryCache--5uAuYPW.d.mts} +7 -3
  5. package/dist/{ResourceRegistry-MGpXoxHZ.mjs → ResourceRegistry-CuCARu2B.mjs} +44 -13
  6. package/dist/actionPermissions-BotX1YOa.mjs +776 -0
  7. package/dist/audit/index.d.mts +43 -5
  8. package/dist/audit/index.mjs +48 -26
  9. package/dist/auth/audit.mjs +1 -1
  10. package/dist/auth/index.d.mts +43 -13
  11. package/dist/auth/index.mjs +130 -155
  12. package/dist/auth/redis-session.mjs +1 -1
  13. package/dist/{authorizationDecision-DawKGoXb.mjs → authorizationDecision-DQYrkl5f.mjs} +7 -102
  14. package/dist/{base-CxrV1LmG.d.mts → base-CZyixEQR.d.mts} +27 -5
  15. package/dist/boundedMap-BULzfXYU.mjs +57 -0
  16. package/dist/{buildHandler-CvxKOc0m.mjs → buildHandler-CRQ8EcX5.mjs} +4 -4
  17. package/dist/cache/index.d.mts +22 -34
  18. package/dist/cache/index.mjs +4 -4
  19. package/dist/{caching-DbCBxlgw.mjs → caching-Cc2M1DwO.mjs} +3 -3
  20. package/dist/claimed-B10RTfkM.mjs +8 -0
  21. package/dist/cleanup/index.d.mts +6 -10
  22. package/dist/cleanup/index.mjs +27 -73
  23. package/dist/cli/commands/describe.d.mts +4 -2
  24. package/dist/cli/commands/describe.mjs +7 -4
  25. package/dist/cli/commands/docs.mjs +3 -3
  26. package/dist/cli/commands/doctor.mjs +1 -1
  27. package/dist/cli/commands/generate.mjs +2 -2
  28. package/dist/cli/commands/init.mjs +2 -2
  29. package/dist/cli/commands/introspect.mjs +2 -2
  30. package/dist/cli/index.mjs +3 -3
  31. package/dist/{constants-D5Ubn1fe.mjs → constants-Cu7Mx7_U.mjs} +4 -2
  32. package/dist/context/index.d.mts +2 -5
  33. package/dist/context/index.mjs +18 -38
  34. package/dist/core/index.d.mts +3 -3
  35. package/dist/core/index.mjs +8 -8
  36. package/dist/{core-5lazhva9.mjs → core-Cb0LjccA.mjs} +5 -4
  37. package/dist/{createActionRouter-DNqoaK81.mjs → createActionRouter-xjqonMRO.mjs} +87 -39
  38. package/dist/{createAggregationRouter-BcbQJf10.mjs → createAggregationRouter-zRYWw_5D.mjs} +11 -8
  39. package/dist/{preloadResources-f3boenVZ.mjs → createApp-D-953ZAq.mjs} +299 -390
  40. package/dist/{createMcpServer-CbDWkZ1T.d.mts → createMcpServer-C9cxS8zt.d.mts} +1 -1
  41. package/dist/{defineAggregation-D_eJjm5A.mjs → defineAggregation-Bm8RjMbq.mjs} +1 -1
  42. package/dist/{defineResource-DxkMTjRn.mjs → defineResource-DIC7JZGU.mjs} +118 -1024
  43. package/dist/discovery/index.mjs +1 -1
  44. package/dist/docs/index.d.mts +10 -4
  45. package/dist/docs/index.mjs +4 -4
  46. package/dist/{elevation-BXCfVcNG.d.mts → elevation-BW2J3tNj.d.mts} +1 -2
  47. package/dist/{elevation-CkvG0LG2.mjs → elevation-DG8SrLN-.mjs} +41 -12
  48. package/dist/encryption/index.mjs +3 -3
  49. package/dist/{errorHandler-D1GHKGlV.mjs → errorHandler-BnfDviL1.mjs} +26 -5
  50. package/dist/{errors-BH7YU_9O.mjs → errors-CtrywMHP.mjs} +58 -23
  51. package/dist/{errors-BGdVwP25.d.mts → errors-JYc9vwqE.d.mts} +9 -0
  52. package/dist/{eventPlugin-B52abJ8W.d.mts → eventPlugin-BtmA4-3P.d.mts} +65 -9
  53. package/dist/{eventPlugin-zZY-unhr.mjs → eventPlugin-Q6BhzHzs.mjs} +82 -21
  54. package/dist/{subscribe-helpers-DatDkcuG.mjs → eventTypes-Cdhm6ZlV.mjs} +151 -17
  55. package/dist/events/index.d.mts +33 -8
  56. package/dist/events/index.mjs +49 -53
  57. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  58. package/dist/events/transports/redis-stream-entry.mjs +139 -157
  59. package/dist/events/transports/redis.d.mts +1 -1
  60. package/dist/events/transports/redis.mjs +2 -2
  61. package/dist/factory/index.d.mts +32 -17
  62. package/dist/factory/index.mjs +213 -32
  63. package/dist/{fastify-augmentation-Cq2nHVaW.d.mts → fastify-augmentation-DhUJ-mvo.d.mts} +11 -25
  64. package/dist/{fields-I_EeRWND.d.mts → fields-BBSpf8pS.d.mts} +1 -1
  65. package/dist/{fields-BHrkoF5y.mjs → fields-ToPYpMqS.mjs} +1 -1
  66. package/dist/filter-merge-IpGywvym.mjs +99 -0
  67. package/dist/{generate-BLRf2m3y.mjs → generate-DRTkLlIl.mjs} +2 -2
  68. package/dist/{headers-BIXWKrzQ.mjs → headers-BbkIF6vA.mjs} +2 -2
  69. package/dist/health-D-Ft6JM6.mjs +22 -0
  70. package/dist/{health-ZRHeDHXH.mjs → health-DqbsG-_w.mjs} +115 -55
  71. package/dist/hooks/index.d.mts +2 -2
  72. package/dist/hooks/index.mjs +2 -2
  73. package/dist/idempotency/index.d.mts +31 -7
  74. package/dist/idempotency/index.mjs +155 -98
  75. package/dist/idempotency/redis.d.mts +1 -1
  76. package/dist/idempotency/redis.mjs +48 -11
  77. package/dist/{index-BHdZD8zK.d.mts → index-Bxriq3so.d.mts} +84 -21
  78. package/dist/{preloadResources-CjS2OfpU.d.mts → index-C8zVGYsk.d.mts} +37 -38
  79. package/dist/{index-D4u76eQW.d.mts → index-CRoPtISf.d.mts} +23 -73
  80. package/dist/{index-CbJiYrfv.d.mts → index-Chgz3d0i.d.mts} +1 -1
  81. package/dist/index-JdbZAIsm.d.mts +510 -0
  82. package/dist/{index-DHbyPo5j.d.mts → index-mqaqZNpe.d.mts} +253 -254
  83. package/dist/index.d.mts +8 -8
  84. package/dist/index.mjs +11 -11
  85. package/dist/{init-CpLkjU18.mjs → init-B_oUYDXI.mjs} +1 -1
  86. package/dist/integrations/event-gateway.d.mts +8 -3
  87. package/dist/integrations/event-gateway.mjs +4 -4
  88. package/dist/integrations/index.d.mts +3 -3
  89. package/dist/integrations/index.mjs +1 -1
  90. package/dist/integrations/jobs/index.d.mts +1 -1
  91. package/dist/integrations/jobs/index.mjs +18 -5
  92. package/dist/integrations/mcp/index.d.mts +72 -59
  93. package/dist/integrations/mcp/index.mjs +19 -4
  94. package/dist/integrations/mcp/testing.d.mts +2 -2
  95. package/dist/integrations/mcp/testing.mjs +2 -2
  96. package/dist/integrations/streamline.d.mts +78 -136
  97. package/dist/integrations/streamline.mjs +116 -54
  98. package/dist/integrations/webhooks.d.mts +60 -120
  99. package/dist/integrations/webhooks.mjs +36 -80
  100. package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
  101. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  102. package/dist/integrations/websocket-redis.d.mts +1 -1
  103. package/dist/integrations/websocket-redis.mjs +1 -1
  104. package/dist/integrations/websocket.d.mts +3 -3
  105. package/dist/integrations/websocket.mjs +2 -2
  106. package/dist/{interface-DEWNOMza.d.mts → interface-C0395yUN.d.mts} +13 -1
  107. package/dist/{jsonBody-ELR9lfeU.mjs → jsonBody-B4YVGcql.mjs} +2 -2
  108. package/dist/{keys-cpZCtcIE.mjs → keys-B2KtgMLJ.mjs} +7 -18
  109. package/dist/{loadResources-BPfWiI5s.mjs → loadResources-Dn4lalw2.mjs} +3 -3
  110. package/dist/{loadResourcesFromEntry-ixtfL3J2.mjs → loadResourcesFromEntry-3sLYbuse.mjs} +2 -2
  111. package/dist/logger/index.d.mts +1 -8
  112. package/dist/logger/index.mjs +3 -153
  113. package/dist/logger-DXjfIqjV.mjs +160 -0
  114. package/dist/{mcpPlugin-BX5PujI0.mjs → mcpPlugin-C08HalN5.mjs} +433 -170
  115. package/dist/{memory-Iotz0cCx.mjs → memory-9Cp7611C.mjs} +36 -34
  116. package/dist/{metrics-NMsmpRmY.mjs → metrics-CWI11eRu.mjs} +39 -72
  117. package/dist/middleware/index.d.mts +2 -2
  118. package/dist/middleware/index.mjs +3 -3
  119. package/dist/migrations/index.mjs +2 -2
  120. package/dist/{module-DpLJG2_N.mjs → module-BSvOQC7E.mjs} +8 -8
  121. package/dist/multiTenant-cY0FkDiA.d.mts +104 -0
  122. package/dist/{multipartBody-DtRWM6_U.mjs → multipartBody-CXM2a-Dy.mjs} +46 -7
  123. package/dist/{openapi-CmFf3yq_.mjs → openapi-DT_G4VhY.mjs} +48 -10
  124. package/dist/outbox-admin/index.d.mts +3 -3
  125. package/dist/outbox-admin/index.mjs +6 -18
  126. package/dist/{outbox-module-D2z5WkDY.mjs → outbox-module-Bjgzl6tg.mjs} +203 -33
  127. package/dist/{outbox-module-BHdUnuuK.d.mts → outbox-module-gZCHoE4H.d.mts} +79 -6
  128. package/dist/permissions/index.d.mts +5 -4
  129. package/dist/permissions/index.mjs +8 -6
  130. package/dist/{permissions-D8TBH44E.mjs → permissions-Cm0uYHvd.mjs} +149 -33
  131. package/dist/{pipe-FMWNheSO.mjs → pipe-BqeGT6UO.mjs} +2 -2
  132. package/dist/pipeline/index.d.mts +2 -2
  133. package/dist/pipeline/index.mjs +2 -2
  134. package/dist/plan-FVR6LP59.mjs +565 -0
  135. package/dist/{BaseController-CYEwjyjN.mjs → plugin-BccET1tL.mjs} +1256 -345
  136. package/dist/plugins/index.d.mts +14 -11
  137. package/dist/plugins/index.mjs +44 -22
  138. package/dist/plugins/response-cache.mjs +8 -14
  139. package/dist/plugins/tracing-entry.mjs +2 -2
  140. package/dist/{pluralize-CXWZIzTv.mjs → pluralize-CVIOcgMF.mjs} +1 -1
  141. package/dist/presets/filesUpload.d.mts +2 -2
  142. package/dist/presets/filesUpload.mjs +5 -5
  143. package/dist/presets/index.d.mts +26 -31
  144. package/dist/presets/index.mjs +4 -4
  145. package/dist/presets/multiTenant.d.mts +2 -107
  146. package/dist/presets/multiTenant.mjs +22 -162
  147. package/dist/presets/search.d.mts +2 -2
  148. package/dist/presets/search.mjs +2 -2
  149. package/dist/{presets-ZJSPgmT2.mjs → presets-Cpvj6fGO.mjs} +99 -107
  150. package/dist/{projection-BzIgzoKZ.mjs → projection-Ce4aQhQk.mjs} +2 -2
  151. package/dist/{pushref-registry-Dov9l4qE.d.mts → pushref-registry-UxCkoA4C.d.mts} +1 -1
  152. package/dist/{queryCachePlugin-BzqxconG.d.mts → queryCachePlugin-C2lZjTxk.d.mts} +1 -1
  153. package/dist/{queryCachePlugin-DZHPOm9p.mjs → queryCachePlugin-CFWNH8aT.mjs} +12 -14
  154. package/dist/{rateLimitKey-BvOvcBDi.mjs → rateLimitKey-C4O-kPm0.mjs} +1 -1
  155. package/dist/{realtime-8tIQWZz4.mjs → realtime-DmPE9S54.mjs} +25 -21
  156. package/dist/redis-stream-BMk-bpQS.d.mts +219 -0
  157. package/dist/{redis-Bzi1y3F9.d.mts → redis-vLvtcGy_.d.mts} +13 -24
  158. package/dist/redisShapes-CQksY67q.d.mts +55 -0
  159. package/dist/registry/index.d.mts +9 -4
  160. package/dist/registry/index.mjs +4 -4
  161. package/dist/{registry-DNkgGQcK.mjs → registry-Bcz9G1bh.mjs} +10 -8
  162. package/dist/{renewingLease-yF8BdW4b.mjs → renewingLease-qODa7KJQ.mjs} +1 -1
  163. package/dist/{replyHelpers-CNdvMfM4.mjs → replyHelpers-CG_VtVgS.mjs} +2 -2
  164. package/dist/{requestContext-cDkAUr-P.mjs → requestContext-Dsu9P1bV.mjs} +1 -1
  165. package/dist/requirement-CbiLdQ1n.d.mts +22 -0
  166. package/dist/requirement-KDdoRcOP.mjs +35 -0
  167. package/dist/resourceRateLimit-Wf6d6FL9.d.mts +30 -0
  168. package/dist/resourceRateLimit-dSjVr1pk.mjs +180 -0
  169. package/dist/{rolldown-runtime-eysyzFAF.mjs → rolldown-runtime-D1w9Y1VJ.mjs} +1 -1
  170. package/dist/routeValidator-D36SW8qt.mjs +70 -0
  171. package/dist/{runtimeCapabilities-CkhS6jn6.mjs → runtimeCapabilities-0tccjRlj.mjs} +1 -1
  172. package/dist/{schedules-j6uXXAeJ.mjs → schedules-C-OpmHBq.mjs} +46 -37
  173. package/dist/{schemaConverter-BVA7cFRG.mjs → schemaConverter-BORek3O-.mjs} +2 -2
  174. package/dist/{schemaIR-CTvPDoPB.mjs → schemaIR-C7Vbp71I.mjs} +2 -2
  175. package/dist/schemas/index.d.mts +10 -0
  176. package/dist/schemas/index.mjs +8 -3
  177. package/dist/scim/index.mjs +2 -2
  178. package/dist/scope/index.d.mts +2 -2
  179. package/dist/scope/index.mjs +6 -6
  180. package/dist/{scope-BUi1xV1I.mjs → scope-DQthe88b.mjs} +4 -4
  181. package/dist/shutdown-MqA81z_P.mjs +59 -0
  182. package/dist/{simpleEqualityMatcher-C9bzq2gO.mjs → simpleEqualityMatcher-auW8QjXN.mjs} +1 -1
  183. package/dist/{sse-BeqVKQKu.mjs → sse-CYzQBVFe.mjs} +11 -15
  184. package/dist/{sseStream-Ppzo-WUH.mjs → sseStream-B5_Afiqn.mjs} +2 -2
  185. package/dist/{store-helpers-dW71XVWL.mjs → store-helpers-BOzk-CuX.mjs} +1 -1
  186. package/dist/{streaming-IcRFjWQU.mjs → streaming-DjTQhZrN.mjs} +1 -1
  187. package/dist/subscriberIdentity-CW39uA8G.mjs +28 -0
  188. package/dist/tenancy--ed9eH_z.mjs +43 -0
  189. package/dist/tenantPreparation-BKon6Yox.mjs +67 -0
  190. package/dist/testing/cleanupStoreContract.mjs +1 -1
  191. package/dist/testing/index.d.mts +193 -175
  192. package/dist/testing/index.mjs +286 -212
  193. package/dist/testing/outboxStoreContract.mjs +137 -2
  194. package/dist/testing/requiredPorts.d.mts +17 -0
  195. package/dist/testing/requiredPorts.mjs +65 -0
  196. package/dist/testing/storageContract.mjs +1 -1
  197. package/dist/timing-qczZ0YC-.mjs +46 -0
  198. package/dist/{transactionContext-CPbdlnVw.mjs → transactionContext-DjFkrNG6.mjs} +1 -1
  199. package/dist/{typeGuards-BLjPgVBm.mjs → typeGuards-CySF12G-.mjs} +1 -1
  200. package/dist/types/index.d.mts +4 -4
  201. package/dist/types/index.mjs +1 -1
  202. package/dist/types/storage.mjs +1 -1
  203. package/dist/{types-CWQn9-IC.d.mts → types-B4Aq4Sdj.d.mts} +22 -9
  204. package/dist/{types-DfVDvkdP.d.mts → types-BJ-2XqVF.d.mts} +189 -204
  205. package/dist/{types-CgenB1gv.mjs → types-Bk9NOgOC.mjs} +4 -4
  206. package/dist/usage/index.d.mts +2 -3
  207. package/dist/usage/index.mjs +5 -5
  208. package/dist/{userHelpers-ChRvkuSZ.mjs → userHelpers-aklawRaK.mjs} +1 -1
  209. package/dist/utils/index.d.mts +84 -223
  210. package/dist/utils/index.mjs +13 -12
  211. package/dist/{utils-CxcctrAh.mjs → utils-ec1oTo1n.mjs} +315 -404
  212. package/dist/{validate-DrYWnOzI.mjs → validate-9yzHC1e2.mjs} +6 -14
  213. package/dist/{versioning-D6JIK8Tk.d.mts → versioning-Bsb51gGT.d.mts} +28 -13
  214. package/dist/{versioning-DKkT4hNJ.mjs → versioning-fhhWHV74.mjs} +4 -4
  215. package/dist/{websocket-BOXVggMf.d.mts → websocket-Cs4awVyD.d.mts} +1 -1
  216. package/dist/{workScope-CxKqoIFn.mjs → workScope-zpPyge4t.mjs} +2 -2
  217. package/package.json +16 -12
  218. package/skills/arc/SKILL.md +13 -4
  219. package/skills/arc/references/auth.md +8 -5
  220. package/skills/arc/references/integrations.md +1 -1
  221. package/skills/arc/references/mcp.md +8 -6
  222. package/skills/arc/references/multi-tenancy.md +19 -14
  223. package/skills/arc/references/production.md +27 -10
  224. package/dist/actionPermissions-BoPGQ5-4.mjs +0 -350
  225. package/dist/index-CFfLKMH4.d.mts +0 -290
  226. package/dist/interface-YzIbF7Ii.mjs +0 -17
  227. package/dist/redis-stream-BPKscsTB.d.mts +0 -260
  228. package/dist/registerRoute-Bsnk6NiZ.mjs +0 -364
  229. package/dist/sync/changelog.d.mts +0 -3
  230. package/dist/sync/changelog.mjs +0 -3
@@ -1,18 +1,28 @@
1
- /*! @classytic/arc v2.41.1 | MIT | https://classytic.com */
2
- import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-D5Ubn1fe.mjs";
3
- import { arcLog } from "./logger/index.mjs";
4
- import { a as conjoinPolicyFilters } from "./authorizationDecision-DawKGoXb.mjs";
5
- import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError, u as ValidationError } from "./errors-BH7YU_9O.mjs";
6
- import { n as getUserRoles, t as getUserId } from "./userHelpers-ChRvkuSZ.mjs";
7
- import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-CgenB1gv.mjs";
8
- import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-C9bzq2gO.mjs";
9
- import { t as buildQueryKey } from "./keys-cpZCtcIE.mjs";
10
- import { t as transactionContext } from "./transactionContext-CPbdlnVw.mjs";
11
- import { D as ArcQueryParser, j as scheduleBackground } from "./utils-CxcctrAh.mjs";
12
- import { a as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-BHrkoF5y.mjs";
1
+ /*! @classytic/arc v2.43.0 | MIT | https://classytic.com */
2
+ import { g as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-Cu7Mx7_U.mjs";
3
+ import { t as arcLog } from "./logger-DXjfIqjV.mjs";
4
+ import { t as conjoinPolicyFilters } from "./filter-merge-IpGywvym.mjs";
5
+ import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError, u as ValidationError } from "./errors-CtrywMHP.mjs";
6
+ import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-auW8QjXN.mjs";
7
+ import { n as requireSingleHeaderValue } from "./headers-BbkIF6vA.mjs";
8
+ import { n as getUserRoles, t as getUserId } from "./userHelpers-aklawRaK.mjs";
9
+ import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-Bk9NOgOC.mjs";
10
+ import { r as resolveTenancy } from "./tenancy--ed9eH_z.mjs";
11
+ import { t as buildQueryKey } from "./keys-B2KtgMLJ.mjs";
12
+ import { t as transactionContext } from "./transactionContext-DjFkrNG6.mjs";
13
+ import { a as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-ToPYpMqS.mjs";
14
+ import { E as ArcQueryParser, v as getDefaultCrudSchemas } from "./utils-ec1oTo1n.mjs";
15
+ import { n as convertRouteSchema, r as ensureSchemaConverter, t as convertOpenApiSchemas } from "./schemaConverter-BORek3O-.mjs";
16
+ import { i as pipeUIMessageStreamToReply, r as isReadableStream } from "./streaming-DjTQhZrN.mjs";
17
+ import { _ as buildArcDecorator, a as resolveRouterPluginMw, b as createRequestContext, c as buildPipelineHandler, d as buildCrudPermissionMw, f as buildRouteHooks, h as buildAuthMiddleware, i as buildRouteConfig, l as resolvePipelineSteps, m as routeHookOptions, n as tryRegisterRoute, o as selectPluginMw, p as resolveRoutePreHandlers, r as buildRateLimitConfig, t as resolveActionPermission, v as createCrudHandlers, y as createFastifyHandler } from "./actionPermissions-BotX1YOa.mjs";
18
+ import { a as withoutTenantInjection } from "./tenantPreparation-BKon6Yox.mjs";
19
+ import { t as hasEvents } from "./typeGuards-CySF12G-.mjs";
20
+ import { n as idempotencyConfigProblem } from "./requirement-KDdoRcOP.mjs";
21
+ import { ARC_ERROR_CODES } from "@classytic/repo-core/errors";
13
22
  import { policyRecordToFilter } from "@classytic/repo-core/filter";
14
23
  import { retryingTransaction } from "@classytic/repo-core/repository";
15
- import { isProductionEnv } from "@classytic/primitives/environment";
24
+ import { scheduleBackground } from "@classytic/repo-core/cache";
25
+ import { isProductionEnv, readEnv } from "@classytic/primitives/environment";
16
26
  //#region src/core/repositoryFilter.ts
17
27
  const NATIVE_FILTER_DIALECT = Symbol.for("@classytic/arc/native-filter-dialect");
18
28
  /**
@@ -115,10 +125,8 @@ var AccessControl = class {
115
125
  const arcContext = this._meta(req);
116
126
  const policyFilters = arcContext?._policyFilters;
117
127
  if (policyFilters) filter = conjoinPolicyFilters(filter, policyFilters);
118
- const crossTenantRead = arcContext?._crossTenantRead === true;
119
- const scope = arcContext?._scope;
120
- const orgId = scope ? getOrgId(scope) : void 0;
121
- if (this.tenantField && orgId && !crossTenantRead) filter = conjoinPolicyFilters(filter, { [this.tenantField]: orgId });
128
+ const tenancy = resolveTenancy(arcContext, this.tenantField);
129
+ if (tenancy.mode === "scoped") filter = conjoinPolicyFilters(filter, { [tenancy.field]: tenancy.orgId });
122
130
  return toRepositoryFilter(filter);
123
131
  }
124
132
  /**
@@ -179,14 +187,11 @@ var AccessControl = class {
179
187
  * unscoped records from leaking across tenants.
180
188
  */
181
189
  checkOrgScope(item, arcContext) {
182
- if (!this.tenantField) return true;
183
- if (arcContext?._crossTenantRead === true) return true;
184
- const scope = arcContext?._scope;
185
- const orgId = scope ? getOrgId(scope) : void 0;
186
- if (!item || !orgId) return true;
187
- const itemOrgId = item[this.tenantField];
190
+ const tenancy = resolveTenancy(arcContext, this.tenantField);
191
+ if (tenancy.mode !== "scoped" || !item) return true;
192
+ const itemOrgId = item[tenancy.field];
188
193
  if (!itemOrgId) return false;
189
- return String(itemOrgId) === String(orgId);
194
+ return String(itemOrgId) === String(tenancy.orgId);
190
195
  }
191
196
  /** Check ownership for update/delete (ownedByUser preset) */
192
197
  checkOwnership(item, req) {
@@ -342,7 +347,7 @@ var BodySanitizer = class {
342
347
  constructor(config) {
343
348
  this.schemaOptions = config.schemaOptions;
344
349
  this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
345
- this.onImmutableWrite = config.onImmutableWrite ?? (process.env.ARC_STRICT_IMMUTABLE_WRITES === "true" ? "reject" : "strip");
350
+ this.onImmutableWrite = config.onImmutableWrite ?? (readEnv(process.env).bool("ARC_STRICT_IMMUTABLE_WRITES", true) ? "reject" : "strip");
346
351
  }
347
352
  /**
348
353
  * Strip readonly and system-managed fields from request body.
@@ -424,21 +429,16 @@ function buildTenantRepoOptions(req, tenantField, arcContext) {
424
429
  const cached = req._tenantRepoOptions;
425
430
  if (cached) return cached;
426
431
  const out = {};
427
- const scope = arcContext?._scope;
428
- if (arcContext?._crossTenantRead === true) {
429
- out.bypassTenant = true;
430
- req._tenantRepoOptions = out;
431
- return out;
432
- }
433
- if (tenantField) {
434
- const orgId = scope ? getOrgId(scope) : void 0;
435
- if (orgId) out[tenantField] = orgId;
436
- }
437
- const presetFields = req._tenantFields;
438
- if (presetFields && typeof presetFields === "object") {
439
- for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
432
+ const tenancy = resolveTenancy(arcContext, tenantField);
433
+ if (tenancy.mode === "unscoped" && tenancy.reason === "cross-tenant") out.bypassTenant = true;
434
+ else {
435
+ if (tenancy.mode === "scoped") out[tenancy.field] = tenancy.orgId;
436
+ const presetFields = arcContext?._tenantFields ?? req._tenantFields;
437
+ if (presetFields && typeof presetFields === "object") {
438
+ for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
439
+ }
440
+ if (tenancy.mode === "unscoped" && tenancy.reason === "platform" && tenantField && out[tenantField] == null) out.bypassTenant = true;
440
441
  }
441
- if (tenantField && out[tenantField] == null && scope && isElevated(scope)) out.bypassTenant = true;
442
442
  const userId = getUserId(req.user);
443
443
  if (userId) out.userId = userId;
444
444
  if (req.user) out.user = req.user;
@@ -567,6 +567,14 @@ function resolveOpCacheConfig(cfg, operation) {
567
567
  * Extract user/org IDs from request for cache key scoping.
568
568
  * Only includes orgId when the resource uses tenant-scoped data (tenantField is set).
569
569
  * Universal resources (tenantField: false) get shared cache keys.
570
+ *
571
+ * Deliberately NOT `resolveTenancy`. That answers "is this read FILTERED by
572
+ * tenant?"; this answers "whose cache PARTITION is it?", and the two diverge on
573
+ * a cross-tenant read: unfiltered, yet still partitioned by the caller's org.
574
+ * A cross-tenant listing's row policy may differ per org ("plus anything your
575
+ * organization bought"), so dropping the org from the key would let one org's
576
+ * cached page answer another's. Over-partitioning costs hit rate; the other
577
+ * direction costs a leak.
570
578
  */
571
579
  function deriveCacheScope(req, tenantField, arcContext) {
572
580
  return {
@@ -751,6 +759,21 @@ function isFieldReadable(rule) {
751
759
  return rule.hidden !== true;
752
760
  }
753
761
  /**
762
+ * Does `path` touch a blocked field? The ONE path rule every read surface uses: blocked by its own
763
+ * name or by ANY ancestor (a rule on a parent covers its children). With `descendants`, a blocked
764
+ * field UNDER `path` counts too — for surfaces that return the value raw (`_distinct`, aggregation
765
+ * groups), where the parent object carries the hidden child.
766
+ */
767
+ function pathTouchesBlocked(path, blocked, options = {}) {
768
+ if (blocked.has(path)) return true;
769
+ for (let dot = path.indexOf("."); dot > 0; dot = path.indexOf(".", dot + 1)) if (blocked.has(path.slice(0, dot))) return true;
770
+ if (options.descendants) {
771
+ const prefix = `${path}.`;
772
+ for (const field of blocked) if (field.startsWith(prefix)) return true;
773
+ }
774
+ return false;
775
+ }
776
+ /**
754
777
  * The set of field names blocked from read-side surfaces (used by
755
778
  * `QueryResolver.sanitizeSelectAny` and `BaseCrudController._distinct`).
756
779
  *
@@ -796,27 +819,6 @@ function noteDeniedJoins(kind) {
796
819
  notedDeniedJoins = true;
797
820
  log.warn(`client requested ${kind} but no allowlist is configured — DENIED (2.24 default flip; it was allow-all). Set schemaOptions.query.allowedPopulate / allowedLookups to enable client-driven joins. (dev-only note, shown once)`);
798
821
  }
799
- /**
800
- * A `$lookup` reaches ANOTHER collection, and the tenant filter arc conjoins applies to the
801
- * BASE one only. The joined side carries whatever predicate the lookup itself names and
802
- * nothing more — no tenant scope, no soft-delete filter — so a resource that opens
803
- * `allowedLookups` can return rows from a collection the caller could not query directly.
804
- *
805
- * Unreachable by default (2.24 denies `lookup=` with no allowlist) and no resource in this
806
- * fleet configures one, so this is a WARNING at the moment a deployment first opts in
807
- * rather than machinery for a door nobody has opened. Warned once per collection set: the
808
- * decision is made when the allowlist is written, not per request.
809
- *
810
- * Scope the join with the lookup's own `where`, which mongokit compiles into a `$match`
811
- * inside the lookup pipeline.
812
- */
813
- const notedUnscopedJoins = /* @__PURE__ */ new Set();
814
- function noteUnscopedJoin(lookups) {
815
- const key = lookups.map((l) => l.from).filter((f) => typeof f === "string").sort().join(",");
816
- if (key.length === 0 || notedUnscopedJoins.has(key)) return;
817
- notedUnscopedJoins.add(key);
818
- log.warn(`client-driven $lookup on [${key}] — the JOINED side carries no tenant or soft-delete filter (arc scopes the base collection only). Narrow each lookup with its own \`where\`, or the join can surface rows the caller cannot query directly. (shown once)`);
819
- }
820
822
  var QueryResolver = class {
821
823
  queryParser;
822
824
  maxLimit;
@@ -939,6 +941,8 @@ var QueryResolver = class {
939
941
  const { _policyFilters: _ignored, ...rest } = rawQuery;
940
942
  query = rest;
941
943
  }
944
+ const keyset = readKeysetControls(query);
945
+ if (keyset.stripped) query = keyset.query;
942
946
  const parsed = this.queryParser.parse(query);
943
947
  /**
944
948
  * Client-supplied keys only, and BEFORE the policy conjunction below: trusted policy
@@ -948,14 +952,16 @@ var QueryResolver = class {
948
952
  this.assertNoBlockedReadKeys(parsed.filters, parsed.sort);
949
953
  const arcContext = meta ?? req.metadata;
950
954
  const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
951
- const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
955
+ const after = parsed.after;
956
+ const before = keyset.before;
957
+ const keysetMode = keyset.mode === "keyset" || after !== void 0 || before !== void 0;
958
+ const page = keysetMode ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
952
959
  const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.permittedDefaultSort();
953
960
  const rawSelect = parsed.select ?? req.query?.select;
954
961
  const policyFilters = arcContext?._policyFilters;
955
962
  let filters = conjoinPolicyFilters(parsed.filters, policyFilters);
956
- const scope = arcContext?._scope;
957
- const orgId = scope ? getOrgId(scope) : void 0;
958
- if (this.tenantField && orgId && !arcContext?._crossTenantRead && !policyFilters?.[this.tenantField]) filters = conjoinPolicyFilters(filters, { [this.tenantField]: orgId });
963
+ const tenancy = resolveTenancy(arcContext, this.tenantField);
964
+ if (tenancy.mode === "scoped" && !policyFilters?.[tenancy.field]) filters = conjoinPolicyFilters(filters, { [tenancy.field]: tenancy.orgId });
959
965
  const portableFilters = toRepositoryFilter(filters);
960
966
  return {
961
967
  page,
@@ -964,10 +970,12 @@ var QueryResolver = class {
964
970
  select: this.sanitizeSelectAny(rawSelect, this.schemaOptions),
965
971
  populate: this.sanitizePopulate(parsed.populate, this.schemaOptions),
966
972
  populateOptions: this.sanitizePopulateOptions(parsed.populateOptions, this.schemaOptions),
967
- lookups: this.sanitizeLookups(parsed.lookups, this.schemaOptions),
973
+ lookups: this.sanitizeLookups(parsed.lookups, this.schemaOptions, arcContext?._scope),
968
974
  filters: portableFilters,
969
975
  search: parsed.search,
970
- after: parsed.after,
976
+ ...after !== void 0 ? { after } : {},
977
+ ...before !== void 0 ? { before } : {},
978
+ ...keysetMode ? { mode: "keyset" } : {},
971
979
  user: req.user,
972
980
  context: arcContext
973
981
  };
@@ -1019,11 +1027,11 @@ var QueryResolver = class {
1019
1027
  return sanitized.length > 0 ? sanitized : void 0;
1020
1028
  }
1021
1029
  /**
1022
- * Sanitize lookup/join options. Deny-by-default: `allowedLookups` must be
1023
- * configured for any client-driven lookup to pass; listed collections are
1024
- * then structurally validated to prevent injection.
1030
+ * Client lookups, deny-by-default. An allowed one is structurally validated, and its joined side
1031
+ * is filtered by the resource's server-owned `LookupPolicy.scope` — the base collection's tenant
1032
+ * filter never reaches a join, and a client-supplied `where` is replaced, never merged.
1025
1033
  */
1026
- sanitizeLookups(lookups, schemaOptions) {
1034
+ sanitizeLookups(lookups, schemaOptions, scope) {
1027
1035
  if (!lookups || lookups.length === 0) return void 0;
1028
1036
  const allowedLookups = schemaOptions.query?.allowedLookups;
1029
1037
  if (!allowedLookups) {
@@ -1031,15 +1039,17 @@ var QueryResolver = class {
1031
1039
  return;
1032
1040
  }
1033
1041
  const validFieldName = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
1034
- const sanitized = lookups.filter((lookup) => {
1035
- if (!lookup.from || !lookup.localField || !lookup.foreignField) return false;
1036
- if (!validFieldName.test(lookup.from)) return false;
1037
- if (!validFieldName.test(lookup.localField)) return false;
1038
- if (!validFieldName.test(lookup.foreignField)) return false;
1039
- if (!allowedLookups.includes(lookup.from)) return false;
1040
- return true;
1042
+ const sanitized = lookups.flatMap((lookup) => {
1043
+ if (!lookup.from || !lookup.localField || !lookup.foreignField) return [];
1044
+ if (!validFieldName.test(lookup.from)) return [];
1045
+ if (!validFieldName.test(lookup.localField)) return [];
1046
+ if (!validFieldName.test(lookup.foreignField)) return [];
1047
+ const policy = Object.hasOwn(allowedLookups, lookup.from) ? allowedLookups[lookup.from] : void 0;
1048
+ return policy ? [{
1049
+ ...lookup,
1050
+ where: policy.scope({ scope })
1051
+ }] : [];
1041
1052
  });
1042
- if (sanitized.length > 0) noteUnscopedJoin(sanitized);
1043
1053
  return sanitized.length > 0 ? sanitized : void 0;
1044
1054
  }
1045
1055
  /**
@@ -1061,8 +1071,7 @@ var QueryResolver = class {
1061
1071
  * whoever wrote it.
1062
1072
  */
1063
1073
  isBlockedProjection(path, blockedFields) {
1064
- const field = path.replace(/^-/, "");
1065
- return blockedFields.some((blocked) => field === blocked || field.startsWith(`${blocked}.`));
1074
+ return pathTouchesBlocked(path.replace(/^-/, ""), new Set(blockedFields));
1066
1075
  }
1067
1076
  /**
1068
1077
  * Refuse a read that FILTERS or SORTS on a `hidden` field.
@@ -1085,11 +1094,7 @@ var QueryResolver = class {
1085
1094
  const blocked = collectReadBlockedFields(this.schemaOptions);
1086
1095
  if (!blocked) return;
1087
1096
  const offenders = /* @__PURE__ */ new Set();
1088
- const isBlocked = (key) => {
1089
- if (blocked.has(key)) return true;
1090
- const dot = key.indexOf(".");
1091
- return dot > 0 && blocked.has(key.slice(0, dot));
1092
- };
1097
+ const isBlocked = (key) => pathTouchesBlocked(key, blocked);
1093
1098
  const walk = (node, depth) => {
1094
1099
  if (depth > 8 || !node || typeof node !== "object") return;
1095
1100
  if (Array.isArray(node)) {
@@ -1115,14 +1120,33 @@ var QueryResolver = class {
1115
1120
  })));
1116
1121
  }
1117
1122
  };
1123
+ /**
1124
+ * `mode` and `before` — lifted out of the query so no
1125
+ * parser reads them as filters. An unknown `mode` is refused with a closed code.
1126
+ */
1127
+ function readKeysetControls(query) {
1128
+ if (!query || !("mode" in query || "before" in query)) return {
1129
+ query,
1130
+ stripped: false
1131
+ };
1132
+ const { mode, before, ...rest } = query;
1133
+ if (mode !== void 0 && mode !== "offset" && mode !== "keyset") throw createDomainError("arc.pagination.invalid_mode", `mode must be 'offset' or 'keyset', got '${String(mode)}'`, 400);
1134
+ if (before !== void 0 && (typeof before !== "string" || before === "")) throw new ValidationError("before must be a non-empty cursor string", [{
1135
+ field: "before",
1136
+ message: "a cursor from a page's prev"
1137
+ }]);
1138
+ return {
1139
+ query: rest,
1140
+ stripped: true,
1141
+ ...mode !== void 0 ? { mode } : {},
1142
+ ...typeof before === "string" ? { before } : {}
1143
+ };
1144
+ }
1118
1145
  //#endregion
1119
1146
  //#region src/core/BaseCrudController.ts
1120
1147
  /**
1121
1148
  * Framework-agnostic CRUD controller implementing IController.
1122
- *
1123
- * Composes AccessControl, BodySanitizer, and QueryResolver. All shared
1124
- * state and helpers are `protected` so the preset mixins (SoftDelete,
1125
- * Tree, Slug, Bulk) can extend cleanly.
1149
+ * Composes AccessControl, BodySanitizer, and QueryResolver.
1126
1150
  *
1127
1151
  * @template TDoc - The document type.
1128
1152
  * @template TRepository - The repository type (defaults to RepositoryLike).
@@ -1136,73 +1160,38 @@ var BaseCrudController = class {
1136
1160
  resourceName;
1137
1161
  tenantField;
1138
1162
  idField = "_id";
1139
- /**
1140
- * Composable access control (ID filtering, policy checks, org scope, ownership).
1141
- *
1142
- * Not `readonly` — `configure()` rebuilds it when the host
1143
- * supplies tenant/idField/matchesFilter post-construction. Same model as
1144
- * `queryResolver` after `setQueryParser` shipped in 2.10.9.
1145
- */
1163
+ /** Alternate unique keys, each with its own access control over the SAME scope. */
1164
+ lookupAccess = [];
1165
+ _lookupFields;
1166
+ /** Access control (ID filtering, policy checks, org scope, ownership). */
1146
1167
  accessControl;
1147
- /**
1148
- * Composable body sanitization (field permissions, system fields).
1149
- *
1150
- * Not `readonly` — `configure()` rebuilds it when the host
1151
- * supplies schemaOptions/onFieldWriteDenied post-construction.
1152
- */
1168
+ /** Body sanitization (field permissions, system fields). */
1153
1169
  bodySanitizer;
1154
- /**
1155
- * Composable query resolution (parsing, pagination, sort, select/populate).
1156
- *
1157
- * Not `readonly` — `setQueryParser()` rebuilds this resolver to swap in a
1158
- * different parser (e.g. mongokit's `QueryParser`). `defineResource` calls
1159
- * it automatically when a resource supplies both `controller` and
1160
- * `queryParser`.
1161
- */
1170
+ /** Query resolution (parsing, pagination, sort, select/populate). */
1162
1171
  queryResolver;
1163
1172
  _matchesFilter;
1164
1173
  _presetFields = {};
1165
1174
  _cacheConfig;
1166
1175
  /**
1167
- * Retained construction-time values so partial `configure()` rebuilds
1168
- * (e.g. `configure({ schemaOptions })`) reuse them instead of silently
1169
- * falling back to the sub-component defaults (`-createdAt` sort,
1170
- * `reject` write-denial) — `defaultSort: false` matters for kits
1171
- * without a `createdAt` column, `strip` for legacy write semantics.
1176
+ * Retained so partial `configure()` rebuilds reuse them instead of falling back
1177
+ * to sub-component defaults (`-createdAt` sort breaks kits without that column).
1172
1178
  */
1173
1179
  _defaultSort;
1174
1180
  _onFieldWriteDenied;
1175
1181
  _onImmutableWrite;
1176
1182
  /**
1177
- * Domain commands bound to the write slots (`ResourceWrites`). Consulted at
1178
- * the PERSISTENCE step only — everything upstream of it (sanitizer, tenant
1179
- * injection, actor stamp, hook sandwich) runs exactly as it does without
1180
- * one, which is the entire point of the seam.
1183
+ * Domain commands bound to the write slots. Consulted at the PERSISTENCE step
1184
+ * only — sanitizer, tenant injection, actor stamp, and hooks run unchanged.
1181
1185
  */
1182
1186
  _writes;
1183
1187
  /** Transactional write envelope — see `ResourceConfig.transactional`. */
1184
1188
  _transactional = false;
1189
+ /** Version field under optimistic concurrency — `null` when the resource did not opt in. */
1190
+ _versionField = null;
1185
1191
  constructor(repository, options = {}) {
1186
1192
  this.repository = repository;
1187
1193
  this.schemaOptions = options.schemaOptions ?? {};
1188
1194
  this.queryParser = options.queryParser ?? getDefaultQueryParser();
1189
- /**
1190
- * The parser's cap is consulted BEFORE the framework default.
1191
- *
1192
- * A resource that hands us a parser has already stated how large a page it
1193
- * can serve — a bounded catalog like a chart of accounts declares 1000 so
1194
- * pickers can read it whole. Defaulting straight to 100 here silently
1195
- * overrode that: the resource asked for 1000, `QueryResolver` received a
1196
- * `maxLimit` of 100 that was indistinguishable from a host deliberately
1197
- * choosing 100, and the list came back truncated with a `200`. be-prod
1198
- * served 100 of 696 accounts that way, and every account picker in the
1199
- * product was short by 85% with nothing anywhere to report it.
1200
- *
1201
- * Order matters and is the whole point: an explicit `options.maxLimit` from
1202
- * the host still wins over the parser, and the parser wins over this
1203
- * framework floor. A general default must only ever fill a gap — never
1204
- * outrank something specific that was actually stated.
1205
- */
1206
1195
  this.maxLimit = options.maxLimit ?? this.queryParser?.maxLimit ?? 100;
1207
1196
  this.defaultLimit = options.defaultLimit ?? 20;
1208
1197
  this.resourceName = options.resourceName;
@@ -1216,11 +1205,14 @@ var BaseCrudController = class {
1216
1205
  this._onImmutableWrite = options.onImmutableWrite;
1217
1206
  this._writes = options.writes;
1218
1207
  this._transactional = options.transactional === true;
1208
+ this._versionField = options.concurrency ? options.concurrency.versionField ?? "__v" : null;
1219
1209
  this.accessControl = new AccessControl({
1220
1210
  tenantField: this.tenantField,
1221
1211
  idField: this.idField,
1222
1212
  matchesFilter: this._matchesFilter
1223
1213
  });
1214
+ this._lookupFields = options.lookupFields;
1215
+ this.lookupAccess = this.buildLookupAccess(options.lookupFields);
1224
1216
  this.bodySanitizer = new BodySanitizer({
1225
1217
  schemaOptions: this.schemaOptions,
1226
1218
  onFieldWriteDenied: this._onFieldWriteDenied,
@@ -1236,63 +1228,39 @@ var BaseCrudController = class {
1236
1228
  });
1237
1229
  this.list = this.list.bind(this);
1238
1230
  this.get = this.get.bind(this);
1239
- /**
1240
- * The write methods are bound here, which means EVERY instance carries an
1241
- * own `create`/`update`/`delete` property — so "does it have an own
1242
- * property" cannot distinguish arc's binding from a host's override.
1243
- *
1244
- * Marking what arc installed makes the difference observable. A class-field
1245
- * override (`update = async (req) => …`) initialises AFTER `super()`
1246
- * returns and therefore REPLACES this bound method outright — the mark goes
1247
- * with it, which is exactly the signal `warnOnWriteMethodOverride` needs.
1248
- * A prototype-method override binds through here and stays marked; that
1249
- * shape is caught by the prototype comparison instead.
1250
- */
1251
1231
  this.create = markArcBoundWrite(this.create.bind(this));
1252
1232
  this.update = markArcBoundWrite(this.update.bind(this));
1253
1233
  this.delete = markArcBoundWrite(this.delete.bind(this));
1254
1234
  }
1255
1235
  /**
1256
- * Swap the controller's query parser. Mutates the existing `QueryResolver`
1257
- * in place via `QueryResolver.setParser()` — the resolver instance stays
1258
- * referentially stable, and there is no second copy of `defaultSort` /
1259
- * `tenantField` / `schemaOptions` for the swap to drift away from.
1260
- *
1261
- * Closes the v2.10.9 gap where `defineResource({ controller, queryParser })`
1262
- * forwarded the parser only to auto-constructed controllers. `defineResource`
1263
- * calls this via duck-typing when both `controller` and `queryParser` are
1264
- * supplied; controllers that don't implement it are left untouched.
1236
+ * Bind the resource this controller serves — hooks, audit, events and cache key on it.
1237
+ * `defineResource` calls it for a supplied controller; an unset name is assigned, a
1238
+ * different one throws (the controller would run another resource's hooks).
1239
+ */
1240
+ bindResourceName(name) {
1241
+ if (this.resourceName === void 0) {
1242
+ this.resourceName = name;
1243
+ return;
1244
+ }
1245
+ if (this.resourceName !== name) throw new Error(`Resource "${name}": its controller is bound to resourceName "${this.resourceName}", so hooks, audit and events would key on the wrong resource.`);
1246
+ }
1247
+ /**
1248
+ * Swap the controller's query parser. Mutates the existing `QueryResolver` in
1249
+ * place, so the resolver stays referentially stable. `defineResource` calls this
1250
+ * (duck-typed) when a resource supplies both `controller` and `queryParser`.
1265
1251
  */
1266
1252
  setQueryParser(queryParser) {
1267
1253
  this.queryParser = queryParser;
1268
1254
  this.queryResolver.setParser(queryParser);
1269
1255
  }
1270
1256
  /**
1271
- * Apply resource-level options to a custom controller AFTER construction.
1272
- *
1273
- * Closes the pre-2.15 footgun where `defineResource({ controller, tenantField,
1274
- * schemaOptions, ... })` warned that the options were "dropped" because the
1275
- * user-supplied controller never received them. Hosts had to remember to
1276
- * forward each one through `super(repo, { ... })` — easy to miss, silently
1277
- * mis-scopes queries when missed.
1278
- *
1279
- * `defineResource()` now calls `controller.configure(resolvedOpts)` after
1280
- * `resolveOrAutoCreateController()` runs. Configure-aware controllers receive
1281
- * the resolved values; arc skips the dropped-options warn for them.
1282
- *
1283
- * Only the keys that affect cross-cutting state (tenant scope, schema/field
1284
- * rules, sort/limit policy, cache, write-denial policy) are honoured —
1285
- * `repository` / `resourceName` are constructor-only because they participate
1286
- * in mixin composition. Each known key rebuilds the affected sub-component
1287
- * (AccessControl / BodySanitizer / QueryResolver) so referentially-stable
1288
- * consumers don't see stale state.
1289
- *
1290
- * Idempotent: safe to call zero, one, or many times before first request;
1291
- * arc calls it exactly once.
1257
+ * Apply resource-level options to a controller AFTER construction.
1258
+ * `defineResource()` calls this once after resolving the controller, so custom
1259
+ * controllers receive tenant scope, field rules, sort/limit, cache, and
1260
+ * write-denial policy without forwarding them through `super(repo, { … })`.
1292
1261
  *
1293
- * Type narrowed to `ControllerConfigurableOptions` — `resourceName` is
1294
- * construction-only and intentionally excluded so accidental "rename
1295
- * the resource at runtime" calls fail to typecheck.
1262
+ * Each key rebuilds only the affected sub-component. Idempotent.
1263
+ * `repository` / `resourceName` are construction-only and excluded by type.
1296
1264
  */
1297
1265
  configure(options) {
1298
1266
  let rebuildAccessControl = false;
@@ -1341,11 +1309,19 @@ var BaseCrudController = class {
1341
1309
  if (options.presetFields !== void 0) this._presetFields = options.presetFields;
1342
1310
  if (options.writes !== void 0) this._writes = options.writes;
1343
1311
  if (options.transactional !== void 0) this._transactional = options.transactional === true;
1344
- if (rebuildAccessControl) this.accessControl = new AccessControl({
1345
- tenantField: this.tenantField,
1346
- idField: this.idField,
1347
- matchesFilter: this._matchesFilter
1348
- });
1312
+ if (options.concurrency !== void 0) this._versionField = options.concurrency ? options.concurrency.versionField ?? "__v" : null;
1313
+ if (options.lookupFields !== void 0) {
1314
+ this._lookupFields = options.lookupFields;
1315
+ rebuildAccessControl = true;
1316
+ }
1317
+ if (rebuildAccessControl) {
1318
+ this.accessControl = new AccessControl({
1319
+ tenantField: this.tenantField,
1320
+ idField: this.idField,
1321
+ matchesFilter: this._matchesFilter
1322
+ });
1323
+ this.lookupAccess = this.buildLookupAccess(this._lookupFields);
1324
+ }
1349
1325
  if (rebuildBodySanitizer) this.bodySanitizer = new BodySanitizer({
1350
1326
  schemaOptions: this.schemaOptions,
1351
1327
  onFieldWriteDenied: this._onFieldWriteDenied,
@@ -1360,25 +1336,15 @@ var BaseCrudController = class {
1360
1336
  tenantField: this.tenantField
1361
1337
  });
1362
1338
  }
1363
- /**
1364
- * Get the tenant field name if multi-tenant scoping is enabled.
1365
- * Returns `undefined` when `tenantField` is `false`.
1366
- */
1339
+ /** Tenant field name, or `undefined` when `tenantField` is `false`. */
1367
1340
  getTenantField() {
1368
1341
  return this.tenantField || void 0;
1369
1342
  }
1370
1343
  /**
1371
- * Build the canonical repo-options bag (tenant scope, audit
1372
- * attribution, trace correlation) from the Fastify request. Full
1373
- * contract + rationale on `buildTenantRepoOptions` in
1374
- * `crud/requestPipeline.ts` — this delegate exists so host overrides
1375
- * and duck-typed consumers (aggregation router, MCP bridge) keep the
1376
- * same `protected` entry point.
1377
- *
1378
- * Method kept named `tenantRepoOptions` for back-compat with hosts
1379
- * that spread `...this.tenantRepoOptions(req)` (10+ call sites in
1380
- * arc, plus host overrides). The bag has always grown over time —
1381
- * hosts that don't want audit forwarding never read those keys.
1344
+ * Canonical repo-options bag (tenant scope, audit attribution, trace
1345
+ * correlation) — see `buildTenantRepoOptions` in `crud/requestPipeline.ts`.
1346
+ * Kept as a `protected` entry point for host overrides and duck-typed
1347
+ * consumers (aggregation router, MCP bridge).
1382
1348
  */
1383
1349
  tenantRepoOptions(req) {
1384
1350
  return buildTenantRepoOptions(req, this.tenantField, this.meta(req));
@@ -1401,18 +1367,11 @@ var BaseCrudController = class {
1401
1367
  return resolveMutationRepoId(id, existing, this.idField, this.repository);
1402
1368
  }
1403
1369
  /**
1404
- * Read-side preflight for mutable-target operations (`update`, `delete`).
1370
+ * Shared preflight for mutations: extract `:id`, fetch under access control +
1371
+ * tenant scope, verify ownership, translate the route id to the repo primary
1372
+ * key. One path so `update` and `delete` cannot drift.
1405
1373
  *
1406
- * Bundles the four steps that every mutation must do before touching the
1407
- * repo: (1) extract `:id`, (2) fetch under access control + tenant scope,
1408
- * (3) verify ownership, (4) translate the route id to the repo's primary
1409
- * key. Returning `{id, existing, repoId}` keeps the call sites a single
1410
- * line and makes drift between `update` and `delete` structurally
1411
- * impossible — there is one preflight, one denial-reason mapping, one
1412
- * ownership check.
1413
- *
1414
- * Pass `extraFetchOptions` for callers (e.g. soft-delete restore) that
1415
- * need to widen the fetch (`{ includeDeleted: true }`).
1374
+ * `extraFetchOptions` widens the fetch (e.g. restore's `{ includeDeleted: true }`).
1416
1375
  */
1417
1376
  async loadMutableTarget(req, extraFetchOptions) {
1418
1377
  const id = this.requireIdParam(req);
@@ -1431,10 +1390,83 @@ var BaseCrudController = class {
1431
1390
  };
1432
1391
  }
1433
1392
  /**
1434
- * Centralized 404 thrower. Maps the denial reason from `fetchDetailed()`
1435
- * into a `NotFoundError` so consumers can distinguish "doc doesn't
1436
- * exist" from "doc filtered by policy/org scope" via the error
1437
- * `details.code` set by the global error handler.
1393
+ * Whether the target of an id-bound ACTION is a row this request may read — the same
1394
+ * compound filter (id + tenant + `_policyFilters`) `GET /:id` uses. The action router
1395
+ * refuses the action with a 404 when it is not, so a caller can act on a row iff it can
1396
+ * read it. Skips the read when neither tenancy nor a row policy narrows the request.
1397
+ */
1398
+ buildLookupAccess(fields) {
1399
+ return (fields ?? []).filter((field) => field !== this.idField).map((idField) => new AccessControl({
1400
+ tenantField: this.tenantField,
1401
+ idField,
1402
+ matchesFilter: this._matchesFilter
1403
+ }));
1404
+ }
1405
+ /**
1406
+ * THE single-row lookup — `GET /:id` and the action-target fence both read through it.
1407
+ * The primary key first; when it finds nothing (or the id cannot be that key's type), each
1408
+ * `lookupFields` key in turn, under the same tenant and row policy.
1409
+ */
1410
+ async fetchByAnyKey(id, req, options) {
1411
+ let primary;
1412
+ let primaryError;
1413
+ try {
1414
+ primary = await this.accessControl.fetchDetailed(id, req, this.repository, options);
1415
+ if (primary.doc || this.lookupAccess.length === 0) return primary;
1416
+ } catch (err) {
1417
+ if (this.lookupAccess.length === 0) throw err;
1418
+ primaryError = err;
1419
+ }
1420
+ let answered = primary;
1421
+ for (const access of this.lookupAccess) try {
1422
+ const found = await access.fetchDetailed(id, req, this.repository, options);
1423
+ if (found.doc) return found;
1424
+ answered ??= found;
1425
+ } catch {}
1426
+ if (answered) return answered;
1427
+ throw primaryError;
1428
+ }
1429
+ /** Whether {@link isActionTargetVisible} can answer — it reads through the repository. */
1430
+ canFenceActionTargets() {
1431
+ return this.repository !== void 0 && this.repository !== null;
1432
+ }
1433
+ async isActionTargetVisible(req, id) {
1434
+ const meta = this.meta(req);
1435
+ const scoped = resolveTenancy(meta, this.tenantField).mode === "scoped";
1436
+ const policy = meta?._policyFilters;
1437
+ if (!scoped && (!policy || Object.keys(policy).length === 0)) return true;
1438
+ const { doc } = await this.fetchByAnyKey(id, req, this.tenantRepoOptions(req));
1439
+ return doc !== null && doc !== void 0;
1440
+ }
1441
+ /** `ETag: "<version>"` for a versioned document under optimistic concurrency. */
1442
+ etagHeaders(doc) {
1443
+ if (!this._versionField) return void 0;
1444
+ const version = doc?.[this._versionField];
1445
+ return typeof version === "number" ? { etag: `"${version}"` } : void 0;
1446
+ }
1447
+ /**
1448
+ * The version an update must apply at. `If-Match` must name the CURRENT version or the
1449
+ * request fails 412 before anything is written; without it (or with `*`) the write is a CAS
1450
+ * on the version just loaded, so a concurrent write in between is a 409, never overwritten.
1451
+ */
1452
+ expectedVersion(req, existing) {
1453
+ if (!this._versionField) return { fromClient: false };
1454
+ const current = existing[this._versionField];
1455
+ const header = requireSingleHeaderValue(req.headers, "if-match")?.trim();
1456
+ if (header === void 0 || header === "*") return typeof current === "number" ? {
1457
+ version: current,
1458
+ fromClient: false
1459
+ } : { fromClient: false };
1460
+ const offered = header.split(",").map((tag) => /^"(\d+)"$/.exec(tag.trim())?.[1]);
1461
+ if (typeof current !== "number" || !offered.includes(String(current))) throw createDomainError(ARC_ERROR_CODES.PRECONDITION_FAILED, `If-Match ${header} does not match the current version${typeof current === "number" ? ` "${current}"` : ""} — re-read and retry.`, 412);
1462
+ return {
1463
+ version: current,
1464
+ fromClient: true
1465
+ };
1466
+ }
1467
+ /**
1468
+ * 404 thrower. Maps the `fetchDetailed()` denial reason into `details.code` so
1469
+ * "doesn't exist" is distinguishable from "filtered by policy/org scope".
1438
1470
  */
1439
1471
  throwNotFound(reason = "NOT_FOUND") {
1440
1472
  throw buildNotFoundError(reason, this.resourceName);
@@ -1444,9 +1476,8 @@ var BaseCrudController = class {
1444
1476
  return resolveOpCacheConfig(this._cacheConfig, operation);
1445
1477
  }
1446
1478
  /**
1447
- * Extract user/org IDs from request for cache key scoping.
1448
- * Only includes orgId when the resource uses tenant-scoped data (tenantField is set).
1449
- * Universal resources (tenantField: false) get shared cache keys.
1479
+ * User/org IDs for cache key scoping. orgId is included only when `tenantField`
1480
+ * is set; universal resources share cache keys.
1450
1481
  */
1451
1482
  cacheScope(req) {
1452
1483
  return deriveCacheScope(req, this.tenantField, this.meta(req));
@@ -1461,11 +1492,7 @@ var BaseCrudController = class {
1461
1492
  if (!id) throw createError(400, "ID parameter is required");
1462
1493
  return id;
1463
1494
  }
1464
- /**
1465
- * Normalizes `repo.exists()` return shapes across adapters. Per
1466
- * StandardRepo's contract, `exists` may return `boolean`, `{ _id }`,
1467
- * or `null` — every truthy non-null shape collapses to `true`.
1468
- */
1495
+ /** Normalizes `repo.exists()` (`boolean | { _id } | null`) to a boolean. */
1469
1496
  isExistsTruthy(result) {
1470
1497
  return isExistsResultTruthy(result);
1471
1498
  }
@@ -1479,25 +1506,18 @@ var BaseCrudController = class {
1479
1506
  };
1480
1507
  }
1481
1508
  /**
1482
- * Run `executeBefore` then `executeAround` (or just the executor if no
1483
- * hooks are wired). Returns the around-phase result directly. Throws an
1484
- * `ArcError` (status 400, code `BEFORE_<OP>_HOOK_ERROR`) when the
1485
- * before-hook fails. Full contract on `executeHookedOp` in
1486
- * `crud/requestPipeline.ts`.
1509
+ * Run before + around hooks (or just the executor if none are wired) and
1510
+ * return the result. A failing before-hook throws 400 `BEFORE_<OP>_HOOK_ERROR`.
1487
1511
  */
1488
1512
  async runHookedOpUntilResult(req, args, executor) {
1489
1513
  return executeHookedOp(this.hookOpContext(req), args, executor);
1490
1514
  }
1491
1515
  /**
1492
1516
  * Run a write's PERSISTENCE step, transactionally when the resource asked.
1493
- *
1494
- * Plain mode hands `fn` the live repository. Transactional mode runs it
1495
- * through repo-core's `retryingTransaction`: the callback receives the
1496
- * TX-BOUND repository (so declared verbs and repository calls join the same
1497
- * transaction), transient conflicts re-run `fn` with jittered bounded
1498
- * backoff, everything else — including `VersionConflictError` — surfaces
1499
- * once. Hooks stay OUTSIDE: before/around ran already and must not re-run;
1500
- * after-hooks run post-commit, which they already do by running after this.
1517
+ * Transactional mode uses `retryingTransaction`: `fn` gets the TX-BOUND repo and
1518
+ * re-runs on transient conflicts; anything else (incl. `VersionConflictError`)
1519
+ * surfaces once. Hooks stay OUTSIDE so before/around never re-run and after
1520
+ * runs post-commit.
1501
1521
  */
1502
1522
  runWritePersistence(fn) {
1503
1523
  if (!this._transactional) return fn(this.repository);
@@ -1517,16 +1537,9 @@ var BaseCrudController = class {
1517
1537
  /**
1518
1538
  * Assemble the `MutationWriteContext` an `update` / `delete` verb receives.
1519
1539
  *
1520
- * `existing` is the document arc ALREADY loaded to run its permission and
1521
- * tenant checks — `loadMutableTarget` throws before any verb runs when it
1522
- * cannot produce one, which is why the context types it required. Handing
1523
- * it over rather than letting the verb re-fetch is deliberate: a second
1524
- * read can observe a different document than the one the request was
1525
- * authorised against.
1526
- *
1527
- * `id` is the REPOSITORY PRIMARY KEY (`repoId`), never the raw route param —
1528
- * see the note on `MutationWriteContext.id`. Callers pass `resolveRepoId`'s
1529
- * output.
1540
+ * `existing` is the document the request was AUTHORISED against — handed over
1541
+ * so the verb does not re-fetch and possibly observe a different document.
1542
+ * `id` is the REPOSITORY PRIMARY KEY (`resolveRepoId` output), never the route param.
1530
1543
  */
1531
1544
  mutationWriteContext(req, id, existing, repo = this.repository, uow) {
1532
1545
  return {
@@ -1538,24 +1551,16 @@ var BaseCrudController = class {
1538
1551
  };
1539
1552
  }
1540
1553
  /**
1541
- * Run `executeAfter` for the given op + data. No-op when hooks aren't
1542
- * wired or `resourceName` isn't set. Caller passes the data shape it
1543
- * wants downstream after-handlers to receive — typically the result for
1544
- * create/update, the original input (`existing`) for delete.
1554
+ * Run after-hooks for `op`. No-op without hooks or `resourceName`. `data` is the
1555
+ * result for create/update, `existing` for delete.
1545
1556
  */
1546
1557
  async runAfterHook(req, op, data, meta) {
1547
1558
  return executeAfterHook(this.hookOpContext(req), op, data, meta);
1548
1559
  }
1549
1560
  /**
1550
- * Per-instance single-flight for cache fills — concurrent requests for
1551
- * the SAME cache key coalesce onto one repository query instead of each
1552
- * issuing its own (a popular entry expiring under 1,000 concurrent
1553
- * readers must produce 1 DB query, not 1,000). Keys already encode the
1554
- * resolved query + user/org scope + resource version, so coalescing can
1555
- * never cross tenants, scopes, or invalidation generations. Per-process
1556
- * by design; distributed refresh leases are a store-level concern
1557
- * (planned with the repo-core CacheEngine façade, which ships
1558
- * single-flight natively).
1561
+ * Per-process single-flight for cache fills: concurrent requests for the SAME
1562
+ * key coalesce onto one repository query. Keys encode query + user/org scope +
1563
+ * resource version, so coalescing never crosses tenants or invalidation generations.
1559
1564
  */
1560
1565
  inFlightCacheFills = /* @__PURE__ */ new Map();
1561
1566
  cacheSingleFlight(key, fill) {
@@ -1568,10 +1573,8 @@ var BaseCrudController = class {
1568
1573
  return flight;
1569
1574
  }
1570
1575
  /**
1571
- * Register a background SWR refresh for `key` unless one is already in
1572
- * flight. Registration is SYNCHRONOUS (before the deferred tick) so a
1573
- * burst of stale readers arriving in the same tick schedules exactly one
1574
- * revalidation.
1576
+ * Register a background SWR refresh for `key` unless one is in flight.
1577
+ * Registration is SYNCHRONOUS so a same-tick burst of stale readers schedules one.
1575
1578
  */
1576
1579
  scheduleCacheRefresh(key, refresh) {
1577
1580
  if (this.inFlightCacheFills.has(key)) return;
@@ -1653,19 +1656,14 @@ var BaseCrudController = class {
1653
1656
  };
1654
1657
  }
1655
1658
  /**
1656
- * Resource-dispatch verbs router. Returns `null` when the request is
1657
- * a regular list query, otherwise returns the dispatch promise.
1658
- *
1659
- * Verbs (mutually exclusive — first match wins):
1659
+ * Resource-dispatch verbs router; `null` for a regular list query.
1660
+ * First match wins:
1660
1661
  * - `?_count=true` → `{ count: number }` via `repo.count()`
1661
1662
  * - `?_distinct=field` → `unknown[]` via `repo.distinct(field)`
1662
1663
  * - `?_exists=true` → `{ exists: boolean }` via `repo.exists()`
1663
1664
  *
1664
- * All verbs share the resolved filter (parsed query + policy filters
1665
- * + tenant scope). Adapters that don't ship the underlying repo
1666
- * method get a `501` so failures surface loudly instead of falling
1667
- * back to a full table scan. Matching + repo invocation live in
1668
- * `crud/resourceVerbs.ts`.
1665
+ * All share the resolved filter (query + policy + tenant scope). A missing repo
1666
+ * method is a `501`, never a fallback to a full scan.
1669
1667
  */
1670
1668
  dispatchResourceVerb(req) {
1671
1669
  const verb = matchResourceVerb(req.query);
@@ -1705,16 +1703,12 @@ var BaseCrudController = class {
1705
1703
  };
1706
1704
  }
1707
1705
  /**
1708
- * True when `field` is safe to expose via `_distinct`.
1709
- *
1710
- * Read-side gate only — only `hidden: true` blocks. `systemManaged`
1711
- * is a *write* rule (clients can't PATCH the value); the field is
1712
- * still in every list response, so blocking `_distinct` adds nothing
1713
- * but inconvenience. See `core/fieldRulePredicates.ts` for the
1714
- * canonical predicate shared with `QueryResolver`.
1706
+ * True when `field` is safe to expose via `_distinct`. Only `hidden: true`
1707
+ * blocks — `systemManaged` is a write rule and the field is already readable.
1715
1708
  */
1716
1709
  isFieldExposedForRead(field) {
1717
- return isFieldReadable(this.schemaOptions.fieldRules?.[field]);
1710
+ const blocked = collectReadBlockedFields(this.schemaOptions);
1711
+ return !blocked || !pathTouchesBlocked(field, blocked, { descendants: true });
1718
1712
  }
1719
1713
  /** Execute list query through hooks (extracted for cache revalidation) */
1720
1714
  async executeListQuery(options, req) {
@@ -1736,21 +1730,30 @@ var BaseCrudController = class {
1736
1730
  ...this.tenantRepoOptions(req)
1737
1731
  };
1738
1732
  const cached = await this.withGetCache(req, id, options);
1739
- if (cached) return cached;
1733
+ if (cached) {
1734
+ const etag = this.etagHeaders(cached.data);
1735
+ return etag ? {
1736
+ ...cached,
1737
+ headers: {
1738
+ ...cached.headers,
1739
+ ...etag
1740
+ }
1741
+ } : cached;
1742
+ }
1740
1743
  const { doc, reason } = await this.executeGetQuery(id, options, req);
1741
1744
  if (!doc) this.throwNotFound(reason);
1742
1745
  await this.runAfterHook(req, "read", doc);
1746
+ const etag = this.etagHeaders(doc);
1743
1747
  return {
1744
1748
  data: doc,
1745
- status: 200
1749
+ status: 200,
1750
+ ...etag ? { headers: etag } : {}
1746
1751
  };
1747
1752
  }
1748
1753
  /** Execute get query through hooks (extracted for cache revalidation) */
1749
1754
  async executeGetQuery(id, options, req) {
1750
1755
  const hooks = this.getHooks(req);
1751
- const fetchItem = async () => {
1752
- return await this.accessControl.fetchDetailed(id, req, this.repository, options);
1753
- };
1756
+ const fetchItem = async () => this.fetchByAnyKey(id, req, options);
1754
1757
  if (hooks && this.resourceName) {
1755
1758
  const result = await fetchItem();
1756
1759
  if (!result.doc) return result;
@@ -1767,6 +1770,7 @@ var BaseCrudController = class {
1767
1770
  async create(req) {
1768
1771
  const arcContext = this.meta(req);
1769
1772
  const data = this.bodySanitizer.sanitize(req.body ?? {}, "create", req, arcContext);
1773
+ Object.assign(data, arcContext?._tenantFields);
1770
1774
  const scope = arcContext?._scope;
1771
1775
  const createOrgId = scope ? getOrgId(scope) : void 0;
1772
1776
  if (this.tenantField && createOrgId) data[this.tenantField] = createOrgId;
@@ -1781,16 +1785,7 @@ var BaseCrudController = class {
1781
1785
  context: arcContext,
1782
1786
  ...this.tenantRepoOptions(req)
1783
1787
  })));
1784
- /**
1785
- * A create COMMAND must return the created document — enforced, because
1786
- * the alternative is quietly worse than any failure: a `201` carrying
1787
- * `undefined` data, after-hooks (audit, events, cache invalidation) fed a
1788
- * non-document, and a client that believes the write happened with no way
1789
- * to address what it created. TypeScript types the contract, but arc is a
1790
- * JavaScript runtime framework and a JS command returning nothing is one
1791
- * missing `return` away.
1792
- */
1793
- if (this._writes?.create && item == null) throw createError(500, `writes.create for resource "${this.resourceName ?? "unknown"}" returned ${item === null ? "null" : "undefined"} — a create command must return the created document. Signal failure by throwing a typed error; a nullish return would otherwise answer 201 with no document.`, { code: "WRITE_VERB_CONTRACT_VIOLATION" });
1788
+ if (this._writes?.create && item == null) throw createError(500, `writes.create for resource "${this.resourceName ?? "unknown"}" returned ${item === null ? "null" : "undefined"} — a create command must return the created document. Signal failure by throwing a typed error; a nullish return would otherwise answer 201 with no document.`, { code: ARC_ERROR_CODES.WRITE_VERB_CONTRACT_VIOLATION });
1794
1789
  await this.runAfterHook(req, "create", item);
1795
1790
  return {
1796
1791
  data: item,
@@ -1801,6 +1796,7 @@ var BaseCrudController = class {
1801
1796
  async update(req) {
1802
1797
  const arcContext = this.meta(req);
1803
1798
  const data = this.bodySanitizer.sanitize(req.body ?? {}, "update", req, arcContext);
1799
+ Object.assign(data, arcContext?._tenantFields);
1804
1800
  const user = req.user;
1805
1801
  const userId = getUserId(user);
1806
1802
  if (userId) data.updatedBy = userId;
@@ -1809,6 +1805,7 @@ var BaseCrudController = class {
1809
1805
  id,
1810
1806
  existing
1811
1807
  };
1808
+ const cas = this._writes?.update ? { fromClient: false } : this.expectedVersion(req, existing);
1812
1809
  const item = await this.runHookedOpUntilResult(req, {
1813
1810
  op: "update",
1814
1811
  input: data,
@@ -1816,15 +1813,12 @@ var BaseCrudController = class {
1816
1813
  }, async (processed) => this.runWritePersistence((repo, uow) => this._writes?.update ? this._writes.update(repoId, processed, this.mutationWriteContext(req, repoId, existing, repo, uow)) : repo.update(repoId, processed, {
1817
1814
  user,
1818
1815
  context: arcContext,
1819
- ...this.tenantRepoOptions(req)
1820
- })));
1821
- /**
1822
- * A declared verb signals "not found" by THROWING, so reaching this line
1823
- * means the command succeeded. Translating its `void` return into a 404
1824
- * would report failure for a write that happened — the repository's
1825
- * `null`-means-miss contract is the repository's, not a domain command's.
1826
- * Re-read so the response still carries the document.
1827
- */
1816
+ ...this.tenantRepoOptions(req),
1817
+ ...cas.version !== void 0 ? { ifVersion: cas.version } : {}
1818
+ }))).catch((err) => {
1819
+ if (cas.fromClient && err?.code === "version_conflict") throw createDomainError(ARC_ERROR_CODES.PRECONDITION_FAILED, "The resource changed since it was read — re-read and retry.", 412);
1820
+ throw err;
1821
+ });
1828
1822
  const resolved = this._writes?.update ? item ?? await this.repository.getById(repoId, {
1829
1823
  user,
1830
1824
  context: arcContext,
@@ -1832,10 +1826,12 @@ var BaseCrudController = class {
1832
1826
  }) : item;
1833
1827
  if (!resolved) this.throwNotFound("NOT_FOUND");
1834
1828
  await this.runAfterHook(req, "update", resolved, hookMeta);
1829
+ const etag = this.etagHeaders(resolved);
1835
1830
  return {
1836
1831
  data: resolved,
1837
1832
  status: 200,
1838
- meta: { message: "Updated successfully" }
1833
+ meta: { message: "Updated successfully" },
1834
+ ...etag ? { headers: etag } : {}
1839
1835
  };
1840
1836
  }
1841
1837
  async delete(req) {
@@ -1869,12 +1865,9 @@ var BaseCrudController = class {
1869
1865
  }
1870
1866
  };
1871
1867
  /**
1872
- * This class's write methods dispatch declared write verbs (`_writes`), so
1873
- * `defineResource` may accept a `writes` declaration for controllers built on
1874
- * it. One mark on the prototype — inherited by every subclass and mixin
1875
- * composition, zero per-instance cost. A duck-typed `configure()` is NOT this
1876
- * proof: it shows an options channel exists, not that anything dispatches
1877
- * `writes` out of it.
1868
+ * Marks that this class dispatches declared write verbs, so `defineResource` may
1869
+ * accept `writes` for controllers built on it (inherited by subclasses and mixins).
1870
+ * A duck-typed `configure()` is not proof that `writes` is dispatched.
1878
1871
  */
1879
1872
  markWriteVerbCapable(BaseCrudController.prototype);
1880
1873
  //#endregion
@@ -1906,22 +1899,18 @@ function BulkMixin(Base) {
1906
1899
  const items = rawItems;
1907
1900
  const arcContext = this.meta(req);
1908
1901
  const user = req.user;
1909
- const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
1902
+ const sanitizedItems = items.map((item) => ({
1903
+ ...this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext),
1904
+ ...arcContext?._tenantFields
1905
+ }));
1910
1906
  let scopedItems = sanitizedItems;
1911
- if (this.tenantField) {
1912
- const scope = arcContext?._scope;
1913
- if (scope) {
1914
- if (scope.kind === "public") throw createError(403, "Organization context required to bulk-create resources", { code: "ORG_CONTEXT_REQUIRED" });
1915
- if (!isElevated(scope)) {
1916
- const orgId = getOrgId(scope);
1917
- if (!orgId) throw createError(403, "Organization context required to bulk-create resources", { code: "ORG_CONTEXT_REQUIRED" });
1918
- const tenantField = this.tenantField;
1919
- scopedItems = sanitizedItems.map((item) => ({
1920
- ...item,
1921
- [tenantField]: orgId
1922
- }));
1923
- }
1924
- }
1907
+ if (this.tenantField && arcContext?._scope) {
1908
+ const tenancy = resolveTenancy(arcContext, this.tenantField);
1909
+ if (tenancy.mode === "scoped") scopedItems = sanitizedItems.map((item) => ({
1910
+ ...item,
1911
+ [tenancy.field]: tenancy.orgId
1912
+ }));
1913
+ else if (tenancy.reason !== "platform" && tenancy.reason !== "untenanted-resource") throw createError(403, "Organization context required to bulk-create resources", { code: "ORG_CONTEXT_REQUIRED" });
1925
1914
  }
1926
1915
  const created = await repo.createMany(scopedItems, {
1927
1916
  user,
@@ -1948,28 +1937,26 @@ function BulkMixin(Base) {
1948
1937
  /**
1949
1938
  * Build a tenant-scoped filter for bulk update/delete.
1950
1939
  *
1951
- * Mirrors `AccessControl.buildIdFilter` semantics:
1940
+ * Same tenancy as `AccessControl.buildIdFilter` — both go through
1941
+ * `resolveTenancy`:
1952
1942
  * - Always merge `_policyFilters` (from permission middleware)
1953
- * - `member` scope on a tenant resource → add org filter
1954
- * - `elevated` scope → no org filter (admin cross-org operation)
1955
- * - `public` scope on a tenant resource → deny
1943
+ * - A caller with an org (member, service, or an elevated admin who
1944
+ * selected one) → add org filter
1945
+ * - Elevated with NO org (platform view) → no org filter
1946
+ * - No org, or anonymous, on a tenant resource → deny
1956
1947
  * - No scope at all (unit tests) → leave filter unchanged
1957
1948
  *
1958
1949
  * Returns the merged filter, or `null` when access must be denied.
1959
1950
  */
1960
1951
  buildBulkFilter(userFilter, req) {
1961
1952
  const arcContext = this.meta(req);
1962
- let filter = conjoinPolicyFilters(userFilter, arcContext?._policyFilters);
1963
- if (this.tenantField) {
1964
- const scope = arcContext?._scope;
1965
- if (!scope) return filter;
1966
- if (scope.kind === "public") return null;
1967
- if (isElevated(scope)) return filter;
1968
- const orgId = getOrgId(scope);
1969
- if (!orgId) return null;
1970
- filter = conjoinPolicyFilters(filter, { [this.tenantField]: orgId });
1953
+ const filter = conjoinPolicyFilters(userFilter, arcContext?._policyFilters);
1954
+ if (!this.tenantField || !arcContext?._scope) return filter;
1955
+ const tenancy = resolveTenancy(arcContext, this.tenantField);
1956
+ switch (tenancy.mode) {
1957
+ case "scoped": return conjoinPolicyFilters(filter, { [tenancy.field]: tenancy.orgId });
1958
+ case "unscoped": return tenancy.reason === "platform" || tenancy.reason === "untenanted-resource" ? filter : null;
1971
1959
  }
1972
- return filter;
1973
1960
  }
1974
1961
  /**
1975
1962
  * Sanitize a bulk update data payload through the same write-permission
@@ -2213,10 +2200,934 @@ function TreeMixin(Base) {
2213
2200
  //#region src/core/BaseController.ts
2214
2201
  /**
2215
2202
  * Fully-composed controller: `BaseCrudController` + SoftDelete + Tree +
2216
- * Slug + Bulk. Drop-in replacement for the pre-2.11 god class. The
2217
- * companion interface above gives every method full generic precision
2218
- * on `TDoc` via declaration merging.
2203
+ * Slug + Bulk. The companion interface above types every method over `TDoc`.
2219
2204
  */
2220
2205
  var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
2221
2206
  //#endregion
2222
- export { BulkMixin as a, collectReadBlockedFields as c, isWriteVerbCapable as d, BodySanitizer as f, toRepositoryFilter as g, nativePolicyFilter as h, SlugMixin as i, isFieldReadable as l, AccessControl as m, TreeMixin as n, BaseCrudController as o, DEFAULT_FIELD_WRITE_DENIAL_POLICY as p, SoftDeleteMixin as r, QueryResolver as s, BaseController as t, detectOverriddenWriteSlots as u };
2207
+ //#region src/core/middlewares/fieldWrite.ts
2208
+ /**
2209
+ * Build a preHandler that enforces field-write permissions on the request body.
2210
+ *
2211
+ * Auto-CRUD's create/update routes get this for free via `BodySanitizer.sanitize()`
2212
+ * inside `BaseController`. Custom routes (`config.routes`, presets, action endpoints)
2213
+ * never went through that path — so a host that declared
2214
+ * `fields: { role: fields.writableBy(['admin']) }` and a custom
2215
+ * `POST /users/promote` happily accepted `{ role: 'admin' }` from any caller.
2216
+ *
2217
+ * This preHandler closes that gap. It applies ONLY the `applyFieldWritePermissions`
2218
+ * step — no `SYSTEM_FIELDS` strip, no `fieldRules` strip — because custom-route
2219
+ * bodies don't necessarily mirror the resource's adapter shape, and we'd
2220
+ * generate surprising 403s by enforcing rules tied to that shape.
2221
+ *
2222
+ * Returns `null` when the resource has no field permissions configured — the
2223
+ * caller should then skip wiring it into the preHandler chain.
2224
+ */
2225
+ function buildFieldWritePreHandler(fieldPermissions, policy = DEFAULT_FIELD_WRITE_DENIAL_POLICY) {
2226
+ if (!fieldPermissions || Object.keys(fieldPermissions).length === 0) return null;
2227
+ return async (request, _reply) => {
2228
+ const body = request.body;
2229
+ if (!body || typeof body !== "object" || Array.isArray(body)) return;
2230
+ const scope = request.scope ?? PUBLIC_SCOPE;
2231
+ if (isElevated(scope)) return;
2232
+ const globalRoles = getUserRoles(request.user);
2233
+ const orgRoles = isMember(scope) ? scope.orgRoles : [];
2234
+ const effectiveRoles = resolveEffectiveRoles(globalRoles, orgRoles);
2235
+ const { body: filtered, deniedFields } = applyFieldWritePermissions(body, fieldPermissions, effectiveRoles);
2236
+ if (deniedFields.length > 0 && policy === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
2237
+ request.body = filtered;
2238
+ };
2239
+ }
2240
+ /**
2241
+ * True for HTTP methods that carry a request body and should run the
2242
+ * field-write preHandler. GET/DELETE/HEAD/OPTIONS skip enforcement.
2243
+ */
2244
+ function methodCarriesBody(method) {
2245
+ const m = method.toUpperCase();
2246
+ return m === "POST" || m === "PUT" || m === "PATCH";
2247
+ }
2248
+ //#endregion
2249
+ //#region src/core/createCrudRouter.ts
2250
+ /**
2251
+ * Fold a route's host-supplied `config` together with arc's derived one into the
2252
+ * SINGLE `config` key Fastify reads. Returns arc's object untouched when the host
2253
+ * supplied none, so the route options stay literally what was declared.
2254
+ */
2255
+ function mergeRouteConfig(hostConfig, arcConfig) {
2256
+ if (!hostConfig) return arcConfig;
2257
+ return { config: {
2258
+ ...hostConfig,
2259
+ ...arcConfig.config ?? {}
2260
+ } };
2261
+ }
2262
+ /**
2263
+ * Wrap a raw handler so one that called `reply.send()` and resolved `undefined` returns the
2264
+ * reply: otherwise, while an async send hook (idempotency `onSend`, encryption `preSerialization`) is pending,
2265
+ * Fastify re-sends the `undefined` and the client gets an empty body.
2266
+ * Pinned by `tests/idempotency/raw-route-keyed-body.test.ts`.
2267
+ */
2268
+ function keepRawReply(raw) {
2269
+ return async function rawRoute(request, reply) {
2270
+ let sendCalled = false;
2271
+ const send = reply.send;
2272
+ reply.send = function trackedSend(payload) {
2273
+ sendCalled = true;
2274
+ return send.call(this, payload);
2275
+ };
2276
+ try {
2277
+ const result = await raw.call(this, request, reply);
2278
+ return result === void 0 && (sendCalled || reply.sent) ? reply : result;
2279
+ } finally {
2280
+ reply.send = send;
2281
+ }
2282
+ };
2283
+ }
2284
+ /** Mount custom routes (from presets or user-defined `routes`) on Fastify. */
2285
+ function createCustomRoutes(fastify, routes, controller, options) {
2286
+ const { tag, resourceName, arcDecorator, rateLimitConfig, pluginMw, pipeline, routeGuards, tenantScopeMw, fieldPermissions, onFieldWriteDenied, extensions } = options;
2287
+ for (const route of routes) {
2288
+ if ("raw" in route) throw new Error(`Route ${route.method} ${route.path}: \`raw\` was removed in arc 2.31 — the FIELD now carries the intent. Move the function to \`rawHandler\` (Fastify-native \`(request, reply)\`) or leave it in \`handler\` (arc pipeline, \`(ctx)\`), and delete the flag. If this route comes from a dependency, that package needs a release built against arc >=2.31.`);
2289
+ const routeWithRefs = route;
2290
+ const hasHandler = route.handler !== void 0 || route.rawHandler !== void 0;
2291
+ const hasControllerMethod = typeof routeWithRefs.controllerMethod === "function";
2292
+ if (hasHandler && hasControllerMethod) throw new Error(`Route ${route.method} ${route.path}: pass either \`handler\` or \`controllerMethod\`, not both. Prefer \`controllerMethod: (c: MyController) => c.method\` for typed handler refs (TS catches typos).`);
2293
+ if (!hasHandler && !hasControllerMethod) throw new Error(`Route ${route.method} ${route.path}: must declare one of \`handler\` (arc pipeline), \`rawHandler\` (Fastify-native), or \`controllerMethod: (c) => c.method\` (typed function-ref form).`);
2294
+ let resolvedHandler;
2295
+ if (hasControllerMethod) {
2296
+ if (!controller) throw new Error(`Route ${route.method} ${route.path}: \`controllerMethod\` requires a controller. Provide one via \`defineResource({ controller, … })\`, or use \`defineResource\` with an \`adapter\` so arc auto-creates a BaseController.`);
2297
+ const referenced = routeWithRefs.controllerMethod?.(controller);
2298
+ if (typeof referenced !== "function") throw new Error(`Route ${route.method} ${route.path}: \`controllerMethod\` did not return a function. Return the method itself: \`controllerMethod: (c) => c.myMethod\`.`);
2299
+ resolvedHandler = referenced.bind(controller);
2300
+ } else {
2301
+ if (route.rawHandler && route.handler) throw new Error(`Route ${route.method} ${route.path}: set either \`handler\` (arc pipeline) or \`rawHandler\` (Fastify-native), not both.`);
2302
+ resolvedHandler = route.rawHandler ?? route.handler;
2303
+ }
2304
+ const opName = route.operation ?? (typeof resolvedHandler === "string" ? resolvedHandler : `${route.method.toLowerCase()}${route.path.replace(/[/:]/g, "_")}`);
2305
+ const isRaw = route.rawHandler !== void 0;
2306
+ const wrapHandler = !isRaw;
2307
+ let handler;
2308
+ if (typeof resolvedHandler === "string") {
2309
+ if (!controller) throw new Error(`Route ${route.method} ${route.path}: string handler '${resolvedHandler}' requires a controller. Either provide a controller or use a function handler instead.`);
2310
+ const method = controller[resolvedHandler];
2311
+ if (typeof method !== "function") throw new Error(`Handler '${resolvedHandler}' not found on controller`);
2312
+ const boundMethod = method.bind(controller);
2313
+ if (wrapHandler) {
2314
+ const steps = resolvePipelineSteps(pipeline, opName);
2315
+ handler = steps.length > 0 ? buildPipelineHandler(boundMethod, steps, opName, resourceName) : createFastifyHandler(boundMethod);
2316
+ } else handler = keepRawReply(boundMethod);
2317
+ } else if (wrapHandler) {
2318
+ const steps = resolvePipelineSteps(pipeline, opName);
2319
+ handler = steps.length > 0 ? buildPipelineHandler(resolvedHandler, steps, opName, resourceName) : createFastifyHandler(resolvedHandler);
2320
+ } else handler = keepRawReply(resolvedHandler);
2321
+ const routeTags = route.tags ?? (tag ? [tag] : void 0);
2322
+ const convertedSchema = route.schema ? convertRouteSchema(route.schema) : void 0;
2323
+ const schema = {
2324
+ ...routeTags ? { tags: routeTags } : {},
2325
+ ...route.summary ? { summary: route.summary } : {},
2326
+ ...route.description ? { description: route.description } : {},
2327
+ ...convertedSchema ?? {}
2328
+ };
2329
+ const customPreHandlers = resolveRoutePreHandlers(route.preHandler, fastify, `${route.method} ${route.path}`);
2330
+ if (route.tenantScope === true) {
2331
+ if (!tenantScopeMw || tenantScopeMw.length === 0) throw new Error(`Route ${route.method} ${route.path}: \`tenantScope: true\` requires a multi-tenant preset. Add \`multiTenantPreset()\` to the resource's \`presets\`, or remove the \`tenantScope\` flag from this route.`);
2332
+ customPreHandlers.unshift(...tenantScopeMw);
2333
+ }
2334
+ if (!isRaw && route.fieldWrite !== false && methodCarriesBody(route.method)) {
2335
+ const fieldWriteMw = buildFieldWritePreHandler(fieldPermissions, onFieldWriteDenied);
2336
+ if (fieldWriteMw) customPreHandlers.push(fieldWriteMw);
2337
+ }
2338
+ const preAuthHandlers = route.preAuth ?? [];
2339
+ const hooks = buildRouteHooks({
2340
+ preAuth: preAuthHandlers,
2341
+ arcDecorator,
2342
+ authMw: buildAuthMiddleware(fastify, route.permissions),
2343
+ permissionMw: buildCrudPermissionMw(route.permissions, resourceName, opName),
2344
+ pluginMw: selectPluginMw(route.method, pluginMw),
2345
+ routeGuards,
2346
+ customMws: customPreHandlers
2347
+ });
2348
+ const isStream = route.streamResponse === true;
2349
+ tryRegisterRoute(fastify, {
2350
+ method: route.method,
2351
+ url: route.path,
2352
+ schema,
2353
+ ...routeHookOptions(hooks),
2354
+ handler: isStream ? async function streamRoute(request, reply) {
2355
+ reply.raw.setHeader("Content-Type", "text/event-stream");
2356
+ reply.raw.setHeader("Cache-Control", "no-cache");
2357
+ reply.raw.setHeader("Connection", "keep-alive");
2358
+ const result = await handler.call(this, request, reply);
2359
+ if (isReadableStream(result)) {
2360
+ await pipeUIMessageStreamToReply(reply, result);
2361
+ return reply;
2362
+ }
2363
+ return result;
2364
+ } : handler,
2365
+ ...mergeRouteConfig(route.config, buildRouteConfig(route.rateLimit !== void 0 ? buildRateLimitConfig(route.rateLimit) : rateLimitConfig, extensions, route.cors))
2366
+ }, {
2367
+ resourceName,
2368
+ op: opName,
2369
+ ...route.rateLimit && typeof route.rateLimit === "object" ? { rateLimitBucket: `route:${route.method}:${route.path}` } : {}
2370
+ });
2371
+ }
2372
+ }
2373
+ /**
2374
+ * Create CRUD routes for a controller.
2375
+ *
2376
+ * @param fastify - Fastify instance with Arc decorators
2377
+ * @param controller - CRUD controller with handler methods (optional when
2378
+ * `disableDefaultRoutes: true` and only custom `routes`
2379
+ * are being registered)
2380
+ * @param options - Router configuration
2381
+ */
2382
+ function createCrudRouter(fastify, controller, options = {}) {
2383
+ const { tag = "Resource", schemas = {}, permissions = {}, middlewares = {}, routeGuards = [], routes: customRoutes = [], disableDefaultRoutes = false, disabledRoutes = [], resourceName = "unknown", schemaOptions, rateLimit, pipe: pipeline, fields: fieldPermissions, onFieldWriteDenied, updateMethod = DEFAULT_UPDATE_METHOD, idField, extensions, idempotency } = options;
2384
+ const idempotentOps = new Set(idempotency?.required ?? []);
2385
+ for (const op of idempotentOps) if (disableDefaultRoutes || disabledRoutes.includes(op)) throw new Error(`[arc] Resource "${resourceName}" declares idempotency.required '${op}', but that built-in route is not mounted — drop it from idempotency.required.`);
2386
+ const rateLimitConfig = buildRateLimitConfig(rateLimit);
2387
+ const resourceHasQueryCache = fastify.hasDecorator("queryCache") && controller && typeof controller._cacheConfig !== "undefined" && controller._cacheConfig !== void 0;
2388
+ const pluginMw = resolveRouterPluginMw(fastify, Boolean(resourceHasQueryCache));
2389
+ const arcDecorator = buildArcDecorator({
2390
+ resourceName,
2391
+ schemaOptions,
2392
+ permissions,
2393
+ hooks: fastify.arc?.hooks,
2394
+ events: fastify.events,
2395
+ fields: fieldPermissions,
2396
+ idField
2397
+ });
2398
+ const mw = {
2399
+ list: middlewares.list ?? [],
2400
+ get: middlewares.get ?? [],
2401
+ create: middlewares.create ?? [],
2402
+ update: middlewares.update ?? [],
2403
+ delete: middlewares.delete ?? []
2404
+ };
2405
+ const tenantScopeMw = middlewares.tenantScope;
2406
+ const idParamsSchema = {
2407
+ type: "object",
2408
+ properties: { id: { type: "string" } },
2409
+ required: ["id"]
2410
+ };
2411
+ const defaultSchemas = getDefaultCrudSchemas();
2412
+ const buildSchema = (base, defaults, userSchema) => ({
2413
+ ...defaults,
2414
+ ...base,
2415
+ ...userSchema ?? {}
2416
+ });
2417
+ if (!disableDefaultRoutes) {
2418
+ if (!controller) throw new Error("Controller is required when disableDefaultRoutes is not true. Provide a controller or use defineResource which auto-creates BaseController.");
2419
+ const handlers = buildCrudHandlers(controller, pipeline, resourceName);
2420
+ const crudTable = [
2421
+ {
2422
+ op: "list",
2423
+ method: "GET",
2424
+ url: "/",
2425
+ summary: `List ${tag}`,
2426
+ hasIdParams: false
2427
+ },
2428
+ {
2429
+ op: "get",
2430
+ method: "GET",
2431
+ url: "/:id",
2432
+ summary: `Get ${tag} by ID`,
2433
+ hasIdParams: true
2434
+ },
2435
+ {
2436
+ op: "create",
2437
+ method: "POST",
2438
+ url: "/",
2439
+ summary: `Create ${tag}`,
2440
+ hasIdParams: false
2441
+ },
2442
+ {
2443
+ op: "update",
2444
+ method: "PATCH",
2445
+ url: "/:id",
2446
+ summary: `Update ${tag}`,
2447
+ hasIdParams: true
2448
+ },
2449
+ {
2450
+ op: "delete",
2451
+ method: "DELETE",
2452
+ url: "/:id",
2453
+ summary: `Delete ${tag}`,
2454
+ hasIdParams: true
2455
+ }
2456
+ ];
2457
+ for (const spec of crudTable) {
2458
+ if (disabledRoutes.includes(spec.op)) continue;
2459
+ const permission = permissions[spec.op];
2460
+ const hooks = buildRouteHooks({
2461
+ arcDecorator,
2462
+ authMw: buildAuthMiddleware(fastify, permission),
2463
+ permissionMw: buildCrudPermissionMw(permission, resourceName, spec.op),
2464
+ pluginMw: selectPluginMw(spec.method, pluginMw),
2465
+ routeGuards,
2466
+ customMws: mw[spec.op]
2467
+ });
2468
+ const methodsToRegister = spec.op === "update" ? updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod] : [spec.method];
2469
+ for (const method of methodsToRegister) {
2470
+ const summary = spec.op === "update" ? `${method === "PUT" ? "Replace" : "Update"} ${tag}` : spec.summary;
2471
+ tryRegisterRoute(fastify, {
2472
+ method,
2473
+ url: spec.url,
2474
+ schema: buildSchema({
2475
+ tags: [tag],
2476
+ summary,
2477
+ ...spec.hasIdParams ? { params: idParamsSchema } : {}
2478
+ }, defaultSchemas[spec.op], schemas[spec.op]),
2479
+ ...routeHookOptions(hooks),
2480
+ handler: handlers[spec.op],
2481
+ ...buildRouteConfig(rateLimitConfig, extensions, void 0, idempotentOps.has(spec.op) ? { required: true } : void 0)
2482
+ }, {
2483
+ resourceName,
2484
+ op: spec.op
2485
+ });
2486
+ }
2487
+ }
2488
+ }
2489
+ if (customRoutes.length > 0) createCustomRoutes(fastify, customRoutes, controller, {
2490
+ tag,
2491
+ resourceName,
2492
+ arcDecorator,
2493
+ rateLimitConfig,
2494
+ pluginMw,
2495
+ pipeline,
2496
+ routeGuards,
2497
+ tenantScopeMw,
2498
+ fieldPermissions,
2499
+ onFieldWriteDenied,
2500
+ extensions
2501
+ });
2502
+ }
2503
+ function buildCrudHandlers(ctrl, pipeline, resourceName) {
2504
+ const standardHandlers = createCrudHandlers(ctrl);
2505
+ if (!pipeline) return standardHandlers;
2506
+ const wrapped = { ...standardHandlers };
2507
+ for (const op of CRUD_OPERATIONS) {
2508
+ const steps = resolvePipelineSteps(pipeline, op);
2509
+ if (steps.length === 0) continue;
2510
+ const method = ctrl[op].bind(ctrl);
2511
+ wrapped[op] = buildPipelineHandler(method, steps, op, resourceName);
2512
+ }
2513
+ return wrapped;
2514
+ }
2515
+ /**
2516
+ * Build a permission middleware from a PermissionCheck, for host routes
2517
+ * registered outside the resource system.
2518
+ */
2519
+ function createPermissionMiddleware(permission, resourceName, action) {
2520
+ return buildCrudPermissionMw(permission, resourceName, action);
2521
+ }
2522
+ //#endregion
2523
+ //#region src/core/schemaOptions.ts
2524
+ /**
2525
+ * Inject the tenant-scoping field rule into `schemaOptions.fieldRules`:
2526
+ *
2527
+ * { [tenantField]: { systemManaged: true, preserveForElevated: true } }
2528
+ *
2529
+ * Why both flags: `systemManaged` tells `BodySanitizer` to strip the
2530
+ * field from inbound bodies (so member clients can't forge a target
2531
+ * tenant). `preserveForElevated` exempts elevated-admin scopes from the
2532
+ * strip, so platform admins without a pinned org can still pick a target
2533
+ * org via the request body (the only channel they have —
2534
+ * `BaseController.create` can't re-stamp from scope when scope has no
2535
+ * orgId).
2536
+ *
2537
+ * **Returns a new `RouteSchemaOptions`** — the input is never mutated.
2538
+ * Callers should assign the return value to whatever config slot they
2539
+ * read from downstream (always the `resolvedConfig`, never raw `config`).
2540
+ *
2541
+ * **No-op when:**
2542
+ * - `tenantField` is `false` (platform-universal resource)
2543
+ * - `tenantField` is undefined
2544
+ * - The caller already declared `fieldRules[tenantField].systemManaged`
2545
+ * (even as `false`) — explicit opt-outs are respected
2546
+ *
2547
+ * `preserveForElevated` defaults to `true` but is preserved verbatim
2548
+ * when the caller set it explicitly.
2549
+ */
2550
+ function autoInjectTenantFieldRules(schemaOptions, tenantField) {
2551
+ if (tenantField === false || tenantField === void 0) return schemaOptions;
2552
+ const fieldName = tenantField || "organizationId";
2553
+ const existing = schemaOptions?.fieldRules ?? {};
2554
+ const existingRule = existing[fieldName];
2555
+ if (existingRule && existingRule.systemManaged !== void 0) return schemaOptions;
2556
+ return {
2557
+ ...schemaOptions ?? {},
2558
+ fieldRules: {
2559
+ ...existing,
2560
+ [fieldName]: {
2561
+ ...existingRule ?? {},
2562
+ systemManaged: true,
2563
+ preserveForElevated: existingRule?.preserveForElevated ?? true
2564
+ }
2565
+ }
2566
+ };
2567
+ }
2568
+ /**
2569
+ * Remove a field from a JSON Schema's `required[]` array. Leaves `properties`
2570
+ * intact so advanced callers can still send the value — the field just isn't
2571
+ * mandatory at validation time.
2572
+ *
2573
+ * Returns a fresh schema (no mutation). No-op when the schema is undefined,
2574
+ * lacks a `required[]`, or the field is already absent from it.
2575
+ */
2576
+ function stripFromRequired(schema, fieldName) {
2577
+ if (!schema || typeof schema !== "object") return schema;
2578
+ const required = schema.required;
2579
+ if (!Array.isArray(required) || !required.includes(fieldName)) return schema;
2580
+ const filtered = required.filter((f) => f !== fieldName);
2581
+ const next = { ...schema };
2582
+ if (filtered.length > 0) next.required = filtered;
2583
+ else delete next.required;
2584
+ return next;
2585
+ }
2586
+ /**
2587
+ * Drop `systemManaged` fields from `required[]` in generated body schemas.
2588
+ *
2589
+ * Arc populates those from scope / preset middleware / controller, so a client
2590
+ * never supplies them — but the DB schema may still mark them required, and the
2591
+ * adapter reflects that into the wire contract.
2592
+ *
2593
+ * THE GOTCHA THIS CLOSES: engines on `@classytic/primitives` default to
2594
+ * `tenant: { required: true }`, stamping `organizationId` as required. Fastify
2595
+ * preValidation runs BEFORE arc's preHandler chain, so the tenant-injection
2596
+ * hook never gets to run and the request dies on
2597
+ * `must have required property 'organizationId'` — even though the client sent
2598
+ * `x-organization-id` and arc had promised to inject it. The alternative was
2599
+ * `tenant: { required: false }` at every consumer site.
2600
+ *
2601
+ * Also covers `auditedPreset`'s `createdBy`/`updatedBy` and any host rule
2602
+ * marked `systemManaged`. Applies to create AND update, since update middleware
2603
+ * injects the same fields.
2604
+ *
2605
+ * `properties` is left INTACT — an elevated caller may still send these, and
2606
+ * `BodySanitizer` owns that runtime policy (`preserveForElevated`,
2607
+ * strip-vs-reject). No-op without `systemManaged` rules or generated schemas.
2608
+ */
2609
+ function stripSystemManagedFromBodyRequired(schemas, schemaOptions) {
2610
+ if (!schemas) return schemas;
2611
+ const rules = schemaOptions?.fieldRules;
2612
+ if (!rules) return schemas;
2613
+ const systemManagedFields = Object.entries(rules).filter(([, rule]) => rule?.systemManaged === true).map(([field]) => field);
2614
+ if (systemManagedFields.length === 0) return schemas;
2615
+ const next = { ...schemas };
2616
+ let createBody = schemas.createBody;
2617
+ for (const field of systemManagedFields) createBody = stripFromRequired(createBody, field);
2618
+ if (createBody !== schemas.createBody) next.createBody = createBody;
2619
+ let updateBody = schemas.updateBody;
2620
+ for (const field of systemManagedFields) updateBody = stripFromRequired(updateBody, field);
2621
+ if (updateBody !== schemas.updateBody) next.updateBody = updateBody;
2622
+ return next;
2623
+ }
2624
+ //#endregion
2625
+ //#region src/core/defineResource/schemas.ts
2626
+ /**
2627
+ * OpenAPI schema resolution — Phase 7 of `defineResource()`.
2628
+ *
2629
+ * Pipeline (each step is a pure function over `OpenApiSchemas | undefined`):
2630
+ *
2631
+ * adapter.generateSchemas()
2632
+ * → stripSystemManagedFromBodyRequired (from `../schemaOptions.js`)
2633
+ * → cleanLegacyObjectIdParams (idField safety net)
2634
+ * → layerQueryParserListQuery (kit's listQuery JSON Schema)
2635
+ * → mergeUserOpenApiOverrides (per-resource overrides)
2636
+ * → convertOpenApiSchemas (Zod → JSON Schema if needed)
2637
+ *
2638
+ * Non-fatal: if any step throws, the orchestrator returns `undefined` so
2639
+ * the resource still boots — docs / introspection / MCP tool schemas
2640
+ * degrade visibly instead of silently drifting.
2641
+ *
2642
+ * Pulled out of `defineResource.ts` so the central function reads as
2643
+ * orchestration only; the schema mechanics live next to each other and
2644
+ * are easier to evolve in isolation.
2645
+ */
2646
+ /**
2647
+ * Phase 7 orchestrator — runs the schema pipeline and returns the
2648
+ * registry metadata for the resource. Returns `undefined` (with a
2649
+ * structured warn log) if any step throws.
2650
+ */
2651
+ function resolveOpenApiSchemas(resolvedConfig) {
2652
+ try {
2653
+ let openApiSchemas = generateAdapterSchemas(resolvedConfig);
2654
+ openApiSchemas = stripSystemManagedFromBodyRequired(openApiSchemas, resolvedConfig.schemaOptions);
2655
+ openApiSchemas = cleanLegacyObjectIdParams(openApiSchemas, resolvedConfig.idField);
2656
+ openApiSchemas = layerQueryParserListQuery(openApiSchemas, resolvedConfig.queryParser);
2657
+ openApiSchemas = applyResourcePaginationCaps(openApiSchemas, resolvedConfig);
2658
+ openApiSchemas = mergeUserOpenApiOverrides(openApiSchemas, resolvedConfig.openApiSchemas);
2659
+ if (openApiSchemas) openApiSchemas = convertOpenApiSchemas(openApiSchemas);
2660
+ return {
2661
+ module: resolvedConfig.module,
2662
+ openApiSchemas
2663
+ };
2664
+ } catch (err) {
2665
+ arcLog("defineResource").warn(`OpenAPI/MCP schema generation failed for resource "${resolvedConfig.name}": ${err instanceof Error ? err.message : String(err)}. Resource will boot without registry metadata — OpenAPI docs and MCP tool schemas will be missing.`);
2666
+ return;
2667
+ }
2668
+ }
2669
+ /**
2670
+ * Step 1 — delegate to the adapter's `generateSchemas`. Returns
2671
+ * `undefined` when the adapter doesn't implement the optional method.
2672
+ */
2673
+ function generateAdapterSchemas(resolvedConfig) {
2674
+ if (!resolvedConfig.adapter?.generateSchemas) return void 0;
2675
+ const adapterContext = {
2676
+ idField: resolvedConfig.idField,
2677
+ resourceName: resolvedConfig.name
2678
+ };
2679
+ return resolvedConfig.adapter.generateSchemas(resolvedConfig.schemaOptions, adapterContext);
2680
+ }
2681
+ /**
2682
+ * Safety net: when `idField` is overridden to a non-default value (UUIDs,
2683
+ * slugs, ORD-2026-0001), strip any ObjectId pattern left on `params.id` by
2684
+ * legacy adapters or plugins that didn't honor `AdapterSchemaContext.idField`.
2685
+ * Custom IDs must not be rejected by AJV before BaseController runs the
2686
+ * actual lookup.
2687
+ */
2688
+ function cleanLegacyObjectIdParams(openApiSchemas, idField) {
2689
+ if (!openApiSchemas || !idField || idField === "_id") return openApiSchemas;
2690
+ const params = withoutObjectIdPattern(openApiSchemas.params, `${idField} (custom ID field)`);
2691
+ return params === openApiSchemas.params ? openApiSchemas : {
2692
+ ...openApiSchemas,
2693
+ params
2694
+ };
2695
+ }
2696
+ /**
2697
+ * `params` with the ObjectId pattern removed from `id` — the same object when there is none.
2698
+ * `GET /:id` on a resource with `lookupFields` uses it: the id may be a business number.
2699
+ */
2700
+ function withoutObjectIdPattern(params, description) {
2701
+ if (!params || typeof params !== "object") return params;
2702
+ const properties = params.properties;
2703
+ const idProp = properties?.id;
2704
+ if (!idProp || typeof idProp !== "object") return params;
2705
+ const pattern = idProp.pattern;
2706
+ if (!(typeof pattern === "string" && (pattern === "^[0-9a-fA-F]{24}$" || pattern === "^[a-f\\d]{24}$" || pattern === "^[a-fA-F0-9]{24}$" || /^\^\[[a-fA-F0-9\\d]+\]\{24\}\$$/.test(pattern)))) return params;
2707
+ const cleanedId = { ...idProp };
2708
+ delete cleanedId.pattern;
2709
+ delete cleanedId.minLength;
2710
+ delete cleanedId.maxLength;
2711
+ if (!cleanedId.description) cleanedId.description = description;
2712
+ return {
2713
+ ...params,
2714
+ properties: {
2715
+ ...properties,
2716
+ id: cleanedId
2717
+ }
2718
+ };
2719
+ }
2720
+ /**
2721
+ * Layer the query parser's `getQuerySchema()` output as `listQuery` so
2722
+ * the kit's filterable-fields surface flows into OpenAPI / MCP without
2723
+ * the user re-declaring it.
2724
+ */
2725
+ function layerQueryParserListQuery(openApiSchemas, queryParser) {
2726
+ const qp = queryParser;
2727
+ if (!qp?.getQuerySchema) return openApiSchemas;
2728
+ const querySchema = qp.getQuerySchema();
2729
+ if (!querySchema) return openApiSchemas;
2730
+ return {
2731
+ ...openApiSchemas,
2732
+ listQuery: querySchema
2733
+ };
2734
+ }
2735
+ /**
2736
+ * Apply resource-level `defaultLimit` / `maxLimit` (2.17.0) onto the
2737
+ * listQuery schema. Resource-level caps win over both the framework
2738
+ * default and the parser-derived value because they're the most-explicit
2739
+ * declaration — a host that wrote `defaultLimit: 1000` on the resource
2740
+ * means "this resource specifically returns up to 1000", regardless of
2741
+ * what the parser's default would have been. The `referenceData: true`
2742
+ * shorthand lifts both to 1000 so reference-data resources don't need
2743
+ * a custom queryParser just to get the caps right.
2744
+ *
2745
+ * Bootstraps a minimal listQuery slot when none exists (no queryParser
2746
+ * registered, no adapter-generated schema) so the AJV layer still gets
2747
+ * a `maximum:` and surfaces the cap in the 400 envelope via
2748
+ * `errorHandler.describePaginationCap()` instead of producing the
2749
+ * generic "Bad Request" we ate pre-2.17.0.
2750
+ */
2751
+ function applyResourcePaginationCaps(openApiSchemas, resolvedConfig) {
2752
+ const cfg = resolvedConfig;
2753
+ if (cfg.defaultLimit === void 0 && cfg.maxLimit === void 0) return openApiSchemas;
2754
+ const existing = openApiSchemas?.listQuery;
2755
+ const existingProps = existing?.properties ?? {};
2756
+ const patchedLimit = { ...existingProps.limit ?? {
2757
+ type: "integer",
2758
+ minimum: 1
2759
+ } };
2760
+ if (cfg.maxLimit !== void 0) patchedLimit.maximum = cfg.maxLimit;
2761
+ if (cfg.defaultLimit !== void 0) patchedLimit.default = cfg.defaultLimit;
2762
+ return {
2763
+ ...openApiSchemas,
2764
+ listQuery: {
2765
+ type: "object",
2766
+ ...existing ?? {},
2767
+ properties: {
2768
+ ...existingProps,
2769
+ limit: patchedLimit
2770
+ }
2771
+ }
2772
+ };
2773
+ }
2774
+ /**
2775
+ * Apply per-resource `openApiSchemas` overrides on top of the kit's
2776
+ * generated schemas. Shallow merge by slot — users who want field-level
2777
+ * surgery should compose at the schema-options layer before this point.
2778
+ */
2779
+ function mergeUserOpenApiOverrides(openApiSchemas, userOverrides) {
2780
+ if (!userOverrides) return openApiSchemas;
2781
+ return {
2782
+ ...openApiSchemas,
2783
+ ...userOverrides
2784
+ };
2785
+ }
2786
+ //#endregion
2787
+ //#region src/core/defineResource/plugin.ts
2788
+ /**
2789
+ * Build the CRUD schema map from the adapter's `OpenApiSchemas`, then layer
2790
+ * `customSchemas` on top per slot (auto-gen always runs, so customising one
2791
+ * slot never disables the others). Returns `null` when neither contributes.
2792
+ *
2793
+ * **`params` cloning is load-bearing:** `get` / `delete` / `update` each own a
2794
+ * clone, so a downstream mutation on one slot cannot leak into the others.
2795
+ */
2796
+ function buildGeneratedCrudSchemas(openApi, customSchemas) {
2797
+ const generated = {};
2798
+ if (openApi) {
2799
+ const { createBody, updateBody, params } = openApi;
2800
+ if (createBody) generated.create = { body: safeBody(createBody) };
2801
+ if (updateBody) {
2802
+ const patchBody = structuredClone(updateBody);
2803
+ stripRequiredDeep(patchBody);
2804
+ generated.update = { body: safeBody(patchBody) };
2805
+ if (params) generated.update.params = cloneShallow(params);
2806
+ }
2807
+ if (params) {
2808
+ generated.get = { params: cloneShallow(params) };
2809
+ generated.delete = { params: cloneShallow(params) };
2810
+ if (!generated.update) generated.update = { params: cloneShallow(params) };
2811
+ else if (!generated.update.params) generated.update.params = cloneShallow(params);
2812
+ }
2813
+ }
2814
+ let schemas = Object.keys(generated).length > 0 ? generated : null;
2815
+ if (customSchemas && Object.keys(customSchemas).length > 0) {
2816
+ schemas = schemas ?? {};
2817
+ for (const [op, customSchema] of Object.entries(customSchemas)) {
2818
+ const key = op;
2819
+ const converted = convertRouteSchema(customSchema);
2820
+ schemas[key] = schemas[key] ? {
2821
+ ...schemas[key],
2822
+ ...converted
2823
+ } : converted;
2824
+ }
2825
+ }
2826
+ return schemas;
2827
+ }
2828
+ /**
2829
+ * Normalize the listQuery JSON Schema for Fastify/AJV strict mode.
2830
+ *
2831
+ * `qs` turns bracket notation (`?name[contains]=foo`, `?tags[]=a`) into nested
2832
+ * objects/arrays that AJV rejects against adapters' `type: "string"` props. The
2833
+ * QueryParser owns filter validation, so every property collapses to `{}` except
2834
+ * integer `page` / `limit`; `limit.maximum` is preserved so the parser's cap holds.
2835
+ */
2836
+ function normalizeListQuerySchema(listQuerySchema) {
2837
+ const NORMALIZED_PROPS = {
2838
+ page: {
2839
+ type: "integer",
2840
+ minimum: 1
2841
+ },
2842
+ limit: {
2843
+ type: "integer",
2844
+ minimum: 1
2845
+ },
2846
+ sort: {},
2847
+ search: {},
2848
+ select: {},
2849
+ after: {},
2850
+ populate: {},
2851
+ lookup: {},
2852
+ aggregate: {}
2853
+ };
2854
+ const props = listQuerySchema.properties;
2855
+ const normalizedProps = props ? { ...props } : void 0;
2856
+ if (normalizedProps) {
2857
+ const originalLimit = normalizedProps.limit;
2858
+ if (originalLimit?.maximum) NORMALIZED_PROPS.limit = {
2859
+ ...NORMALIZED_PROPS.limit,
2860
+ maximum: originalLimit.maximum
2861
+ };
2862
+ for (const key of Object.keys(normalizedProps)) normalizedProps[key] = NORMALIZED_PROPS[key] ?? {};
2863
+ }
2864
+ const keysetProps = {
2865
+ mode: {
2866
+ type: "string",
2867
+ description: "Pagination mode: `offset` (default) or `keyset` (walk by `after` / `before`)."
2868
+ },
2869
+ before: {
2870
+ type: "string",
2871
+ description: "Cursor from a page's `prev` — the page before it."
2872
+ }
2873
+ };
2874
+ return {
2875
+ type: "object",
2876
+ ...listQuerySchema,
2877
+ properties: {
2878
+ ...keysetProps,
2879
+ ...normalizedProps ?? listQuerySchema.properties ?? {}
2880
+ },
2881
+ additionalProperties: listQuerySchema.additionalProperties ?? true
2882
+ };
2883
+ }
2884
+ /**
2885
+ * Deep-merge two JSON schema branches: arrays union (deduplicated), objects
2886
+ * recurse, primitives are overwritten.
2887
+ */
2888
+ function deepMergeSchemas(base, override) {
2889
+ if (!override) return base;
2890
+ if (!base) return override;
2891
+ const result = { ...base };
2892
+ for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [.../* @__PURE__ */ new Set([...result[key], ...value])];
2893
+ else if (value && typeof value === "object" && !Array.isArray(value)) result[key] = deepMergeSchemas(result[key], value);
2894
+ else result[key] = value;
2895
+ return result;
2896
+ }
2897
+ function cloneShallow(value) {
2898
+ return { ...value };
2899
+ }
2900
+ /**
2901
+ * Delete every `required` from a JSON-schema subtree — PATCH means optional at
2902
+ * every depth (e.g. subdocument-array `items`), whatever the generator produced.
2903
+ */
2904
+ function stripRequiredDeep(node) {
2905
+ if (Array.isArray(node)) {
2906
+ for (const item of node) stripRequiredDeep(item);
2907
+ return;
2908
+ }
2909
+ if (!node || typeof node !== "object") return;
2910
+ const obj = node;
2911
+ delete obj.required;
2912
+ for (const value of Object.values(obj)) stripRequiredDeep(value);
2913
+ }
2914
+ function safeBody(schema) {
2915
+ if (schema && typeof schema === "object" && schema.type === "object") return {
2916
+ additionalProperties: true,
2917
+ ...schema
2918
+ };
2919
+ return schema;
2920
+ }
2921
+ /**
2922
+ * Normalize `ActionsMap` into the config `createActionRouter` expects.
2923
+ *
2924
+ * Permissions are resolved FAIL-CLOSED at boot via `actionPermissions.ts`: the
2925
+ * returned map carries every action's effective gate, and an action with none
2926
+ * throws. The resource-level gate uses slot `resourceActionPermissions`;
2927
+ * `globalAuth` stays reserved for direct `createActionRouter` callers.
2928
+ */
2929
+ function normalizeActionsToRouterConfig(actions, resourceActionPermissions, tag, resourcePermissions, resourceName, log) {
2930
+ const handlers = {};
2931
+ const permissions = {};
2932
+ const schemas = {};
2933
+ const idLessActionNames = [];
2934
+ const handlerScopedActionNames = [];
2935
+ const idempotentActionNames = [];
2936
+ const handlerDedupOwners = {};
2937
+ for (const [name, entry] of Object.entries(actions)) {
2938
+ const explicit = typeof entry !== "function" && entry.permissions ? entry.permissions : void 0;
2939
+ if (typeof entry === "function") handlers[name] = entry;
2940
+ else {
2941
+ const def = entry;
2942
+ handlers[name] = def.handler;
2943
+ if (def.schema) schemas[name] = def.schema;
2944
+ if (def.id === false) idLessActionNames.push(name);
2945
+ if (def.idempotency?.required === true) idempotentActionNames.push(name);
2946
+ const problem = idempotencyConfigProblem({ idempotency: def.idempotency ? { ...def.idempotency } : void 0 });
2947
+ if (problem) throw new Error(`[Arc] Resource '${resourceName}': action '${name}' idempotency: ${problem}.`);
2948
+ if (def.idempotency?.dedup === "handler") handlerDedupOwners[name] = String(def.idempotency.owner);
2949
+ if (def.target !== void 0) {
2950
+ if (def.target.scope !== "handler" || def.target.reason.trim() === "") throw new Error(`[Arc] Resource '${resourceName}': action '${name}' declares \`target\` without \`scope: 'handler'\` and a non-empty \`reason\` — say why its target is not this resource's row.`);
2951
+ handlerScopedActionNames.push(name);
2952
+ }
2953
+ }
2954
+ const effective = resolveActionPermission({
2955
+ action: entry,
2956
+ resourcePermissions,
2957
+ resourceActionPermissions,
2958
+ globalAuth: void 0
2959
+ });
2960
+ if (!explicit && !resourceActionPermissions && effective && effective === resourcePermissions?.update) log?.warn?.({
2961
+ resource: resourceName,
2962
+ action: name,
2963
+ fallback: "permissions.update"
2964
+ }, `[Arc] Action '${resourceName}.${name}' has no explicit permission — falling back to the resource's \`permissions.update\` gate. Declare \`actions.${name}.permissions\` (or resource \`actionPermissions\`) to silence this.`);
2965
+ if (!effective) throw new Error(`[Arc] Resource '${resourceName}': action '${name}' has no permission gate and the resource defines no \`permissions.update\` fallback. Declare one of:\n - \`actions.${name}.permissions: <PermissionCheck>\` (per-action)\n - \`actionPermissions: <PermissionCheck>\` (resource-wide)\n - \`permissions.update: <PermissionCheck>\` (inherited by actions)\nUse \`allowPublic()\` if you genuinely want the action unauthenticated.`);
2966
+ permissions[name] = effective;
2967
+ }
2968
+ return {
2969
+ tag,
2970
+ actions: handlers,
2971
+ actionPermissions: permissions,
2972
+ actionSchemas: schemas,
2973
+ idLessActionNames,
2974
+ handlerScopedActionNames,
2975
+ idempotentActionNames,
2976
+ handlerDedupOwners
2977
+ };
2978
+ }
2979
+ /**
2980
+ * The action-target fence: an id-bound action may act only on a row the caller could
2981
+ * `GET`. Built from the controller's access control; a tenant-scoped resource whose
2982
+ * controller cannot answer is refused at boot rather than mounted unfenced. HTTP and MCP
2983
+ * both call it, so the two surfaces fence the same rows.
2984
+ */
2985
+ function resolveActionTargetFence(resource, actionConfig) {
2986
+ const exempt = /* @__PURE__ */ new Set([...actionConfig.idLessActionNames, ...actionConfig.handlerScopedActionNames]);
2987
+ const fenced = Object.keys(actionConfig.actions).filter((name) => !exempt.has(name));
2988
+ if (fenced.length === 0) return void 0;
2989
+ const ctrl = resource.controller;
2990
+ const check = ctrl?.isActionTargetVisible;
2991
+ if (!(typeof check === "function" && ctrl?.canFenceActionTargets?.() === true)) {
2992
+ if (typeof resource.tenantField === "string" || ctrl !== void 0 && resource.tenantField !== false) throw new Error(`[Arc] Resource '${resource.name}' is tenant-scoped (tenantField '${resource.tenantField === void 0 ? "organizationId (default)" : resource.tenantField}') and declares id-bound actions [${fenced.join(", ")}], but its controller cannot authorize an action target. Use a BaseController with a repository, declare \`tenantField: false\`, or declare \`target: { scope: 'handler', reason }\` on each action.`);
2993
+ return;
2994
+ }
2995
+ return (id, ctx) => check.call(ctrl, ctx, id);
2996
+ }
2997
+ /**
2998
+ * Build the FastifyPluginAsync that materialises a `ResourceDefinition` into
2999
+ * routes, hooks, registry entries, and cache invalidation rules. Can be
3000
+ * registered any number of times; shared-state writes happen once per host.
3001
+ */
3002
+ function buildResourcePlugin(resource) {
3003
+ return async function resourcePlugin(fastify, opts) {
3004
+ await ensureSchemaConverter();
3005
+ const sharedRoot = fastify.server;
3006
+ const isFirstMount = !resource._sharedStateRegisteredOn.has(sharedRoot);
3007
+ if (isFirstMount) resource._sharedStateRegisteredOn.add(sharedRoot);
3008
+ if (isFirstMount && resource._deferredChecks?.length) for (const check of resource._deferredChecks) check();
3009
+ if (isFirstMount && resource._diagnostics?.length) for (const diagnostic of resource._diagnostics) {
3010
+ const level = diagnostic.severity === "info" ? "info" : diagnostic.severity === "error" ? "error" : "warn";
3011
+ fastify.log?.[level]?.(diagnostic.message);
3012
+ }
3013
+ const arc = fastify.arc;
3014
+ if (isFirstMount && arc?.registry && resource._registryMeta) arc.registry.register(resource, resource._registryMeta);
3015
+ if (isFirstMount && resource._pendingHooks.length > 0 && arc?.hooks) for (const hook of resource._pendingHooks) arc.hooks.register({
3016
+ resource: resource.name,
3017
+ operation: hook.operation,
3018
+ phase: hook.phase,
3019
+ handler: hook.handler,
3020
+ priority: hook.priority
3021
+ });
3022
+ if (isFirstMount && resource.cache?.invalidateOn) {
3023
+ if (fastify.registerCacheInvalidationRule) for (const [pattern, tags] of Object.entries(resource.cache.invalidateOn)) fastify.registerCacheInvalidationRule({
3024
+ pattern,
3025
+ tags
3026
+ });
3027
+ else fastify.log?.warn?.(`[Arc] Resource '${resource.name}' declares \`cache.invalidateOn\` but \`queryCachePlugin\` is not registered. Cache-invalidation rules are silently ignored. Either register the plugin (\`await fastify.register(queryCachePlugin, ...)\`) or remove the \`cache.invalidateOn\` config to clean up the resource definition.`);
3028
+ }
3029
+ if (isFirstMount && resource.audit) {
3030
+ if (!(fastify.hasDecorator("audit") && typeof fastify.audit?.create === "function" && fastify.audit?._noop !== true)) fastify.log?.warn?.(`[Arc] Resource '${resource.name}' declares \`audit: ${JSON.stringify(resource.audit)}\` but \`auditPlugin\` is not registered (or is registered with a noop logger). Audit events for this resource will NOT be recorded. Either register \`auditPlugin\` (\`await fastify.register(auditPlugin, { store: ... })\`) or remove the \`audit\` flag from the resource definition.`);
3031
+ }
3032
+ if (isFirstMount && resource.events && Object.keys(resource.events).length > 0) {
3033
+ const eventsDecor = fastify.events;
3034
+ if (!(eventsDecor != null && typeof eventsDecor.publish === "function")) {
3035
+ const eventNames = Object.keys(resource.events).join(", ");
3036
+ fastify.log?.warn?.(`[Arc] Resource '${resource.name}' declares events (${eventNames}) but \`eventPlugin\` is not registered. The events will silently NOT be emitted on CRUD mutations. Either register the plugin (\`await fastify.register(eventPlugin, { transport: ... })\`) or remove the \`events\` declaration from the resource.`);
3037
+ }
3038
+ }
3039
+ if (opts?.arcMountRoutes === false) return;
3040
+ await fastify.register(async (instance) => {
3041
+ const typedInstance = instance;
3042
+ let schemas = buildGeneratedCrudSchemas(resource._registryMeta?.openApiSchemas, resource.customSchemas);
3043
+ const getParams = schemas?.get?.params;
3044
+ if (resource.lookupFields?.length && schemas?.get && getParams) schemas.get = {
3045
+ ...schemas.get,
3046
+ params: withoutObjectIdPattern(getParams, `${resource.idField ?? "_id"} or ${resource.lookupFields.join(" / ")}`)
3047
+ };
3048
+ const listQuerySchema = resource._registryMeta?.openApiSchemas?.listQuery;
3049
+ if (listQuerySchema) {
3050
+ const normalizedSchema = normalizeListQuerySchema(listQuerySchema);
3051
+ schemas = schemas ?? {};
3052
+ schemas.list = schemas.list ? deepMergeSchemas({ querystring: normalizedSchema }, schemas.list) : { querystring: normalizedSchema };
3053
+ }
3054
+ createCrudRouter(typedInstance, resource.controller, {
3055
+ tag: resource.tag,
3056
+ schemas: schemas ?? void 0,
3057
+ permissions: resource.permissions,
3058
+ middlewares: resource.middlewares,
3059
+ routeGuards: resource.routeGuards,
3060
+ routes: resource.routes,
3061
+ disableDefaultRoutes: resource.disableDefaultRoutes,
3062
+ disabledRoutes: [...resource.disabledRoutes],
3063
+ resourceName: resource.name,
3064
+ schemaOptions: resource.schemaOptions,
3065
+ rateLimit: resource.rateLimit,
3066
+ idempotency: resource.idempotency,
3067
+ updateMethod: resource.updateMethod,
3068
+ pipe: resource.pipe,
3069
+ fields: resource.fields,
3070
+ onFieldWriteDenied: resource.onFieldWriteDenied,
3071
+ idField: resource.idField,
3072
+ extensions: resource.extensions
3073
+ });
3074
+ if (resource.actions && Object.keys(resource.actions).length > 0) {
3075
+ const { createActionRouter } = await import("./createActionRouter-xjqonMRO.mjs").then((n) => n.n);
3076
+ const actionConfig = normalizeActionsToRouterConfig(resource.actions, resource.actionPermissions, resource.tag, resource.permissions, resource.name, typedInstance.log);
3077
+ const actionTarget = resolveActionTargetFence(resource, actionConfig);
3078
+ createActionRouter(typedInstance, {
3079
+ ...actionConfig,
3080
+ ...actionTarget ? { isTargetVisible: (id, req) => actionTarget(id, createRequestContext(req)) } : {},
3081
+ auditActions: Boolean(resource.audit),
3082
+ resourceName: resource.name,
3083
+ fields: resource.fields,
3084
+ schemaOptions: resource.schemaOptions,
3085
+ idField: resource.idField,
3086
+ permissions: resource.permissions,
3087
+ routeGuards: resource.routeGuards,
3088
+ tenantMiddlewares: withoutTenantInjection(resource.middlewares?.tenantScope),
3089
+ pipeline: resource.pipe,
3090
+ rateLimit: resource.rateLimit,
3091
+ extensions: resource.extensions
3092
+ });
3093
+ }
3094
+ if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
3095
+ const [{ createAggregationRouter }, { adapterSupportsAggregate, ArcAggregationConfigError }] = await Promise.all([import("./createAggregationRouter-zRYWw_5D.mjs"), import("./validate-9yzHC1e2.mjs").then((n) => n.r)]);
3096
+ const repoForAgg = resource.controller?.repository ?? resource.repository;
3097
+ if (!adapterSupportsAggregate(repoForAgg)) {
3098
+ const undispatchable = Object.entries(resource.aggregations).filter(([, cfg]) => !cfg.materialized).map(([name]) => name);
3099
+ if (undispatchable.length > 0) throw new ArcAggregationConfigError(`Resource '${resource.name}' declares aggregations [${undispatchable.join(", ")}] but no repository implementing 'aggregate(req, options?)' is wired. Either (a) attach an adapter whose repo ships StandardRepo.aggregate (mongokit >= 3.13 / sqlitekit >= 0.3), (b) pass a custom controller exposing such a repository, or (c) declare 'materialized' on each aggregation so it dispatches through your own hook.`);
3100
+ }
3101
+ const buildOptions = (req) => {
3102
+ const ctrl = resource.controller;
3103
+ if (!ctrl?.tenantRepoOptions) return {};
3104
+ const ctx = createRequestContext(req);
3105
+ return ctrl.tenantRepoOptions(ctx);
3106
+ };
3107
+ createAggregationRouter(typedInstance, {
3108
+ tag: resource.tag,
3109
+ resourceName: resource.name,
3110
+ aggregations: resource.aggregations,
3111
+ fields: resource.fields,
3112
+ schemaOptions: resource.schemaOptions,
3113
+ permissions: resource.permissions,
3114
+ routeGuards: resource.routeGuards,
3115
+ repository: repoForAgg,
3116
+ buildOptions,
3117
+ middlewares: resource.middlewares?.aggregations
3118
+ });
3119
+ }
3120
+ if (resource.events && Object.keys(resource.events).length > 0) typedInstance.log?.debug?.(`Resource '${resource.name}' defined ${Object.keys(resource.events).length} events`);
3121
+ }, { prefix: resource.prefix });
3122
+ if (hasEvents(fastify)) try {
3123
+ await fastify.events.publish("arc.resource.registered", {
3124
+ resource: resource.name,
3125
+ prefix: resource.prefix,
3126
+ presets: resource._appliedPresets,
3127
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3128
+ });
3129
+ } catch {}
3130
+ };
3131
+ }
3132
+ //#endregion
3133
+ export { toRepositoryFilter as C, nativePolicyFilter as S, pathTouchesBlocked as _, autoInjectTenantFieldRules as a, BodySanitizer as b, BaseController as c, SlugMixin as d, BulkMixin as f, isFieldReadable as g, collectReadBlockedFields as h, resolveOpenApiSchemas as i, TreeMixin as l, QueryResolver as m, normalizeActionsToRouterConfig as n, createCrudRouter as o, BaseCrudController as p, resolveActionTargetFence as r, createPermissionMiddleware as s, buildResourcePlugin as t, SoftDeleteMixin as u, detectOverriddenWriteSlots as v, AccessControl as x, isWriteVerbCapable as y };