@classytic/arc 2.41.0 → 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-n7qO5Cz3.mjs → EventTransport-tzA7CAB4.mjs} +22 -17
  3. package/dist/{HookSystem-CxvZBo77.mjs → HookSystem-B6UaKRbF.mjs} +2 -2
  4. package/dist/{QueryCache-DFA-QrU9.d.mts → QueryCache--5uAuYPW.d.mts} +7 -3
  5. package/dist/{ResourceRegistry-Cu7uTJp9.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-2YY_Kaj1.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-CUhjGHGZ.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-DsWDjIxx.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-BxLgNFF7.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-iNGqYerO.mjs → core-Cb0LjccA.mjs} +5 -4
  37. package/dist/{createActionRouter-BBlbPs3k.mjs → createActionRouter-xjqonMRO.mjs} +87 -39
  38. package/dist/{createAggregationRouter-Dv5SuK2x.mjs → createAggregationRouter-zRYWw_5D.mjs} +11 -8
  39. package/dist/{preloadResources-Bbp4QJ8V.mjs → createApp-D-953ZAq.mjs} +299 -390
  40. package/dist/{createMcpServer-CbDWkZ1T.d.mts → createMcpServer-C9cxS8zt.d.mts} +1 -1
  41. package/dist/{defineAggregation-UyeurGkp.mjs → defineAggregation-Bm8RjMbq.mjs} +1 -1
  42. package/dist/{defineResource-DzGT-2LP.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-BF-vhGxY.mjs → elevation-DG8SrLN-.mjs} +41 -12
  48. package/dist/encryption/index.mjs +3 -3
  49. package/dist/{errorHandler-CIMkCOWm.mjs → errorHandler-BnfDviL1.mjs} +26 -5
  50. package/dist/{errors-C_YhELhe.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-CmXaPWLv.mjs → eventPlugin-Q6BhzHzs.mjs} +82 -21
  54. package/dist/{subscribe-helpers-AVYPgqTl.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-BTyPLHK0.mjs → fields-ToPYpMqS.mjs} +1 -1
  66. package/dist/filter-merge-IpGywvym.mjs +99 -0
  67. package/dist/{generate-D_YmiCNf.mjs → generate-DRTkLlIl.mjs} +2 -2
  68. package/dist/{headers-CNECDE6q.mjs → headers-BbkIF6vA.mjs} +2 -2
  69. package/dist/health-D-Ft6JM6.mjs +22 -0
  70. package/dist/{health-CxQzbJmr.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-Y2MlSN9n.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-Cq9KYl2Z.mjs → jsonBody-B4YVGcql.mjs} +2 -2
  108. package/dist/{keys-Cmv4cBRa.mjs → keys-B2KtgMLJ.mjs} +7 -18
  109. package/dist/{loadResources-CblF7xt2.mjs → loadResources-Dn4lalw2.mjs} +3 -3
  110. package/dist/{loadResourcesFromEntry-99fIMC3p.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-xF3Gw-HW.mjs → mcpPlugin-C08HalN5.mjs} +433 -170
  115. package/dist/{memory-CpDZ9NPw.mjs → memory-9Cp7611C.mjs} +36 -34
  116. package/dist/{metrics-BaKnq8ag.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-BkQEesNQ.mjs → module-BSvOQC7E.mjs} +8 -8
  121. package/dist/multiTenant-cY0FkDiA.d.mts +104 -0
  122. package/dist/{multipartBody-C92aJ8am.mjs → multipartBody-CXM2a-Dy.mjs} +46 -7
  123. package/dist/{openapi-C3LFRzPq.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-dsCyLxXH.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-DiHXy8Ck.mjs → permissions-Cm0uYHvd.mjs} +149 -33
  131. package/dist/{pipe-CBVmUJ-j.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-DYJRZ0EJ.mjs → plugin-BccET1tL.mjs} +1256 -344
  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-2_U6QZQg.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-BdlugYTc.mjs → presets-Cpvj6fGO.mjs} +99 -107
  150. package/dist/{projection-Uvgp-EEj.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-B5F09bYr.mjs → queryCachePlugin-CFWNH8aT.mjs} +12 -14
  154. package/dist/{rateLimitKey-CK-EU2L8.mjs → rateLimitKey-C4O-kPm0.mjs} +1 -1
  155. package/dist/{realtime-FnnD0n3Z.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-D3y8VEJ1.mjs → registry-Bcz9G1bh.mjs} +10 -8
  162. package/dist/{renewingLease-B3VYh5aU.mjs → renewingLease-qODa7KJQ.mjs} +1 -1
  163. package/dist/{replyHelpers-DPHsdw-5.mjs → replyHelpers-CG_VtVgS.mjs} +2 -2
  164. package/dist/{requestContext-cSVKnYPk.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-DjDuyglE.mjs → rolldown-runtime-D1w9Y1VJ.mjs} +1 -1
  170. package/dist/routeValidator-D36SW8qt.mjs +70 -0
  171. package/dist/{runtimeCapabilities-DSA_dH1_.mjs → runtimeCapabilities-0tccjRlj.mjs} +1 -1
  172. package/dist/{schedules-BktJb3Y3.mjs → schedules-C-OpmHBq.mjs} +46 -37
  173. package/dist/{schemaConverter-BLCSmNbr.mjs → schemaConverter-BORek3O-.mjs} +2 -2
  174. package/dist/{schemaIR-K16Bb5BX.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-jSxuBTFM.mjs → scope-DQthe88b.mjs} +4 -4
  181. package/dist/shutdown-MqA81z_P.mjs +59 -0
  182. package/dist/{simpleEqualityMatcher-BiOnyflP.mjs → simpleEqualityMatcher-auW8QjXN.mjs} +1 -1
  183. package/dist/{sse-CH7Slxc1.mjs → sse-CYzQBVFe.mjs} +11 -15
  184. package/dist/{sseStream-CN-Lh1Tu.mjs → sseStream-B5_Afiqn.mjs} +2 -2
  185. package/dist/{store-helpers-9DX9-1j_.mjs → store-helpers-BOzk-CuX.mjs} +1 -1
  186. package/dist/{streaming-dvppnxsr.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-DCWMI6hV.mjs → transactionContext-DjFkrNG6.mjs} +1 -1
  199. package/dist/{typeGuards-D7tHFdH6.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-CDy3-zw1.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-DDMOZ_Pm.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-B-zj_b__.mjs → utils-ec1oTo1n.mjs} +315 -404
  212. package/dist/{validate-R0G_JrnK.mjs → validate-9yzHC1e2.mjs} +6 -14
  213. package/dist/{versioning-D6JIK8Tk.d.mts → versioning-Bsb51gGT.d.mts} +28 -13
  214. package/dist/{versioning-DuVWcu-F.mjs → versioning-fhhWHV74.mjs} +4 -4
  215. package/dist/{websocket-BOXVggMf.d.mts → websocket-Cs4awVyD.d.mts} +1 -1
  216. package/dist/{workScope-BhsWde4k.mjs → workScope-zpPyge4t.mjs} +2 -2
  217. package/package.json +578 -574
  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-DPwPWPOV.mjs +0 -350
  225. package/dist/index-CFfLKMH4.d.mts +0 -290
  226. package/dist/interface-BIpgzbZl.mjs +0 -17
  227. package/dist/redis-stream-BPKscsTB.d.mts +0 -260
  228. package/dist/registerRoute-DRsSHPSM.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.0 | MIT | https://classytic.com */
2
- import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-BxLgNFF7.mjs";
3
- import { arcLog } from "./logger/index.mjs";
4
- import { a as conjoinPolicyFilters } from "./authorizationDecision-2YY_Kaj1.mjs";
5
- import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError, u as ValidationError } from "./errors-C_YhELhe.mjs";
6
- import { n as getUserRoles, t as getUserId } from "./userHelpers-DDMOZ_Pm.mjs";
7
- import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-CDy3-zw1.mjs";
8
- import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-BiOnyflP.mjs";
9
- import { t as buildQueryKey } from "./keys-Cmv4cBRa.mjs";
10
- import { t as transactionContext } from "./transactionContext-DCWMI6hV.mjs";
11
- import { D as ArcQueryParser, j as scheduleBackground } from "./utils-B-zj_b__.mjs";
12
- import { a as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-BTyPLHK0.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,9 +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 scope = arcContext?._scope;
119
- const orgId = scope ? getOrgId(scope) : void 0;
120
- if (this.tenantField && orgId) 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 });
121
130
  return toRepositoryFilter(filter);
122
131
  }
123
132
  /**
@@ -178,14 +187,11 @@ var AccessControl = class {
178
187
  * unscoped records from leaking across tenants.
179
188
  */
180
189
  checkOrgScope(item, arcContext) {
181
- if (!this.tenantField) return true;
182
- if (arcContext?._crossTenantRead === true) return true;
183
- const scope = arcContext?._scope;
184
- const orgId = scope ? getOrgId(scope) : void 0;
185
- if (!item || !orgId) return true;
186
- 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];
187
193
  if (!itemOrgId) return false;
188
- return String(itemOrgId) === String(orgId);
194
+ return String(itemOrgId) === String(tenancy.orgId);
189
195
  }
190
196
  /** Check ownership for update/delete (ownedByUser preset) */
191
197
  checkOwnership(item, req) {
@@ -341,7 +347,7 @@ var BodySanitizer = class {
341
347
  constructor(config) {
342
348
  this.schemaOptions = config.schemaOptions;
343
349
  this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
344
- 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");
345
351
  }
346
352
  /**
347
353
  * Strip readonly and system-managed fields from request body.
@@ -423,21 +429,16 @@ function buildTenantRepoOptions(req, tenantField, arcContext) {
423
429
  const cached = req._tenantRepoOptions;
424
430
  if (cached) return cached;
425
431
  const out = {};
426
- const scope = arcContext?._scope;
427
- if (arcContext?._crossTenantRead === true) {
428
- out.bypassTenant = true;
429
- req._tenantRepoOptions = out;
430
- return out;
431
- }
432
- if (tenantField) {
433
- const orgId = scope ? getOrgId(scope) : void 0;
434
- if (orgId) out[tenantField] = orgId;
435
- }
436
- const presetFields = 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;
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;
439
441
  }
440
- if (tenantField && out[tenantField] == null && scope && isElevated(scope)) out.bypassTenant = true;
441
442
  const userId = getUserId(req.user);
442
443
  if (userId) out.userId = userId;
443
444
  if (req.user) out.user = req.user;
@@ -566,6 +567,14 @@ function resolveOpCacheConfig(cfg, operation) {
566
567
  * Extract user/org IDs from request for cache key scoping.
567
568
  * Only includes orgId when the resource uses tenant-scoped data (tenantField is set).
568
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.
569
578
  */
570
579
  function deriveCacheScope(req, tenantField, arcContext) {
571
580
  return {
@@ -750,6 +759,21 @@ function isFieldReadable(rule) {
750
759
  return rule.hidden !== true;
751
760
  }
752
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
+ /**
753
777
  * The set of field names blocked from read-side surfaces (used by
754
778
  * `QueryResolver.sanitizeSelectAny` and `BaseCrudController._distinct`).
755
779
  *
@@ -795,27 +819,6 @@ function noteDeniedJoins(kind) {
795
819
  notedDeniedJoins = true;
796
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)`);
797
821
  }
798
- /**
799
- * A `$lookup` reaches ANOTHER collection, and the tenant filter arc conjoins applies to the
800
- * BASE one only. The joined side carries whatever predicate the lookup itself names and
801
- * nothing more — no tenant scope, no soft-delete filter — so a resource that opens
802
- * `allowedLookups` can return rows from a collection the caller could not query directly.
803
- *
804
- * Unreachable by default (2.24 denies `lookup=` with no allowlist) and no resource in this
805
- * fleet configures one, so this is a WARNING at the moment a deployment first opts in
806
- * rather than machinery for a door nobody has opened. Warned once per collection set: the
807
- * decision is made when the allowlist is written, not per request.
808
- *
809
- * Scope the join with the lookup's own `where`, which mongokit compiles into a `$match`
810
- * inside the lookup pipeline.
811
- */
812
- const notedUnscopedJoins = /* @__PURE__ */ new Set();
813
- function noteUnscopedJoin(lookups) {
814
- const key = lookups.map((l) => l.from).filter((f) => typeof f === "string").sort().join(",");
815
- if (key.length === 0 || notedUnscopedJoins.has(key)) return;
816
- notedUnscopedJoins.add(key);
817
- 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)`);
818
- }
819
822
  var QueryResolver = class {
820
823
  queryParser;
821
824
  maxLimit;
@@ -938,6 +941,8 @@ var QueryResolver = class {
938
941
  const { _policyFilters: _ignored, ...rest } = rawQuery;
939
942
  query = rest;
940
943
  }
944
+ const keyset = readKeysetControls(query);
945
+ if (keyset.stripped) query = keyset.query;
941
946
  const parsed = this.queryParser.parse(query);
942
947
  /**
943
948
  * Client-supplied keys only, and BEFORE the policy conjunction below: trusted policy
@@ -947,14 +952,16 @@ var QueryResolver = class {
947
952
  this.assertNoBlockedReadKeys(parsed.filters, parsed.sort);
948
953
  const arcContext = meta ?? req.metadata;
949
954
  const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
950
- 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;
951
959
  const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.permittedDefaultSort();
952
960
  const rawSelect = parsed.select ?? req.query?.select;
953
961
  const policyFilters = arcContext?._policyFilters;
954
962
  let filters = conjoinPolicyFilters(parsed.filters, policyFilters);
955
- const scope = arcContext?._scope;
956
- const orgId = scope ? getOrgId(scope) : void 0;
957
- 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 });
958
965
  const portableFilters = toRepositoryFilter(filters);
959
966
  return {
960
967
  page,
@@ -963,10 +970,12 @@ var QueryResolver = class {
963
970
  select: this.sanitizeSelectAny(rawSelect, this.schemaOptions),
964
971
  populate: this.sanitizePopulate(parsed.populate, this.schemaOptions),
965
972
  populateOptions: this.sanitizePopulateOptions(parsed.populateOptions, this.schemaOptions),
966
- lookups: this.sanitizeLookups(parsed.lookups, this.schemaOptions),
973
+ lookups: this.sanitizeLookups(parsed.lookups, this.schemaOptions, arcContext?._scope),
967
974
  filters: portableFilters,
968
975
  search: parsed.search,
969
- after: parsed.after,
976
+ ...after !== void 0 ? { after } : {},
977
+ ...before !== void 0 ? { before } : {},
978
+ ...keysetMode ? { mode: "keyset" } : {},
970
979
  user: req.user,
971
980
  context: arcContext
972
981
  };
@@ -1018,11 +1027,11 @@ var QueryResolver = class {
1018
1027
  return sanitized.length > 0 ? sanitized : void 0;
1019
1028
  }
1020
1029
  /**
1021
- * Sanitize lookup/join options. Deny-by-default: `allowedLookups` must be
1022
- * configured for any client-driven lookup to pass; listed collections are
1023
- * 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.
1024
1033
  */
1025
- sanitizeLookups(lookups, schemaOptions) {
1034
+ sanitizeLookups(lookups, schemaOptions, scope) {
1026
1035
  if (!lookups || lookups.length === 0) return void 0;
1027
1036
  const allowedLookups = schemaOptions.query?.allowedLookups;
1028
1037
  if (!allowedLookups) {
@@ -1030,15 +1039,17 @@ var QueryResolver = class {
1030
1039
  return;
1031
1040
  }
1032
1041
  const validFieldName = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
1033
- const sanitized = lookups.filter((lookup) => {
1034
- if (!lookup.from || !lookup.localField || !lookup.foreignField) return false;
1035
- if (!validFieldName.test(lookup.from)) return false;
1036
- if (!validFieldName.test(lookup.localField)) return false;
1037
- if (!validFieldName.test(lookup.foreignField)) return false;
1038
- if (!allowedLookups.includes(lookup.from)) return false;
1039
- 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
+ }] : [];
1040
1052
  });
1041
- if (sanitized.length > 0) noteUnscopedJoin(sanitized);
1042
1053
  return sanitized.length > 0 ? sanitized : void 0;
1043
1054
  }
1044
1055
  /**
@@ -1060,8 +1071,7 @@ var QueryResolver = class {
1060
1071
  * whoever wrote it.
1061
1072
  */
1062
1073
  isBlockedProjection(path, blockedFields) {
1063
- const field = path.replace(/^-/, "");
1064
- return blockedFields.some((blocked) => field === blocked || field.startsWith(`${blocked}.`));
1074
+ return pathTouchesBlocked(path.replace(/^-/, ""), new Set(blockedFields));
1065
1075
  }
1066
1076
  /**
1067
1077
  * Refuse a read that FILTERS or SORTS on a `hidden` field.
@@ -1084,11 +1094,7 @@ var QueryResolver = class {
1084
1094
  const blocked = collectReadBlockedFields(this.schemaOptions);
1085
1095
  if (!blocked) return;
1086
1096
  const offenders = /* @__PURE__ */ new Set();
1087
- const isBlocked = (key) => {
1088
- if (blocked.has(key)) return true;
1089
- const dot = key.indexOf(".");
1090
- return dot > 0 && blocked.has(key.slice(0, dot));
1091
- };
1097
+ const isBlocked = (key) => pathTouchesBlocked(key, blocked);
1092
1098
  const walk = (node, depth) => {
1093
1099
  if (depth > 8 || !node || typeof node !== "object") return;
1094
1100
  if (Array.isArray(node)) {
@@ -1114,14 +1120,33 @@ var QueryResolver = class {
1114
1120
  })));
1115
1121
  }
1116
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
+ }
1117
1145
  //#endregion
1118
1146
  //#region src/core/BaseCrudController.ts
1119
1147
  /**
1120
1148
  * Framework-agnostic CRUD controller implementing IController.
1121
- *
1122
- * Composes AccessControl, BodySanitizer, and QueryResolver. All shared
1123
- * state and helpers are `protected` so the preset mixins (SoftDelete,
1124
- * Tree, Slug, Bulk) can extend cleanly.
1149
+ * Composes AccessControl, BodySanitizer, and QueryResolver.
1125
1150
  *
1126
1151
  * @template TDoc - The document type.
1127
1152
  * @template TRepository - The repository type (defaults to RepositoryLike).
@@ -1135,73 +1160,38 @@ var BaseCrudController = class {
1135
1160
  resourceName;
1136
1161
  tenantField;
1137
1162
  idField = "_id";
1138
- /**
1139
- * Composable access control (ID filtering, policy checks, org scope, ownership).
1140
- *
1141
- * Not `readonly` — `configure()` rebuilds it when the host
1142
- * supplies tenant/idField/matchesFilter post-construction. Same model as
1143
- * `queryResolver` after `setQueryParser` shipped in 2.10.9.
1144
- */
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). */
1145
1167
  accessControl;
1146
- /**
1147
- * Composable body sanitization (field permissions, system fields).
1148
- *
1149
- * Not `readonly` — `configure()` rebuilds it when the host
1150
- * supplies schemaOptions/onFieldWriteDenied post-construction.
1151
- */
1168
+ /** Body sanitization (field permissions, system fields). */
1152
1169
  bodySanitizer;
1153
- /**
1154
- * Composable query resolution (parsing, pagination, sort, select/populate).
1155
- *
1156
- * Not `readonly` — `setQueryParser()` rebuilds this resolver to swap in a
1157
- * different parser (e.g. mongokit's `QueryParser`). `defineResource` calls
1158
- * it automatically when a resource supplies both `controller` and
1159
- * `queryParser`.
1160
- */
1170
+ /** Query resolution (parsing, pagination, sort, select/populate). */
1161
1171
  queryResolver;
1162
1172
  _matchesFilter;
1163
1173
  _presetFields = {};
1164
1174
  _cacheConfig;
1165
1175
  /**
1166
- * Retained construction-time values so partial `configure()` rebuilds
1167
- * (e.g. `configure({ schemaOptions })`) reuse them instead of silently
1168
- * falling back to the sub-component defaults (`-createdAt` sort,
1169
- * `reject` write-denial) — `defaultSort: false` matters for kits
1170
- * 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).
1171
1178
  */
1172
1179
  _defaultSort;
1173
1180
  _onFieldWriteDenied;
1174
1181
  _onImmutableWrite;
1175
1182
  /**
1176
- * Domain commands bound to the write slots (`ResourceWrites`). Consulted at
1177
- * the PERSISTENCE step only — everything upstream of it (sanitizer, tenant
1178
- * injection, actor stamp, hook sandwich) runs exactly as it does without
1179
- * 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.
1180
1185
  */
1181
1186
  _writes;
1182
1187
  /** Transactional write envelope — see `ResourceConfig.transactional`. */
1183
1188
  _transactional = false;
1189
+ /** Version field under optimistic concurrency — `null` when the resource did not opt in. */
1190
+ _versionField = null;
1184
1191
  constructor(repository, options = {}) {
1185
1192
  this.repository = repository;
1186
1193
  this.schemaOptions = options.schemaOptions ?? {};
1187
1194
  this.queryParser = options.queryParser ?? getDefaultQueryParser();
1188
- /**
1189
- * The parser's cap is consulted BEFORE the framework default.
1190
- *
1191
- * A resource that hands us a parser has already stated how large a page it
1192
- * can serve — a bounded catalog like a chart of accounts declares 1000 so
1193
- * pickers can read it whole. Defaulting straight to 100 here silently
1194
- * overrode that: the resource asked for 1000, `QueryResolver` received a
1195
- * `maxLimit` of 100 that was indistinguishable from a host deliberately
1196
- * choosing 100, and the list came back truncated with a `200`. be-prod
1197
- * served 100 of 696 accounts that way, and every account picker in the
1198
- * product was short by 85% with nothing anywhere to report it.
1199
- *
1200
- * Order matters and is the whole point: an explicit `options.maxLimit` from
1201
- * the host still wins over the parser, and the parser wins over this
1202
- * framework floor. A general default must only ever fill a gap — never
1203
- * outrank something specific that was actually stated.
1204
- */
1205
1195
  this.maxLimit = options.maxLimit ?? this.queryParser?.maxLimit ?? 100;
1206
1196
  this.defaultLimit = options.defaultLimit ?? 20;
1207
1197
  this.resourceName = options.resourceName;
@@ -1215,11 +1205,14 @@ var BaseCrudController = class {
1215
1205
  this._onImmutableWrite = options.onImmutableWrite;
1216
1206
  this._writes = options.writes;
1217
1207
  this._transactional = options.transactional === true;
1208
+ this._versionField = options.concurrency ? options.concurrency.versionField ?? "__v" : null;
1218
1209
  this.accessControl = new AccessControl({
1219
1210
  tenantField: this.tenantField,
1220
1211
  idField: this.idField,
1221
1212
  matchesFilter: this._matchesFilter
1222
1213
  });
1214
+ this._lookupFields = options.lookupFields;
1215
+ this.lookupAccess = this.buildLookupAccess(options.lookupFields);
1223
1216
  this.bodySanitizer = new BodySanitizer({
1224
1217
  schemaOptions: this.schemaOptions,
1225
1218
  onFieldWriteDenied: this._onFieldWriteDenied,
@@ -1235,63 +1228,39 @@ var BaseCrudController = class {
1235
1228
  });
1236
1229
  this.list = this.list.bind(this);
1237
1230
  this.get = this.get.bind(this);
1238
- /**
1239
- * The write methods are bound here, which means EVERY instance carries an
1240
- * own `create`/`update`/`delete` property — so "does it have an own
1241
- * property" cannot distinguish arc's binding from a host's override.
1242
- *
1243
- * Marking what arc installed makes the difference observable. A class-field
1244
- * override (`update = async (req) => …`) initialises AFTER `super()`
1245
- * returns and therefore REPLACES this bound method outright — the mark goes
1246
- * with it, which is exactly the signal `warnOnWriteMethodOverride` needs.
1247
- * A prototype-method override binds through here and stays marked; that
1248
- * shape is caught by the prototype comparison instead.
1249
- */
1250
1231
  this.create = markArcBoundWrite(this.create.bind(this));
1251
1232
  this.update = markArcBoundWrite(this.update.bind(this));
1252
1233
  this.delete = markArcBoundWrite(this.delete.bind(this));
1253
1234
  }
1254
1235
  /**
1255
- * Swap the controller's query parser. Mutates the existing `QueryResolver`
1256
- * in place via `QueryResolver.setParser()` — the resolver instance stays
1257
- * referentially stable, and there is no second copy of `defaultSort` /
1258
- * `tenantField` / `schemaOptions` for the swap to drift away from.
1259
- *
1260
- * Closes the v2.10.9 gap where `defineResource({ controller, queryParser })`
1261
- * forwarded the parser only to auto-constructed controllers. `defineResource`
1262
- * calls this via duck-typing when both `controller` and `queryParser` are
1263
- * 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`.
1264
1251
  */
1265
1252
  setQueryParser(queryParser) {
1266
1253
  this.queryParser = queryParser;
1267
1254
  this.queryResolver.setParser(queryParser);
1268
1255
  }
1269
1256
  /**
1270
- * Apply resource-level options to a custom controller AFTER construction.
1271
- *
1272
- * Closes the pre-2.15 footgun where `defineResource({ controller, tenantField,
1273
- * schemaOptions, ... })` warned that the options were "dropped" because the
1274
- * user-supplied controller never received them. Hosts had to remember to
1275
- * forward each one through `super(repo, { ... })` — easy to miss, silently
1276
- * mis-scopes queries when missed.
1277
- *
1278
- * `defineResource()` now calls `controller.configure(resolvedOpts)` after
1279
- * `resolveOrAutoCreateController()` runs. Configure-aware controllers receive
1280
- * the resolved values; arc skips the dropped-options warn for them.
1281
- *
1282
- * Only the keys that affect cross-cutting state (tenant scope, schema/field
1283
- * rules, sort/limit policy, cache, write-denial policy) are honoured —
1284
- * `repository` / `resourceName` are constructor-only because they participate
1285
- * in mixin composition. Each known key rebuilds the affected sub-component
1286
- * (AccessControl / BodySanitizer / QueryResolver) so referentially-stable
1287
- * consumers don't see stale state.
1288
- *
1289
- * Idempotent: safe to call zero, one, or many times before first request;
1290
- * 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, { … })`.
1291
1261
  *
1292
- * Type narrowed to `ControllerConfigurableOptions` — `resourceName` is
1293
- * construction-only and intentionally excluded so accidental "rename
1294
- * 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.
1295
1264
  */
1296
1265
  configure(options) {
1297
1266
  let rebuildAccessControl = false;
@@ -1340,11 +1309,19 @@ var BaseCrudController = class {
1340
1309
  if (options.presetFields !== void 0) this._presetFields = options.presetFields;
1341
1310
  if (options.writes !== void 0) this._writes = options.writes;
1342
1311
  if (options.transactional !== void 0) this._transactional = options.transactional === true;
1343
- if (rebuildAccessControl) this.accessControl = new AccessControl({
1344
- tenantField: this.tenantField,
1345
- idField: this.idField,
1346
- matchesFilter: this._matchesFilter
1347
- });
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
+ }
1348
1325
  if (rebuildBodySanitizer) this.bodySanitizer = new BodySanitizer({
1349
1326
  schemaOptions: this.schemaOptions,
1350
1327
  onFieldWriteDenied: this._onFieldWriteDenied,
@@ -1359,25 +1336,15 @@ var BaseCrudController = class {
1359
1336
  tenantField: this.tenantField
1360
1337
  });
1361
1338
  }
1362
- /**
1363
- * Get the tenant field name if multi-tenant scoping is enabled.
1364
- * Returns `undefined` when `tenantField` is `false`.
1365
- */
1339
+ /** Tenant field name, or `undefined` when `tenantField` is `false`. */
1366
1340
  getTenantField() {
1367
1341
  return this.tenantField || void 0;
1368
1342
  }
1369
1343
  /**
1370
- * Build the canonical repo-options bag (tenant scope, audit
1371
- * attribution, trace correlation) from the Fastify request. Full
1372
- * contract + rationale on `buildTenantRepoOptions` in
1373
- * `crud/requestPipeline.ts` — this delegate exists so host overrides
1374
- * and duck-typed consumers (aggregation router, MCP bridge) keep the
1375
- * same `protected` entry point.
1376
- *
1377
- * Method kept named `tenantRepoOptions` for back-compat with hosts
1378
- * that spread `...this.tenantRepoOptions(req)` (10+ call sites in
1379
- * arc, plus host overrides). The bag has always grown over time —
1380
- * 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).
1381
1348
  */
1382
1349
  tenantRepoOptions(req) {
1383
1350
  return buildTenantRepoOptions(req, this.tenantField, this.meta(req));
@@ -1400,18 +1367,11 @@ var BaseCrudController = class {
1400
1367
  return resolveMutationRepoId(id, existing, this.idField, this.repository);
1401
1368
  }
1402
1369
  /**
1403
- * 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.
1404
1373
  *
1405
- * Bundles the four steps that every mutation must do before touching the
1406
- * repo: (1) extract `:id`, (2) fetch under access control + tenant scope,
1407
- * (3) verify ownership, (4) translate the route id to the repo's primary
1408
- * key. Returning `{id, existing, repoId}` keeps the call sites a single
1409
- * line and makes drift between `update` and `delete` structurally
1410
- * impossible — there is one preflight, one denial-reason mapping, one
1411
- * ownership check.
1412
- *
1413
- * Pass `extraFetchOptions` for callers (e.g. soft-delete restore) that
1414
- * need to widen the fetch (`{ includeDeleted: true }`).
1374
+ * `extraFetchOptions` widens the fetch (e.g. restore's `{ includeDeleted: true }`).
1415
1375
  */
1416
1376
  async loadMutableTarget(req, extraFetchOptions) {
1417
1377
  const id = this.requireIdParam(req);
@@ -1430,10 +1390,83 @@ var BaseCrudController = class {
1430
1390
  };
1431
1391
  }
1432
1392
  /**
1433
- * Centralized 404 thrower. Maps the denial reason from `fetchDetailed()`
1434
- * into a `NotFoundError` so consumers can distinguish "doc doesn't
1435
- * exist" from "doc filtered by policy/org scope" via the error
1436
- * `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".
1437
1470
  */
1438
1471
  throwNotFound(reason = "NOT_FOUND") {
1439
1472
  throw buildNotFoundError(reason, this.resourceName);
@@ -1443,9 +1476,8 @@ var BaseCrudController = class {
1443
1476
  return resolveOpCacheConfig(this._cacheConfig, operation);
1444
1477
  }
1445
1478
  /**
1446
- * Extract user/org IDs from request for cache key scoping.
1447
- * Only includes orgId when the resource uses tenant-scoped data (tenantField is set).
1448
- * 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.
1449
1481
  */
1450
1482
  cacheScope(req) {
1451
1483
  return deriveCacheScope(req, this.tenantField, this.meta(req));
@@ -1460,11 +1492,7 @@ var BaseCrudController = class {
1460
1492
  if (!id) throw createError(400, "ID parameter is required");
1461
1493
  return id;
1462
1494
  }
1463
- /**
1464
- * Normalizes `repo.exists()` return shapes across adapters. Per
1465
- * StandardRepo's contract, `exists` may return `boolean`, `{ _id }`,
1466
- * or `null` — every truthy non-null shape collapses to `true`.
1467
- */
1495
+ /** Normalizes `repo.exists()` (`boolean | { _id } | null`) to a boolean. */
1468
1496
  isExistsTruthy(result) {
1469
1497
  return isExistsResultTruthy(result);
1470
1498
  }
@@ -1478,25 +1506,18 @@ var BaseCrudController = class {
1478
1506
  };
1479
1507
  }
1480
1508
  /**
1481
- * Run `executeBefore` then `executeAround` (or just the executor if no
1482
- * hooks are wired). Returns the around-phase result directly. Throws an
1483
- * `ArcError` (status 400, code `BEFORE_<OP>_HOOK_ERROR`) when the
1484
- * before-hook fails. Full contract on `executeHookedOp` in
1485
- * `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`.
1486
1511
  */
1487
1512
  async runHookedOpUntilResult(req, args, executor) {
1488
1513
  return executeHookedOp(this.hookOpContext(req), args, executor);
1489
1514
  }
1490
1515
  /**
1491
1516
  * Run a write's PERSISTENCE step, transactionally when the resource asked.
1492
- *
1493
- * Plain mode hands `fn` the live repository. Transactional mode runs it
1494
- * through repo-core's `retryingTransaction`: the callback receives the
1495
- * TX-BOUND repository (so declared verbs and repository calls join the same
1496
- * transaction), transient conflicts re-run `fn` with jittered bounded
1497
- * backoff, everything else — including `VersionConflictError` — surfaces
1498
- * once. Hooks stay OUTSIDE: before/around ran already and must not re-run;
1499
- * 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.
1500
1521
  */
1501
1522
  runWritePersistence(fn) {
1502
1523
  if (!this._transactional) return fn(this.repository);
@@ -1516,16 +1537,9 @@ var BaseCrudController = class {
1516
1537
  /**
1517
1538
  * Assemble the `MutationWriteContext` an `update` / `delete` verb receives.
1518
1539
  *
1519
- * `existing` is the document arc ALREADY loaded to run its permission and
1520
- * tenant checks — `loadMutableTarget` throws before any verb runs when it
1521
- * cannot produce one, which is why the context types it required. Handing
1522
- * it over rather than letting the verb re-fetch is deliberate: a second
1523
- * read can observe a different document than the one the request was
1524
- * authorised against.
1525
- *
1526
- * `id` is the REPOSITORY PRIMARY KEY (`repoId`), never the raw route param —
1527
- * see the note on `MutationWriteContext.id`. Callers pass `resolveRepoId`'s
1528
- * 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.
1529
1543
  */
1530
1544
  mutationWriteContext(req, id, existing, repo = this.repository, uow) {
1531
1545
  return {
@@ -1537,24 +1551,16 @@ var BaseCrudController = class {
1537
1551
  };
1538
1552
  }
1539
1553
  /**
1540
- * Run `executeAfter` for the given op + data. No-op when hooks aren't
1541
- * wired or `resourceName` isn't set. Caller passes the data shape it
1542
- * wants downstream after-handlers to receive — typically the result for
1543
- * 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.
1544
1556
  */
1545
1557
  async runAfterHook(req, op, data, meta) {
1546
1558
  return executeAfterHook(this.hookOpContext(req), op, data, meta);
1547
1559
  }
1548
1560
  /**
1549
- * Per-instance single-flight for cache fills — concurrent requests for
1550
- * the SAME cache key coalesce onto one repository query instead of each
1551
- * issuing its own (a popular entry expiring under 1,000 concurrent
1552
- * readers must produce 1 DB query, not 1,000). Keys already encode the
1553
- * resolved query + user/org scope + resource version, so coalescing can
1554
- * never cross tenants, scopes, or invalidation generations. Per-process
1555
- * by design; distributed refresh leases are a store-level concern
1556
- * (planned with the repo-core CacheEngine façade, which ships
1557
- * 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.
1558
1564
  */
1559
1565
  inFlightCacheFills = /* @__PURE__ */ new Map();
1560
1566
  cacheSingleFlight(key, fill) {
@@ -1567,10 +1573,8 @@ var BaseCrudController = class {
1567
1573
  return flight;
1568
1574
  }
1569
1575
  /**
1570
- * Register a background SWR refresh for `key` unless one is already in
1571
- * flight. Registration is SYNCHRONOUS (before the deferred tick) so a
1572
- * burst of stale readers arriving in the same tick schedules exactly one
1573
- * 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.
1574
1578
  */
1575
1579
  scheduleCacheRefresh(key, refresh) {
1576
1580
  if (this.inFlightCacheFills.has(key)) return;
@@ -1652,19 +1656,14 @@ var BaseCrudController = class {
1652
1656
  };
1653
1657
  }
1654
1658
  /**
1655
- * Resource-dispatch verbs router. Returns `null` when the request is
1656
- * a regular list query, otherwise returns the dispatch promise.
1657
- *
1658
- * Verbs (mutually exclusive — first match wins):
1659
+ * Resource-dispatch verbs router; `null` for a regular list query.
1660
+ * First match wins:
1659
1661
  * - `?_count=true` → `{ count: number }` via `repo.count()`
1660
1662
  * - `?_distinct=field` → `unknown[]` via `repo.distinct(field)`
1661
1663
  * - `?_exists=true` → `{ exists: boolean }` via `repo.exists()`
1662
1664
  *
1663
- * All verbs share the resolved filter (parsed query + policy filters
1664
- * + tenant scope). Adapters that don't ship the underlying repo
1665
- * method get a `501` so failures surface loudly instead of falling
1666
- * back to a full table scan. Matching + repo invocation live in
1667
- * `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.
1668
1667
  */
1669
1668
  dispatchResourceVerb(req) {
1670
1669
  const verb = matchResourceVerb(req.query);
@@ -1704,16 +1703,12 @@ var BaseCrudController = class {
1704
1703
  };
1705
1704
  }
1706
1705
  /**
1707
- * True when `field` is safe to expose via `_distinct`.
1708
- *
1709
- * Read-side gate only — only `hidden: true` blocks. `systemManaged`
1710
- * is a *write* rule (clients can't PATCH the value); the field is
1711
- * still in every list response, so blocking `_distinct` adds nothing
1712
- * but inconvenience. See `core/fieldRulePredicates.ts` for the
1713
- * 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.
1714
1708
  */
1715
1709
  isFieldExposedForRead(field) {
1716
- return isFieldReadable(this.schemaOptions.fieldRules?.[field]);
1710
+ const blocked = collectReadBlockedFields(this.schemaOptions);
1711
+ return !blocked || !pathTouchesBlocked(field, blocked, { descendants: true });
1717
1712
  }
1718
1713
  /** Execute list query through hooks (extracted for cache revalidation) */
1719
1714
  async executeListQuery(options, req) {
@@ -1735,21 +1730,30 @@ var BaseCrudController = class {
1735
1730
  ...this.tenantRepoOptions(req)
1736
1731
  };
1737
1732
  const cached = await this.withGetCache(req, id, options);
1738
- 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
+ }
1739
1743
  const { doc, reason } = await this.executeGetQuery(id, options, req);
1740
1744
  if (!doc) this.throwNotFound(reason);
1741
1745
  await this.runAfterHook(req, "read", doc);
1746
+ const etag = this.etagHeaders(doc);
1742
1747
  return {
1743
1748
  data: doc,
1744
- status: 200
1749
+ status: 200,
1750
+ ...etag ? { headers: etag } : {}
1745
1751
  };
1746
1752
  }
1747
1753
  /** Execute get query through hooks (extracted for cache revalidation) */
1748
1754
  async executeGetQuery(id, options, req) {
1749
1755
  const hooks = this.getHooks(req);
1750
- const fetchItem = async () => {
1751
- return await this.accessControl.fetchDetailed(id, req, this.repository, options);
1752
- };
1756
+ const fetchItem = async () => this.fetchByAnyKey(id, req, options);
1753
1757
  if (hooks && this.resourceName) {
1754
1758
  const result = await fetchItem();
1755
1759
  if (!result.doc) return result;
@@ -1766,6 +1770,7 @@ var BaseCrudController = class {
1766
1770
  async create(req) {
1767
1771
  const arcContext = this.meta(req);
1768
1772
  const data = this.bodySanitizer.sanitize(req.body ?? {}, "create", req, arcContext);
1773
+ Object.assign(data, arcContext?._tenantFields);
1769
1774
  const scope = arcContext?._scope;
1770
1775
  const createOrgId = scope ? getOrgId(scope) : void 0;
1771
1776
  if (this.tenantField && createOrgId) data[this.tenantField] = createOrgId;
@@ -1780,16 +1785,7 @@ var BaseCrudController = class {
1780
1785
  context: arcContext,
1781
1786
  ...this.tenantRepoOptions(req)
1782
1787
  })));
1783
- /**
1784
- * A create COMMAND must return the created document — enforced, because
1785
- * the alternative is quietly worse than any failure: a `201` carrying
1786
- * `undefined` data, after-hooks (audit, events, cache invalidation) fed a
1787
- * non-document, and a client that believes the write happened with no way
1788
- * to address what it created. TypeScript types the contract, but arc is a
1789
- * JavaScript runtime framework and a JS command returning nothing is one
1790
- * missing `return` away.
1791
- */
1792
- 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 });
1793
1789
  await this.runAfterHook(req, "create", item);
1794
1790
  return {
1795
1791
  data: item,
@@ -1800,6 +1796,7 @@ var BaseCrudController = class {
1800
1796
  async update(req) {
1801
1797
  const arcContext = this.meta(req);
1802
1798
  const data = this.bodySanitizer.sanitize(req.body ?? {}, "update", req, arcContext);
1799
+ Object.assign(data, arcContext?._tenantFields);
1803
1800
  const user = req.user;
1804
1801
  const userId = getUserId(user);
1805
1802
  if (userId) data.updatedBy = userId;
@@ -1808,6 +1805,7 @@ var BaseCrudController = class {
1808
1805
  id,
1809
1806
  existing
1810
1807
  };
1808
+ const cas = this._writes?.update ? { fromClient: false } : this.expectedVersion(req, existing);
1811
1809
  const item = await this.runHookedOpUntilResult(req, {
1812
1810
  op: "update",
1813
1811
  input: data,
@@ -1815,15 +1813,12 @@ var BaseCrudController = class {
1815
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, {
1816
1814
  user,
1817
1815
  context: arcContext,
1818
- ...this.tenantRepoOptions(req)
1819
- })));
1820
- /**
1821
- * A declared verb signals "not found" by THROWING, so reaching this line
1822
- * means the command succeeded. Translating its `void` return into a 404
1823
- * would report failure for a write that happened — the repository's
1824
- * `null`-means-miss contract is the repository's, not a domain command's.
1825
- * Re-read so the response still carries the document.
1826
- */
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
+ });
1827
1822
  const resolved = this._writes?.update ? item ?? await this.repository.getById(repoId, {
1828
1823
  user,
1829
1824
  context: arcContext,
@@ -1831,10 +1826,12 @@ var BaseCrudController = class {
1831
1826
  }) : item;
1832
1827
  if (!resolved) this.throwNotFound("NOT_FOUND");
1833
1828
  await this.runAfterHook(req, "update", resolved, hookMeta);
1829
+ const etag = this.etagHeaders(resolved);
1834
1830
  return {
1835
1831
  data: resolved,
1836
1832
  status: 200,
1837
- meta: { message: "Updated successfully" }
1833
+ meta: { message: "Updated successfully" },
1834
+ ...etag ? { headers: etag } : {}
1838
1835
  };
1839
1836
  }
1840
1837
  async delete(req) {
@@ -1868,12 +1865,9 @@ var BaseCrudController = class {
1868
1865
  }
1869
1866
  };
1870
1867
  /**
1871
- * This class's write methods dispatch declared write verbs (`_writes`), so
1872
- * `defineResource` may accept a `writes` declaration for controllers built on
1873
- * it. One mark on the prototype — inherited by every subclass and mixin
1874
- * composition, zero per-instance cost. A duck-typed `configure()` is NOT this
1875
- * proof: it shows an options channel exists, not that anything dispatches
1876
- * `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.
1877
1871
  */
1878
1872
  markWriteVerbCapable(BaseCrudController.prototype);
1879
1873
  //#endregion
@@ -1905,22 +1899,18 @@ function BulkMixin(Base) {
1905
1899
  const items = rawItems;
1906
1900
  const arcContext = this.meta(req);
1907
1901
  const user = req.user;
1908
- 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
+ }));
1909
1906
  let scopedItems = sanitizedItems;
1910
- if (this.tenantField) {
1911
- const scope = arcContext?._scope;
1912
- if (scope) {
1913
- if (scope.kind === "public") throw createError(403, "Organization context required to bulk-create resources", { code: "ORG_CONTEXT_REQUIRED" });
1914
- if (!isElevated(scope)) {
1915
- const orgId = getOrgId(scope);
1916
- if (!orgId) throw createError(403, "Organization context required to bulk-create resources", { code: "ORG_CONTEXT_REQUIRED" });
1917
- const tenantField = this.tenantField;
1918
- scopedItems = sanitizedItems.map((item) => ({
1919
- ...item,
1920
- [tenantField]: orgId
1921
- }));
1922
- }
1923
- }
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" });
1924
1914
  }
1925
1915
  const created = await repo.createMany(scopedItems, {
1926
1916
  user,
@@ -1947,28 +1937,26 @@ function BulkMixin(Base) {
1947
1937
  /**
1948
1938
  * Build a tenant-scoped filter for bulk update/delete.
1949
1939
  *
1950
- * Mirrors `AccessControl.buildIdFilter` semantics:
1940
+ * Same tenancy as `AccessControl.buildIdFilter` — both go through
1941
+ * `resolveTenancy`:
1951
1942
  * - Always merge `_policyFilters` (from permission middleware)
1952
- * - `member` scope on a tenant resource → add org filter
1953
- * - `elevated` scope → no org filter (admin cross-org operation)
1954
- * - `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
1955
1947
  * - No scope at all (unit tests) → leave filter unchanged
1956
1948
  *
1957
1949
  * Returns the merged filter, or `null` when access must be denied.
1958
1950
  */
1959
1951
  buildBulkFilter(userFilter, req) {
1960
1952
  const arcContext = this.meta(req);
1961
- let filter = conjoinPolicyFilters(userFilter, arcContext?._policyFilters);
1962
- if (this.tenantField) {
1963
- const scope = arcContext?._scope;
1964
- if (!scope) return filter;
1965
- if (scope.kind === "public") return null;
1966
- if (isElevated(scope)) return filter;
1967
- const orgId = getOrgId(scope);
1968
- if (!orgId) return null;
1969
- 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;
1970
1959
  }
1971
- return filter;
1972
1960
  }
1973
1961
  /**
1974
1962
  * Sanitize a bulk update data payload through the same write-permission
@@ -2212,10 +2200,934 @@ function TreeMixin(Base) {
2212
2200
  //#region src/core/BaseController.ts
2213
2201
  /**
2214
2202
  * Fully-composed controller: `BaseCrudController` + SoftDelete + Tree +
2215
- * Slug + Bulk. Drop-in replacement for the pre-2.11 god class. The
2216
- * companion interface above gives every method full generic precision
2217
- * on `TDoc` via declaration merging.
2203
+ * Slug + Bulk. The companion interface above types every method over `TDoc`.
2218
2204
  */
2219
2205
  var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
2220
2206
  //#endregion
2221
- 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 };