@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,339 +1,12 @@
1
- /*! @classytic/arc v2.41.0 | MIT | https://classytic.com */
2
- import { p as MUTATION_OPERATIONS, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-BxLgNFF7.mjs";
3
- import { arcLog } from "./logger/index.mjs";
4
- import { r as ForbiddenError } from "./errors-C_YhELhe.mjs";
5
- import { n as getUserRoles } from "./userHelpers-DDMOZ_Pm.mjs";
6
- import { b as isElevated, n as PUBLIC_SCOPE, x as isMember } from "./types-CDy3-zw1.mjs";
7
- import { d as isWriteVerbCapable, p as DEFAULT_FIELD_WRITE_DENIAL_POLICY, t as BaseController, u as detectOverriddenWriteSlots } from "./BaseController-DYJRZ0EJ.mjs";
8
- import { y as getDefaultCrudSchemas } from "./utils-B-zj_b__.mjs";
9
- import { a as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-BTyPLHK0.mjs";
10
- import { n as convertRouteSchema, r as ensureSchemaConverter, t as convertOpenApiSchemas } from "./schemaConverter-BLCSmNbr.mjs";
11
- import { i as pipeUIMessageStreamToReply, r as isReadableStream } from "./streaming-dvppnxsr.mjs";
12
- import { a as createCrudHandlers, i as resolvePipelineSteps, o as createFastifyHandler, r as buildPipelineHandler, s as createRequestContext, t as resolveActionPermission } from "./actionPermissions-DPwPWPOV.mjs";
13
- import { a as selectPluginMw, c as buildRouteHooks, d as buildAuthMiddleware, i as resolveRouterPluginMw, l as resolveRoutePreHandlers, n as buildRateLimitConfig, p as buildArcDecorator, r as buildRouteConfig, s as buildCrudPermissionMw, t as tryRegisterRoute, u as routeHookOptions } from "./registerRoute-DRsSHPSM.mjs";
14
- import { t as hasEvents } from "./typeGuards-D7tHFdH6.mjs";
15
- import { B as requireAuth } from "./permissions-DiHXy8Ck.mjs";
16
- import { r as getAvailablePresets, t as applyPresets } from "./presets-BdlugYTc.mjs";
17
- import { t as pluralize } from "./pluralize-2_U6QZQg.mjs";
1
+ /*! @classytic/arc v2.43.0 | MIT | https://classytic.com */
2
+ import { m as MUTATION_OPERATIONS, t as CRUD_OPERATIONS } from "./constants-Cu7Mx7_U.mjs";
3
+ import { t as arcLog } from "./logger-DXjfIqjV.mjs";
4
+ import { a as autoInjectTenantFieldRules, c as BaseController, i as resolveOpenApiSchemas, t as buildResourcePlugin, v as detectOverriddenWriteSlots, y as isWriteVerbCapable } from "./plugin-BccET1tL.mjs";
5
+ import { B as requireAuth } from "./permissions-Cm0uYHvd.mjs";
6
+ import { n as getAvailablePresets, t as applyPresets } from "./presets-Cpvj6fGO.mjs";
7
+ import { t as pluralize } from "./pluralize-CVIOcgMF.mjs";
18
8
  import { isReadOnlyRepo } from "@classytic/repo-core/repository";
19
- //#region src/core/middlewares/fieldWrite.ts
20
- /**
21
- * Build a preHandler that enforces field-write permissions on the request body.
22
- *
23
- * Auto-CRUD's create/update routes get this for free via `BodySanitizer.sanitize()`
24
- * inside `BaseController`. Custom routes (`config.routes`, presets, action endpoints)
25
- * never went through that path — so a host that declared
26
- * `fields: { role: fields.writableBy(['admin']) }` and a custom
27
- * `POST /users/promote` happily accepted `{ role: 'admin' }` from any caller.
28
- *
29
- * This preHandler closes that gap. It applies ONLY the `applyFieldWritePermissions`
30
- * step — no `SYSTEM_FIELDS` strip, no `fieldRules` strip — because custom-route
31
- * bodies don't necessarily mirror the resource's adapter shape, and we'd
32
- * generate surprising 403s by enforcing rules tied to that shape.
33
- *
34
- * Returns `null` when the resource has no field permissions configured — the
35
- * caller should then skip wiring it into the preHandler chain.
36
- */
37
- function buildFieldWritePreHandler(fieldPermissions, policy = DEFAULT_FIELD_WRITE_DENIAL_POLICY) {
38
- if (!fieldPermissions || Object.keys(fieldPermissions).length === 0) return null;
39
- return async (request, _reply) => {
40
- const body = request.body;
41
- if (!body || typeof body !== "object" || Array.isArray(body)) return;
42
- const scope = request.scope ?? PUBLIC_SCOPE;
43
- if (isElevated(scope)) return;
44
- const globalRoles = getUserRoles(request.user);
45
- const orgRoles = isMember(scope) ? scope.orgRoles : [];
46
- const effectiveRoles = resolveEffectiveRoles(globalRoles, orgRoles);
47
- const { body: filtered, deniedFields } = applyFieldWritePermissions(body, fieldPermissions, effectiveRoles);
48
- if (deniedFields.length > 0 && policy === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
49
- request.body = filtered;
50
- };
51
- }
52
- /**
53
- * True for HTTP methods that carry a request body and should run the
54
- * field-write preHandler. GET/DELETE/HEAD/OPTIONS skip enforcement.
55
- */
56
- function methodCarriesBody(method) {
57
- const m = method.toUpperCase();
58
- return m === "POST" || m === "PUT" || m === "PATCH";
59
- }
60
- //#endregion
61
- //#region src/core/createCrudRouter.ts
62
- /**
63
- * Fold a route's host-supplied `config` together with arc's derived one into the
64
- * SINGLE `config` key Fastify reads. Returns arc's object untouched when the host
65
- * supplied none, so the route options stay literally what was declared.
66
- */
67
- function mergeRouteConfig(hostConfig, arcConfig) {
68
- if (!hostConfig) return arcConfig;
69
- return { config: {
70
- ...hostConfig,
71
- ...arcConfig.config ?? {}
72
- } };
73
- }
74
- /**
75
- * Mount custom routes (from presets or user-defined `routes`) on Fastify.
76
- * `wrapHandler` is derived inline from whether `rawHandler` was set.
77
- */
78
- function createCustomRoutes(fastify, routes, controller, options) {
79
- const { tag, resourceName, arcDecorator, rateLimitConfig, pluginMw, pipeline, routeGuards, tenantScopeMw, fieldPermissions, onFieldWriteDenied, extensions } = options;
80
- for (const route of routes) {
81
- 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.`);
82
- const routeWithRefs = route;
83
- const hasHandler = route.handler !== void 0 || route.rawHandler !== void 0;
84
- const hasControllerMethod = typeof routeWithRefs.controllerMethod === "function";
85
- 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).`);
86
- 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).`);
87
- let resolvedHandler;
88
- if (hasControllerMethod) {
89
- 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.`);
90
- const referenced = routeWithRefs.controllerMethod?.(controller);
91
- 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\`.`);
92
- resolvedHandler = referenced.bind(controller);
93
- } else {
94
- if (route.rawHandler && route.handler) throw new Error(`Route ${route.method} ${route.path}: set either \`handler\` (arc pipeline) or \`rawHandler\` (Fastify-native), not both.`);
95
- resolvedHandler = route.rawHandler ?? route.handler;
96
- }
97
- const opName = route.operation ?? (typeof resolvedHandler === "string" ? resolvedHandler : `${route.method.toLowerCase()}${route.path.replace(/[/:]/g, "_")}`);
98
- const isRaw = route.rawHandler !== void 0;
99
- const wrapHandler = !isRaw;
100
- let handler;
101
- if (typeof resolvedHandler === "string") {
102
- 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.`);
103
- const method = controller[resolvedHandler];
104
- if (typeof method !== "function") throw new Error(`Handler '${resolvedHandler}' not found on controller`);
105
- const boundMethod = method.bind(controller);
106
- if (wrapHandler) {
107
- const steps = resolvePipelineSteps(pipeline, opName);
108
- handler = steps.length > 0 ? buildPipelineHandler(boundMethod, steps, opName, resourceName) : createFastifyHandler(boundMethod);
109
- } else handler = boundMethod;
110
- } else if (wrapHandler) {
111
- const steps = resolvePipelineSteps(pipeline, opName);
112
- handler = steps.length > 0 ? buildPipelineHandler(resolvedHandler, steps, opName, resourceName) : createFastifyHandler(resolvedHandler);
113
- } else handler = resolvedHandler;
114
- const routeTags = route.tags ?? (tag ? [tag] : void 0);
115
- const convertedSchema = route.schema ? convertRouteSchema(route.schema) : void 0;
116
- const schema = {
117
- ...routeTags ? { tags: routeTags } : {},
118
- ...route.summary ? { summary: route.summary } : {},
119
- ...route.description ? { description: route.description } : {},
120
- ...convertedSchema ?? {}
121
- };
122
- const customPreHandlers = resolveRoutePreHandlers(route.preHandler, fastify, `${route.method} ${route.path}`);
123
- if (route.tenantScope === true) {
124
- if (!tenantScopeMw || tenantScopeMw.length === 0) throw new Error(`Route ${route.method} ${route.path}: \`tenantScope: true\` requires a multi-tenant preset. Add \`multiTenantPreset()\` (or \`flexibleMultiTenantPreset()\`) to the resource's \`presets\`, or remove the \`tenantScope\` flag from this route.`);
125
- customPreHandlers.unshift(...tenantScopeMw);
126
- }
127
- if (!isRaw && route.fieldWrite !== false && methodCarriesBody(route.method)) {
128
- const fieldWriteMw = buildFieldWritePreHandler(fieldPermissions, onFieldWriteDenied);
129
- if (fieldWriteMw) customPreHandlers.push(fieldWriteMw);
130
- }
131
- const preAuthHandlers = route.preAuth ?? [];
132
- const hooks = buildRouteHooks({
133
- preAuth: preAuthHandlers,
134
- arcDecorator,
135
- authMw: buildAuthMiddleware(fastify, route.permissions),
136
- permissionMw: buildCrudPermissionMw(route.permissions, resourceName, opName),
137
- pluginMw: selectPluginMw(route.method, pluginMw),
138
- routeGuards,
139
- customMws: customPreHandlers
140
- });
141
- const isStream = route.streamResponse === true;
142
- tryRegisterRoute(fastify, {
143
- method: route.method,
144
- url: route.path,
145
- schema,
146
- ...routeHookOptions(hooks),
147
- handler: isStream ? async function streamRoute(request, reply) {
148
- reply.raw.setHeader("Content-Type", "text/event-stream");
149
- reply.raw.setHeader("Cache-Control", "no-cache");
150
- reply.raw.setHeader("Connection", "keep-alive");
151
- const result = await handler.call(this, request, reply);
152
- if (isReadableStream(result)) {
153
- await pipeUIMessageStreamToReply(reply, result);
154
- return reply;
155
- }
156
- return result;
157
- } : handler,
158
- /**
159
- * ONE `config` object, merging the host's `route.config` with arc's own
160
- * derived keys (per-route rate limit, cors, `arcExtensions`).
161
- *
162
- * It has to be merged HERE because Fastify does not merge for us: it
163
- * builds the context from a single `opts.config`
164
- * (`{ ...opts.config, url, method }` — lib/route.js), so two spreads
165
- * writing `config` means the later one silently replaces the earlier.
166
- * Emitting them separately dropped `route.config` on every resource
167
- * that declared extensions, a rate limit or per-route cors — which is
168
- * the same silent-absence bug `config` was added to fix, just narrowed
169
- * to the resources most likely to have it.
170
- *
171
- * Arc's derived keys win on collision: they come from the declared
172
- * `route.rateLimit` / `route.cors` / `extensions` fields, so letting a
173
- * hand-written `config.rateLimit` override them would give one setting
174
- * two sources that can disagree.
175
- */
176
- ...mergeRouteConfig(route.config, buildRouteConfig(route.rateLimit !== void 0 ? buildRateLimitConfig(route.rateLimit) : rateLimitConfig, extensions, route.cors))
177
- }, {
178
- resourceName,
179
- op: opName
180
- });
181
- }
182
- }
183
- /**
184
- * Create CRUD routes for a controller.
185
- *
186
- * @param fastify - Fastify instance with Arc decorators
187
- * @param controller - CRUD controller with handler methods (optional when
188
- * `disableDefaultRoutes: true` and only custom `routes`
189
- * are being registered)
190
- * @param options - Router configuration
191
- */
192
- function createCrudRouter(fastify, controller, options = {}) {
193
- 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 } = options;
194
- const rateLimitConfig = buildRateLimitConfig(rateLimit);
195
- const resourceHasQueryCache = fastify.hasDecorator("queryCache") && controller && typeof controller._cacheConfig !== "undefined" && controller._cacheConfig !== void 0;
196
- const pluginMw = resolveRouterPluginMw(fastify, Boolean(resourceHasQueryCache));
197
- const arcDecorator = buildArcDecorator({
198
- resourceName,
199
- schemaOptions,
200
- permissions,
201
- hooks: fastify.arc?.hooks,
202
- events: fastify.events,
203
- fields: fieldPermissions,
204
- idField
205
- });
206
- const mw = {
207
- list: middlewares.list ?? [],
208
- get: middlewares.get ?? [],
209
- create: middlewares.create ?? [],
210
- update: middlewares.update ?? [],
211
- delete: middlewares.delete ?? []
212
- };
213
- const tenantScopeMw = middlewares.tenantScope;
214
- const idParamsSchema = {
215
- type: "object",
216
- properties: { id: { type: "string" } },
217
- required: ["id"]
218
- };
219
- const defaultSchemas = getDefaultCrudSchemas();
220
- /**
221
- * Merge: base (tags/summary) → defaults (response/querystring) → user overrides.
222
- * User-provided schemas always win; defaults enable fast-json-stringify when
223
- * no user schema is set.
224
- */
225
- const buildSchema = (base, defaults, userSchema) => ({
226
- ...defaults,
227
- ...base,
228
- ...userSchema ?? {}
229
- });
230
- if (!disableDefaultRoutes) {
231
- if (!controller) throw new Error("Controller is required when disableDefaultRoutes is not true. Provide a controller or use defineResource which auto-creates BaseController.");
232
- const handlers = buildCrudHandlers(controller, pipeline, resourceName);
233
- const crudTable = [
234
- {
235
- op: "list",
236
- method: "GET",
237
- url: "/",
238
- summary: `List ${tag}`,
239
- hasIdParams: false
240
- },
241
- {
242
- op: "get",
243
- method: "GET",
244
- url: "/:id",
245
- summary: `Get ${tag} by ID`,
246
- hasIdParams: true
247
- },
248
- {
249
- op: "create",
250
- method: "POST",
251
- url: "/",
252
- summary: `Create ${tag}`,
253
- hasIdParams: false
254
- },
255
- {
256
- op: "update",
257
- method: "PATCH",
258
- url: "/:id",
259
- summary: `Update ${tag}`,
260
- hasIdParams: true
261
- },
262
- {
263
- op: "delete",
264
- method: "DELETE",
265
- url: "/:id",
266
- summary: `Delete ${tag}`,
267
- hasIdParams: true
268
- }
269
- ];
270
- for (const spec of crudTable) {
271
- if (disabledRoutes.includes(spec.op)) continue;
272
- const permission = permissions[spec.op];
273
- const hooks = buildRouteHooks({
274
- arcDecorator,
275
- authMw: buildAuthMiddleware(fastify, permission),
276
- permissionMw: buildCrudPermissionMw(permission, resourceName, spec.op),
277
- pluginMw: selectPluginMw(spec.method, pluginMw),
278
- routeGuards,
279
- customMws: mw[spec.op]
280
- });
281
- const methodsToRegister = spec.op === "update" ? updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod] : [spec.method];
282
- for (const method of methodsToRegister) {
283
- const summary = spec.op === "update" ? `${method === "PUT" ? "Replace" : "Update"} ${tag}` : spec.summary;
284
- tryRegisterRoute(fastify, {
285
- method,
286
- url: spec.url,
287
- schema: buildSchema({
288
- tags: [tag],
289
- summary,
290
- ...spec.hasIdParams ? { params: idParamsSchema } : {}
291
- }, defaultSchemas[spec.op], schemas[spec.op]),
292
- ...routeHookOptions(hooks),
293
- handler: handlers[spec.op],
294
- ...buildRouteConfig(rateLimitConfig, extensions)
295
- }, {
296
- resourceName,
297
- op: spec.op
298
- });
299
- }
300
- }
301
- }
302
- if (customRoutes.length > 0) createCustomRoutes(fastify, customRoutes, controller, {
303
- tag,
304
- resourceName,
305
- arcDecorator,
306
- rateLimitConfig,
307
- pluginMw,
308
- pipeline,
309
- routeGuards,
310
- tenantScopeMw,
311
- fieldPermissions,
312
- onFieldWriteDenied,
313
- extensions
314
- });
315
- }
316
- function buildCrudHandlers(ctrl, pipeline, resourceName) {
317
- const standardHandlers = createCrudHandlers(ctrl);
318
- if (!pipeline) return standardHandlers;
319
- const wrapped = { ...standardHandlers };
320
- for (const op of CRUD_OPERATIONS) {
321
- const steps = resolvePipelineSteps(pipeline, op);
322
- if (steps.length === 0) continue;
323
- const method = ctrl[op].bind(ctrl);
324
- wrapped[op] = buildPipelineHandler(method, steps, op, resourceName);
325
- }
326
- return wrapped;
327
- }
328
- /**
329
- * Build a permission middleware from a PermissionCheck — useful when hosts
330
- * register their own routes outside the resource system but still want to
331
- * evaluate permissions through the shared applicator.
332
- */
333
- function createPermissionMiddleware(permission, resourceName, action) {
334
- return buildCrudPermissionMw(permission, resourceName, action);
335
- }
336
- //#endregion
9
+ import { readEnv } from "@classytic/primitives/environment";
337
10
  //#region src/core/defineResource/writeVerbs.ts
338
11
  /**
339
12
  * Phase 4b — write-verb registration policy.
@@ -406,34 +79,13 @@ function warnOnWriteMethodOverride(controller, resolvedConfig) {
406
79
  }
407
80
  //#endregion
408
81
  //#region src/core/defineResource/controller.ts
409
- /**
410
- * Phase 4 — pick (or auto-create) the resource's controller.
411
- *
412
- * Three branches:
413
- * 1. User-supplied controller → forward `queryParser` (duck-typed)
414
- * and warn on dropped resource-level options.
415
- * 2. No controller, has CRUD routes, has a repository → auto-build
416
- * a `BaseController` with every resource-level knob threaded
417
- * through (tenantField, schemaOptions, idField, defaultSort,
418
- * cache, onFieldWriteDenied, presetFields).
419
- * 3. Otherwise → `undefined` (custom-routes-only resource).
420
- *
421
- * The warns are load-bearing DX: silently dropping `queryParser`,
422
- * `schemaOptions`, etc. on a custom controller produces 90-minute
423
- * "why don't my filters work" debugs. Each warn names the resource,
424
- * lists the dropped options, and points at the canonical fix. All
425
- * warns honour `ARC_SUPPRESS_WARNINGS=1` via `arcLog()`.
426
- */
427
82
  /** CRUD routes refused over a read-only repository. */
428
83
  const READ_ONLY_REFUSED_ROUTES = [
429
84
  "create",
430
85
  "update",
431
86
  "delete"
432
87
  ];
433
- /**
434
- * Resolve the controller for the resource. See module docstring for
435
- * branch semantics.
436
- */
88
+ /** Resolve the controller for the resource (branches in the module docstring). */
437
89
  function resolveOrAutoCreateController(resolvedConfig, adapter, repository, hasCrudRoutes) {
438
90
  const userController = resolvedConfig.controller;
439
91
  if (resolvedConfig.transactional === true) {
@@ -445,6 +97,7 @@ function resolveOrAutoCreateController(resolvedConfig, adapter, repository, hasC
445
97
  if (enabledWrites.length > 0) throw new Error(`Resource "${resolvedConfig.name}" mounts write routes (${enabledWrites.join(", ")}) over a READ-ONLY repository — its rows are owned by another component that enforces invariants generic CRUD would bypass (Better Auth hashes passwords, revokes sessions, and cascades org membership on every write it performs). Disable the write routes (\`disabledRoutes: ['create', 'update', 'delete']\`), or if raw writes are genuinely required, build the repository with the kit's explicit \`unsafeWritable: true\` opt-in.`);
446
98
  }
447
99
  if (userController) {
100
+ bindControllerResourceName(userController, resolvedConfig.name);
448
101
  threadQueryParser(userController, resolvedConfig);
449
102
  enforceWriteVerbReachability(userController, resolvedConfig);
450
103
  threadConfigureLifecycle(userController, resolvedConfig, adapter);
@@ -457,22 +110,12 @@ function resolveOrAutoCreateController(resolvedConfig, adapter, repository, hasC
457
110
  return buildBaseController(resolvedConfig, adapter, repository);
458
111
  }
459
112
  /**
460
- * Forward resource-level options into a user-supplied controller via
461
- * the duck-typed `configure(opts)` lifecycle hook. Closes the pre-2.15
462
- * gap where `tenantField` / `schemaOptions` / `idField` / `defaultSort`
463
- * / `cache` / `onFieldWriteDenied` had no path into a host controller
464
- * unless the host remembered to forward them through `super(repo,
465
- * { ... })`. `BaseController` / `BaseCrudController` ship `configure()`;
466
- * custom controllers can opt in by adding the method.
113
+ * Forward resource-level options into a user-supplied controller via the
114
+ * duck-typed `configure(opts)` hook.
467
115
  *
468
- * **Gate by `_declaredKeys`**: only forward keys the user literally
469
- * passed at the resource level. Without this gate, arc-inferred values
470
- * (e.g. `inferTenantFieldFromAdapter` setting `tenantField: false`
471
- * because the model's organizationId path doesn't exist) would clobber
472
- * the matching value the host already set in the controller's
473
- * constructor (e.g. `new BaseController(repo, { tenantField:
474
- * "companyId" })`). Resource-level explicit values still win — the
475
- * snapshot captures what the user typed before any inference runs.
116
+ * **Gated by `_declaredKeys`**: only keys the user literally passed are
117
+ * forwarded, so arc-inferred values (e.g. `tenantField: false` from adapter
118
+ * inference) never clobber what the host set in the controller's constructor.
476
119
  */
477
120
  function threadConfigureLifecycle(controller, resolvedConfig, adapter) {
478
121
  const ctrl = controller;
@@ -482,20 +125,16 @@ function threadConfigureLifecycle(controller, resolvedConfig, adapter) {
482
125
  const opts = {};
483
126
  if (resolvedConfig.tenantField !== void 0 && wasDeclared("tenantField")) opts.tenantField = resolvedConfig.tenantField;
484
127
  if (resolvedConfig.schemaOptions !== void 0 && wasDeclared("schemaOptions")) opts.schemaOptions = resolvedConfig.schemaOptions;
128
+ if (resolvedConfig.lookupFields !== void 0 && wasDeclared("lookupFields")) opts.lookupFields = resolvedConfig.lookupFields;
485
129
  if (resolvedConfig.idField !== void 0 && wasDeclared("idField")) opts.idField = resolvedConfig.idField;
486
130
  if (resolvedConfig.defaultSort !== void 0 && wasDeclared("defaultSort")) opts.defaultSort = resolvedConfig.defaultSort;
487
131
  if (resolvedConfig.cache !== void 0 && wasDeclared("cache")) opts.cache = resolvedConfig.cache;
488
132
  if (resolvedConfig.onFieldWriteDenied !== void 0 && wasDeclared("onFieldWriteDenied")) opts.onFieldWriteDenied = resolvedConfig.onFieldWriteDenied;
489
133
  if (resolvedConfig.onImmutableWrite !== void 0 && wasDeclared("onImmutableWrite")) opts.onImmutableWrite = resolvedConfig.onImmutableWrite;
490
134
  if (resolvedConfig.queryParser !== void 0 && wasDeclared("queryParser")) opts.queryParser = resolvedConfig.queryParser;
491
- /**
492
- * NOT gated on `_declaredKeys`: `writes` is never inferred or injected by a
493
- * preset, so its presence IS the declaration. Gating it would mean a
494
- * resource that declares write verbs alongside its own controller silently
495
- * kept generic CRUD — the exact bypass the seam exists to close.
496
- */
497
135
  if (resolvedConfig.writes !== void 0) opts.writes = resolvedConfig.writes;
498
136
  if (resolvedConfig.transactional !== void 0) opts.transactional = resolvedConfig.transactional;
137
+ if (resolvedConfig.concurrency !== void 0) opts.concurrency = resolvedConfig.concurrency;
499
138
  if (adapter?.matchesFilter !== void 0) opts.matchesFilter = adapter.matchesFilter;
500
139
  if (resolvedConfig._controllerOptions) opts.presetFields = {
501
140
  slugField: resolvedConfig._controllerOptions.slugField,
@@ -505,18 +144,17 @@ function threadConfigureLifecycle(controller, resolvedConfig, adapter) {
505
144
  ctrl.configure(opts);
506
145
  }
507
146
  /**
508
- * Forward a resource-level `queryParser` into a user-supplied
509
- * controller via duck-typed `setQueryParser`. Without this the
510
- * controller's internal default would silently override the
511
- * resource's parser, drifting `[contains]` / `[like]` semantics
512
- * away from what the OpenAPI schema advertises.
513
- *
514
- * **Fail-loud (2.15.0):** older versions warned and continued —
515
- * letting the resource register with a silently-shadowed parser. The
516
- * "ship and pray they read the log" path produced 90-minute "why
517
- * doesn't `[contains]` work" debugs in production. Now throws at
518
- * registration so the misconfig surfaces immediately, with the same
519
- * fix-it message in the error.
147
+ * Bind the resource name into a supplied controller (duck-typed `bindResourceName`), so a
148
+ * subclass that never passed `resourceName` to `super()` still runs its hooks, audit and events.
149
+ */
150
+ function bindControllerResourceName(controller, name) {
151
+ const ctrl = controller;
152
+ if (typeof ctrl.bindResourceName === "function") ctrl.bindResourceName(name);
153
+ }
154
+ /**
155
+ * Forward a resource-level `queryParser` into a user-supplied controller via
156
+ * duck-typed `setQueryParser`. Throws when the controller can't accept it —
157
+ * a shadowed parser silently drifts filter semantics from the OpenAPI schema.
520
158
  */
521
159
  function threadQueryParser(controller, resolvedConfig) {
522
160
  if (!resolvedConfig.queryParser) return;
@@ -528,11 +166,8 @@ function threadQueryParser(controller, resolvedConfig) {
528
166
  throw new Error(`Resource "${resolvedConfig.name}" declares a custom \`queryParser\` but its controller does not expose \`setQueryParser(qp)\`. The parser would be silently dropped, drifting \`[contains]\` / \`[like]\` semantics away from the OpenAPI schema. Extend \`BaseController\` / \`BaseCrudController\` (both implement \`setQueryParser\`) OR add a \`setQueryParser(qp)\` method to your custom controller that actually wires the parser into the controller's query resolution path. (arc 2.15.0 hardened this to a registration-time throw — pre-2.15 it was a warn.)`);
529
167
  }
530
168
  /**
531
- * Warn when the user supplies their own controller AND declares
532
- * resource-level options arc only auto-threads on the auto-build
533
- * path. The user *can* fix this by forwarding through `super(repo,
534
- * { ... })`, so the warn names the dropped options + the canonical
535
- * fix.
169
+ * Warn when a custom controller without `configure()` can't receive
170
+ * resource-level options the user declared.
536
171
  */
537
172
  function warnOnDroppedAuthorOptions(resolvedConfig) {
538
173
  if (typeof resolvedConfig.controller?.configure === "function") return;
@@ -550,11 +185,9 @@ function warnOnDroppedAuthorOptions(resolvedConfig) {
550
185
  arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" declares a custom controller AND resource-level option(s) [${dropped.join(", ")}]. Arc only threads these when it auto-builds the controller — when you pass your own, they are dropped silently and the controller falls back to its own defaults (e.g. tenantField → 'organizationId'). Either implement \`configure(opts)\` on the controller (arc 2.15+ canonical) or forward them via \`super(repo, { ... })\`. Same root cause as the \`queryParser\` warn above.`);
551
186
  }
552
187
  /**
553
- * Warn when a preset injected `_controllerOptions` (slugLookup,
554
- * softDelete, parent presets) but the user supplied their own
555
- * controller. The user did NOT declare these — "forward them" is
556
- * bad advice. The fix is either drop the preset or extend
557
- * `BaseController` so the auto-build path runs.
188
+ * Warn when a preset injected `_controllerOptions` but the user supplied their
189
+ * own controller. The user didn't declare these, so the fix is dropping the
190
+ * preset or extending `BaseController` — not "forward them".
558
191
  */
559
192
  function warnOnDroppedPresetOptions(resolvedConfig) {
560
193
  if (resolvedConfig._controllerOptions === void 0) return;
@@ -564,10 +197,8 @@ function warnOnDroppedPresetOptions(resolvedConfig) {
564
197
  arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" applies a preset that injects controller field(s) [${presetFields.join(", ") || "preset metadata"}] (e.g. slugLookup / softDelete / parent), but the resource also declares a custom controller. Preset metadata only reaches arc's auto-built BaseController — your custom controller will not see \`slugField\`/\`parentField\`/etc. Either (a) drop the preset on this resource (\`presets: [...]\` without it), or (b) extend \`BaseController\` / \`BaseCrudController\` so arc auto-builds the controller and threads the preset fields automatically.`);
565
198
  }
566
199
  /**
567
- * Auto-build a `BaseController` with every resource-level knob
568
- * threaded in. `maxLimit` is extracted from the parser's schema so
569
- * `BaseController.QueryResolver` and Fastify validation stay in sync
570
- * with the parser's configured limit.
200
+ * Auto-build a `BaseController` with every resource-level option. `maxLimit`
201
+ * comes from the parser's schema so QueryResolver and Fastify validation agree.
571
202
  */
572
203
  function buildBaseController(resolvedConfig, adapter, repository) {
573
204
  const qp = resolvedConfig.queryParser;
@@ -586,32 +217,32 @@ function buildBaseController(resolvedConfig, adapter, repository) {
586
217
  ...resourceConfigDefaultLimit !== void 0 ? { defaultLimit: resourceConfigDefaultLimit } : {},
587
218
  tenantField: resolvedConfig.tenantField,
588
219
  idField: resolvedConfig.idField,
220
+ ...resolvedConfig.lookupFields ? { lookupFields: resolvedConfig.lookupFields } : {},
589
221
  ...resolvedConfig.defaultSort !== void 0 ? { defaultSort: resolvedConfig.defaultSort } : {},
590
222
  matchesFilter: adapter?.matchesFilter,
591
223
  cache: resolvedConfig.cache,
592
224
  onFieldWriteDenied: resolvedConfig.onFieldWriteDenied,
593
225
  onImmutableWrite: resolvedConfig.onImmutableWrite,
594
- /**
595
- * Domain commands for the write slots. Threaded here rather than
596
- * subclassing: the pipeline stays arc's and the command stays the
597
- * kernel's, so neither has to be re-implemented to reach the other.
598
- */
599
226
  writes: resolvedConfig.writes,
600
227
  transactional: resolvedConfig.transactional === true,
228
+ ...resolvedConfig.concurrency ? { concurrency: resolvedConfig.concurrency } : {},
601
229
  presetFields: resolvedConfig._controllerOptions ? {
602
230
  slugField: resolvedConfig._controllerOptions.slugField,
603
231
  parentField: resolvedConfig._controllerOptions.parentField
604
232
  } : void 0
605
233
  });
606
234
  }
235
+ /** Deferred like the transaction check: `concurrency` needs the kit's CAS on `ifVersion`. */
236
+ function assertConcurrencyCapability(repository, resourceName) {
237
+ const capabilities = repository?.capabilities;
238
+ if (!capabilities || capabilities.optimisticConcurrency === true) return;
239
+ throw new Error(`Resource "${resourceName}" declares \`concurrency\` but its repository reports \`capabilities.optimisticConcurrency: ${String(capabilities.optimisticConcurrency)}\` — an ETag it cannot enforce would promise a guard that is not there. Use a kit with CAS writes (mongokit), or remove the option.`);
240
+ }
607
241
  /**
608
- * The DEFERRED half of the `transactional: true` gate — run at resource
609
- * registration, after `beforeBoot()` has had its chance to connect.
610
- *
611
- * Fail-closed: anything other than a confirmed `true` refuses the boot. A
612
- * repository with NO capability descriptor (hand-written, custom kit) passes
613
- * — it never promised one, and the define-time presence check already covered
614
- * the only signal it offers.
242
+ * The DEFERRED half of the `transactional: true` gate — run at registration,
243
+ * after `beforeBoot()` connects. Fail-closed: anything but a confirmed `true`
244
+ * refuses the boot. A repository with NO capability descriptor passes (it never
245
+ * promised one; the define-time presence check covered it).
615
246
  */
616
247
  function assertTransactionCapability(repository, resourceName) {
617
248
  const capabilities = repository?.capabilities;
@@ -873,108 +504,6 @@ function resolveCrudAllowList(config) {
873
504
  };
874
505
  }
875
506
  //#endregion
876
- //#region src/core/schemaOptions.ts
877
- /**
878
- * Inject the tenant-scoping field rule into `schemaOptions.fieldRules`:
879
- *
880
- * { [tenantField]: { systemManaged: true, preserveForElevated: true } }
881
- *
882
- * Why both flags: `systemManaged` tells `BodySanitizer` to strip the
883
- * field from inbound bodies (so member clients can't forge a target
884
- * tenant). `preserveForElevated` exempts elevated-admin scopes from the
885
- * strip, so platform admins without a pinned org can still pick a target
886
- * org via the request body (the only channel they have —
887
- * `BaseController.create` can't re-stamp from scope when scope has no
888
- * orgId).
889
- *
890
- * **Returns a new `RouteSchemaOptions`** — the input is never mutated.
891
- * Callers should assign the return value to whatever config slot they
892
- * read from downstream (always the `resolvedConfig`, never raw `config`).
893
- *
894
- * **No-op when:**
895
- * - `tenantField` is `false` (platform-universal resource)
896
- * - `tenantField` is undefined
897
- * - The caller already declared `fieldRules[tenantField].systemManaged`
898
- * (even as `false`) — explicit opt-outs are respected
899
- *
900
- * `preserveForElevated` defaults to `true` but is preserved verbatim
901
- * when the caller set it explicitly.
902
- */
903
- function autoInjectTenantFieldRules(schemaOptions, tenantField) {
904
- if (tenantField === false || tenantField === void 0) return schemaOptions;
905
- const fieldName = tenantField || "organizationId";
906
- const existing = schemaOptions?.fieldRules ?? {};
907
- const existingRule = existing[fieldName];
908
- if (existingRule && existingRule.systemManaged !== void 0) return schemaOptions;
909
- return {
910
- ...schemaOptions ?? {},
911
- fieldRules: {
912
- ...existing,
913
- [fieldName]: {
914
- ...existingRule ?? {},
915
- systemManaged: true,
916
- preserveForElevated: existingRule?.preserveForElevated ?? true
917
- }
918
- }
919
- };
920
- }
921
- /**
922
- * Remove a field from a JSON Schema's `required[]` array. Leaves `properties`
923
- * intact so advanced callers can still send the value — the field just isn't
924
- * mandatory at validation time.
925
- *
926
- * Returns a fresh schema (no mutation). No-op when the schema is undefined,
927
- * lacks a `required[]`, or the field is already absent from it.
928
- */
929
- function stripFromRequired(schema, fieldName) {
930
- if (!schema || typeof schema !== "object") return schema;
931
- const required = schema.required;
932
- if (!Array.isArray(required) || !required.includes(fieldName)) return schema;
933
- const filtered = required.filter((f) => f !== fieldName);
934
- const next = { ...schema };
935
- if (filtered.length > 0) next.required = filtered;
936
- else delete next.required;
937
- return next;
938
- }
939
- /**
940
- * Drop `systemManaged` fields from `required[]` in generated body schemas.
941
- *
942
- * Arc populates those from scope / preset middleware / controller, so a client
943
- * never supplies them — but the DB schema may still mark them required, and the
944
- * adapter reflects that into the wire contract.
945
- *
946
- * THE GOTCHA THIS CLOSES: engines on `@classytic/primitives` default to
947
- * `tenant: { required: true }`, stamping `organizationId` as required. Fastify
948
- * preValidation runs BEFORE arc's preHandler chain, so the tenant-injection
949
- * hook never gets to run and the request dies on
950
- * `must have required property 'organizationId'` — even though the client sent
951
- * `x-organization-id` and arc had promised to inject it. The alternative was
952
- * `tenant: { required: false }` at every consumer site.
953
- *
954
- * Also covers `auditedPreset`'s `createdBy`/`updatedBy` and any host rule
955
- * marked `systemManaged`. Applies to create AND update, since update middleware
956
- * injects the same fields.
957
- *
958
- * `properties` is left INTACT — an elevated caller may still send these, and
959
- * `BodySanitizer` owns that runtime policy (`preserveForElevated`,
960
- * strip-vs-reject). No-op without `systemManaged` rules or generated schemas.
961
- */
962
- function stripSystemManagedFromBodyRequired(schemas, schemaOptions) {
963
- if (!schemas) return schemas;
964
- const rules = schemaOptions?.fieldRules;
965
- if (!rules) return schemas;
966
- const systemManagedFields = Object.entries(rules).filter(([, rule]) => rule?.systemManaged === true).map(([field]) => field);
967
- if (systemManagedFields.length === 0) return schemas;
968
- const next = { ...schemas };
969
- let createBody = schemas.createBody;
970
- for (const field of systemManagedFields) createBody = stripFromRequired(createBody, field);
971
- if (createBody !== schemas.createBody) next.createBody = createBody;
972
- let updateBody = schemas.updateBody;
973
- for (const field of systemManagedFields) updateBody = stripFromRequired(updateBody, field);
974
- if (updateBody !== schemas.updateBody) next.updateBody = updateBody;
975
- return next;
976
- }
977
- //#endregion
978
507
  //#region src/core/defineResource/presets.ts
979
508
  /**
980
509
  * Phase 3 — apply presets + auto-inject tenant-field schema rules.
@@ -1132,352 +661,6 @@ function assertTenantFieldUsable(resourceName, tenantField, strategyType) {
1132
661
  if (tenantField === false) throw new Error(`[Arc/Cascade] Resource '${resourceName}' declares onTenantDelete (strategy: ${strategyType}) but \`tenantField: false\`. Company-wide resources can't be cascaded by org — set a real \`tenantField\` or remove the \`onTenantDelete\` declaration.`);
1133
662
  }
1134
663
  //#endregion
1135
- //#region src/core/defineResource/plugin.ts
1136
- /**
1137
- * Build the CRUD schema map from the adapter's `OpenApiSchemas` plus
1138
- * any `customSchemas` overrides on the resource.
1139
- *
1140
- * Returns `null` when neither input has anything to contribute, so
1141
- * the caller can pass `undefined` straight to `createCrudRouter`.
1142
- *
1143
- * **Per-slot layering (post-2.12 DX fix).** Adapter auto-gen runs
1144
- * unconditionally — declaring one custom slot (e.g. a richer
1145
- * `create.body`) no longer wholesale-disables generated `get`,
1146
- * `update`, `delete`, and `params` schemas. The pre-fix branch
1147
- * skipped auto-gen entirely whenever `customSchemas` had any entry,
1148
- * which silently flipped four slots from "auto-derived from the
1149
- * adapter's schema generator" to "Fastify default" the moment a
1150
- * host customised one. Now: auto-gen first, then deep-merge
1151
- * customSchemas on top per slot.
1152
- *
1153
- * **`params` cloning is load-bearing.** Three CRUD slots (`get`,
1154
- * `delete`, `update`) need a `params` schema. The previous inline
1155
- * code shared the same reference across all three, so a downstream
1156
- * mutation (e.g. attaching a vendor `description` for OpenAPI
1157
- * tooling) leaked across operations. Each slot now owns its own
1158
- * shallow clone.
1159
- */
1160
- function buildGeneratedCrudSchemas(openApi, customSchemas) {
1161
- const generated = {};
1162
- if (openApi) {
1163
- const { createBody, updateBody, params } = openApi;
1164
- if (createBody) generated.create = { body: safeBody(createBody) };
1165
- if (updateBody) {
1166
- const patchBody = structuredClone(updateBody);
1167
- stripRequiredDeep(patchBody);
1168
- generated.update = { body: safeBody(patchBody) };
1169
- if (params) generated.update.params = cloneShallow(params);
1170
- }
1171
- if (params) {
1172
- generated.get = { params: cloneShallow(params) };
1173
- generated.delete = { params: cloneShallow(params) };
1174
- if (!generated.update) generated.update = { params: cloneShallow(params) };
1175
- else if (!generated.update.params) generated.update.params = cloneShallow(params);
1176
- }
1177
- }
1178
- let schemas = Object.keys(generated).length > 0 ? generated : null;
1179
- if (customSchemas && Object.keys(customSchemas).length > 0) {
1180
- schemas = schemas ?? {};
1181
- for (const [op, customSchema] of Object.entries(customSchemas)) {
1182
- const key = op;
1183
- const converted = convertRouteSchema(customSchema);
1184
- schemas[key] = schemas[key] ? {
1185
- ...schemas[key],
1186
- ...converted
1187
- } : converted;
1188
- }
1189
- }
1190
- return schemas;
1191
- }
1192
- /**
1193
- * Normalize the listQuery JSON Schema for Fastify/AJV strict-mode use.
1194
- *
1195
- * The `qs` parser turns bracket notation into nested objects/arrays:
1196
- * `?name[contains]=foo` → `{ name: { contains: "foo" } }`
1197
- * `?tags[]=a&tags[]=b` → `{ tags: ["a", "b"] }`
1198
- * `?populate[author][select]=name` → deeply nested object
1199
- *
1200
- * AJV rejects these against the OpenAPI-friendly `type: "string"`
1201
- * declarations adapters generate. The QueryParser is the source of truth
1202
- * for filter validation/coercion, so we replace each property with a
1203
- * minimal AJV-strict-mode-clean shape: numeric pagination keys keep
1204
- * `type: "integer"` (so `minimum`/`maximum` don't trigger AJV warnings),
1205
- * everything else collapses to `{}`.
1206
- *
1207
- * The original `limit.maximum` is preserved so the parser's configured
1208
- * max stays effective at the AJV layer.
1209
- */
1210
- function normalizeListQuerySchema(listQuerySchema) {
1211
- const NORMALIZED_PROPS = {
1212
- page: {
1213
- type: "integer",
1214
- minimum: 1
1215
- },
1216
- limit: {
1217
- type: "integer",
1218
- minimum: 1
1219
- },
1220
- sort: {},
1221
- search: {},
1222
- select: {},
1223
- after: {},
1224
- populate: {},
1225
- lookup: {},
1226
- aggregate: {}
1227
- };
1228
- const props = listQuerySchema.properties;
1229
- const normalizedProps = props ? { ...props } : void 0;
1230
- if (normalizedProps) {
1231
- const originalLimit = normalizedProps.limit;
1232
- if (originalLimit?.maximum) NORMALIZED_PROPS.limit = {
1233
- ...NORMALIZED_PROPS.limit,
1234
- maximum: originalLimit.maximum
1235
- };
1236
- for (const key of Object.keys(normalizedProps)) normalizedProps[key] = NORMALIZED_PROPS[key] ?? {};
1237
- }
1238
- return {
1239
- type: "object",
1240
- ...listQuerySchema,
1241
- ...normalizedProps ? { properties: normalizedProps } : {},
1242
- additionalProperties: listQuerySchema.additionalProperties ?? true
1243
- };
1244
- }
1245
- /**
1246
- * Merge two JSON schema branches deeply. Arrays are unioned with
1247
- * deduplication (so combined `required` lists don't duplicate field
1248
- * names); plain-object keys recurse; primitives are overwritten.
1249
- *
1250
- * Exported for the action/CRUD router-config code paths that need to
1251
- * compose user overrides on top of generated schemas.
1252
- */
1253
- function deepMergeSchemas(base, override) {
1254
- if (!override) return base;
1255
- if (!base) return override;
1256
- const result = { ...base };
1257
- for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [.../* @__PURE__ */ new Set([...result[key], ...value])];
1258
- else if (value && typeof value === "object" && !Array.isArray(value)) result[key] = deepMergeSchemas(result[key], value);
1259
- else result[key] = value;
1260
- return result;
1261
- }
1262
- function cloneShallow(value) {
1263
- return { ...value };
1264
- }
1265
- /**
1266
- * Delete every `required` from a JSON-schema subtree — PATCH means optional at
1267
- * every depth, not just the root.
1268
- *
1269
- * A generator may legitimately return an `updateBody` whose nested
1270
- * subdocument-array `items` carries its own `required`. Stripping only the
1271
- * root left that intact, so a PATCH adding one item to a subdocument array was
1272
- * rejected by AJV for a field the caller had no way to know it must resend.
1273
- * Recursing here enforces PATCH semantics on whatever a generator produces,
1274
- * rather than trusting each to recurse correctly.
1275
- */
1276
- function stripRequiredDeep(node) {
1277
- if (Array.isArray(node)) {
1278
- for (const item of node) stripRequiredDeep(item);
1279
- return;
1280
- }
1281
- if (!node || typeof node !== "object") return;
1282
- const obj = node;
1283
- delete obj.required;
1284
- for (const value of Object.values(obj)) stripRequiredDeep(value);
1285
- }
1286
- function safeBody(schema) {
1287
- if (schema && typeof schema === "object" && schema.type === "object") return {
1288
- additionalProperties: true,
1289
- ...schema
1290
- };
1291
- return schema;
1292
- }
1293
- /**
1294
- * Normalize `ActionsMap` into the `ActionRouterConfig` shape that
1295
- * `createActionRouter` expects.
1296
- *
1297
- * **Permission fallback chain (fail-closed, v2.10.5):** delegated to the
1298
- * shared resolver in `actionPermissions.ts`. The resource-level gate
1299
- * goes into the resolver's slot 2 (`resourceActionPermissions`) — its
1300
- * semantic home — leaving slot 3 (`globalAuth`) reserved for direct
1301
- * `createActionRouter` callers that genuinely have a router-wide gate.
1302
- *
1303
- * The returned `actionPermissions` map is FULLY RESOLVED per action.
1304
- * Earlier versions returned a sparse map plus a `globalAuth` field that
1305
- * `createActionRouter` flattened at request time via `?? globalAuth`.
1306
- * That conflated two different layers in the resolver chain (slot 2 vs.
1307
- * slot 3). Boot-time resolution closes the drift: every action that
1308
- * survives this pass has its effective gate baked into the map, and
1309
- * `createActionRouter`'s request-time `?? globalAuth` becomes a no-op
1310
- * for the defineResource path.
1311
- */
1312
- function normalizeActionsToRouterConfig(actions, resourceActionPermissions, tag, resourcePermissions, resourceName, log) {
1313
- const handlers = {};
1314
- const permissions = {};
1315
- const schemas = {};
1316
- const idLessActionNames = [];
1317
- for (const [name, entry] of Object.entries(actions)) {
1318
- const explicit = typeof entry !== "function" && entry.permissions ? entry.permissions : void 0;
1319
- if (typeof entry === "function") handlers[name] = entry;
1320
- else {
1321
- const def = entry;
1322
- handlers[name] = def.handler;
1323
- if (def.schema) schemas[name] = def.schema;
1324
- if (def.id === false) idLessActionNames.push(name);
1325
- }
1326
- const effective = resolveActionPermission({
1327
- action: entry,
1328
- resourcePermissions,
1329
- resourceActionPermissions,
1330
- globalAuth: void 0
1331
- });
1332
- if (!explicit && !resourceActionPermissions && effective && effective === resourcePermissions?.update) log?.warn?.({
1333
- resource: resourceName,
1334
- action: name,
1335
- fallback: "permissions.update"
1336
- }, `[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.`);
1337
- 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.`);
1338
- permissions[name] = effective;
1339
- }
1340
- return {
1341
- tag,
1342
- actions: handlers,
1343
- actionPermissions: permissions,
1344
- actionSchemas: schemas,
1345
- idLessActionNames
1346
- };
1347
- }
1348
- /**
1349
- * Build the FastifyPluginAsync that materialises a `ResourceDefinition`
1350
- * into routes, hooks, registry entries, and cache invalidation rules.
1351
- *
1352
- * Called once per `ResourceDefinition.toPlugin()`. The returned plugin
1353
- * function captures `resource` in its closure and can be `app.register`-ed
1354
- * any number of times — shared-state writes are idempotent per host
1355
- * Fastify instance via `resource._sharedStateRegisteredOn`.
1356
- */
1357
- function buildResourcePlugin(resource) {
1358
- return async function resourcePlugin(fastify, opts) {
1359
- await ensureSchemaConverter();
1360
- const sharedRoot = fastify.server;
1361
- const isFirstMount = !resource._sharedStateRegisteredOn.has(sharedRoot);
1362
- if (isFirstMount) resource._sharedStateRegisteredOn.add(sharedRoot);
1363
- if (isFirstMount && resource._deferredChecks?.length) for (const check of resource._deferredChecks) check();
1364
- if (isFirstMount && resource._diagnostics?.length) for (const diagnostic of resource._diagnostics) {
1365
- const level = diagnostic.severity === "info" ? "info" : diagnostic.severity === "error" ? "error" : "warn";
1366
- fastify.log?.[level]?.(diagnostic.message);
1367
- }
1368
- const arc = fastify.arc;
1369
- if (isFirstMount && arc?.registry && resource._registryMeta) try {
1370
- arc.registry.register(resource, resource._registryMeta);
1371
- } catch (err) {
1372
- fastify.log?.warn?.(`Failed to register resource '${resource.name}' in registry: ${err instanceof Error ? err.message : err}`);
1373
- }
1374
- if (isFirstMount && resource._pendingHooks.length > 0 && arc?.hooks) for (const hook of resource._pendingHooks) arc.hooks.register({
1375
- resource: resource.name,
1376
- operation: hook.operation,
1377
- phase: hook.phase,
1378
- handler: hook.handler,
1379
- priority: hook.priority
1380
- });
1381
- if (isFirstMount && resource.cache?.invalidateOn) {
1382
- if (fastify.registerCacheInvalidationRule) for (const [pattern, tags] of Object.entries(resource.cache.invalidateOn)) fastify.registerCacheInvalidationRule({
1383
- pattern,
1384
- tags
1385
- });
1386
- 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.`);
1387
- }
1388
- if (isFirstMount && resource.audit) {
1389
- 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.`);
1390
- }
1391
- if (isFirstMount && resource.events && Object.keys(resource.events).length > 0) {
1392
- const eventsDecor = fastify.events;
1393
- if (!(eventsDecor != null && typeof eventsDecor.publish === "function")) {
1394
- const eventNames = Object.keys(resource.events).join(", ");
1395
- 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.`);
1396
- }
1397
- }
1398
- if (opts?.arcMountRoutes === false) return;
1399
- await fastify.register(async (instance) => {
1400
- const typedInstance = instance;
1401
- let schemas = buildGeneratedCrudSchemas(resource._registryMeta?.openApiSchemas, resource.customSchemas);
1402
- const listQuerySchema = resource._registryMeta?.openApiSchemas?.listQuery;
1403
- if (listQuerySchema) {
1404
- const normalizedSchema = normalizeListQuerySchema(listQuerySchema);
1405
- schemas = schemas ?? {};
1406
- schemas.list = schemas.list ? deepMergeSchemas({ querystring: normalizedSchema }, schemas.list) : { querystring: normalizedSchema };
1407
- }
1408
- createCrudRouter(typedInstance, resource.controller, {
1409
- tag: resource.tag,
1410
- schemas: schemas ?? void 0,
1411
- permissions: resource.permissions,
1412
- middlewares: resource.middlewares,
1413
- routeGuards: resource.routeGuards,
1414
- routes: resource.routes,
1415
- disableDefaultRoutes: resource.disableDefaultRoutes,
1416
- disabledRoutes: [...resource.disabledRoutes],
1417
- resourceName: resource.name,
1418
- schemaOptions: resource.schemaOptions,
1419
- rateLimit: resource.rateLimit,
1420
- updateMethod: resource.updateMethod,
1421
- pipe: resource.pipe,
1422
- fields: resource.fields,
1423
- onFieldWriteDenied: resource.onFieldWriteDenied,
1424
- idField: resource.idField,
1425
- extensions: resource.extensions
1426
- });
1427
- if (resource.actions && Object.keys(resource.actions).length > 0) {
1428
- const { createActionRouter } = await import("./createActionRouter-BBlbPs3k.mjs").then((n) => n.n);
1429
- createActionRouter(typedInstance, {
1430
- ...normalizeActionsToRouterConfig(resource.actions, resource.actionPermissions, resource.tag, resource.permissions, resource.name, typedInstance.log),
1431
- resourceName: resource.name,
1432
- fields: resource.fields,
1433
- schemaOptions: resource.schemaOptions,
1434
- idField: resource.idField,
1435
- permissions: resource.permissions,
1436
- routeGuards: resource.routeGuards,
1437
- pipeline: resource.pipe,
1438
- rateLimit: resource.rateLimit,
1439
- extensions: resource.extensions
1440
- });
1441
- }
1442
- if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
1443
- const [{ createAggregationRouter }, { adapterSupportsAggregate, ArcAggregationConfigError }] = await Promise.all([import("./createAggregationRouter-Dv5SuK2x.mjs"), import("./validate-R0G_JrnK.mjs").then((n) => n.r)]);
1444
- const repoForAgg = resource.controller?.repository ?? resource.repository;
1445
- if (!adapterSupportsAggregate(repoForAgg)) {
1446
- const undispatchable = Object.entries(resource.aggregations).filter(([, cfg]) => !cfg.materialized).map(([name]) => name);
1447
- 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.`);
1448
- }
1449
- const buildOptions = (req) => {
1450
- const ctrl = resource.controller;
1451
- if (!ctrl?.tenantRepoOptions) return {};
1452
- const ctx = createRequestContext(req);
1453
- return ctrl.tenantRepoOptions(ctx);
1454
- };
1455
- createAggregationRouter(typedInstance, {
1456
- tag: resource.tag,
1457
- resourceName: resource.name,
1458
- aggregations: resource.aggregations,
1459
- fields: resource.fields,
1460
- schemaOptions: resource.schemaOptions,
1461
- permissions: resource.permissions,
1462
- routeGuards: resource.routeGuards,
1463
- repository: repoForAgg,
1464
- buildOptions,
1465
- middlewares: resource.middlewares?.aggregations
1466
- });
1467
- }
1468
- if (resource.events && Object.keys(resource.events).length > 0) typedInstance.log?.debug?.(`Resource '${resource.name}' defined ${Object.keys(resource.events).length} events`);
1469
- }, { prefix: resource.prefix });
1470
- if (hasEvents(fastify)) try {
1471
- await fastify.events.publish("arc.resource.registered", {
1472
- resource: resource.name,
1473
- prefix: resource.prefix,
1474
- presets: resource._appliedPresets,
1475
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1476
- });
1477
- } catch {}
1478
- };
1479
- }
1480
- //#endregion
1481
664
  //#region src/core/defineResource/ResourceDefinition.ts
1482
665
  var ResourceDefinition = class {
1483
666
  name;
@@ -1500,6 +683,8 @@ var ResourceDefinition = class {
1500
683
  aggregations;
1501
684
  events;
1502
685
  rateLimit;
686
+ idempotency;
687
+ lookupFields;
1503
688
  audit;
1504
689
  updateMethod;
1505
690
  pipe;
@@ -1533,6 +718,12 @@ var ResourceDefinition = class {
1533
718
  */
1534
719
  resolvedTenantPurge;
1535
720
  idField;
721
+ /**
722
+ * The optimistic-concurrency version field, resolved (`concurrency: {}` → `'__v'`);
723
+ * `undefined` when the resource did not opt in. Published in the resource manifest
724
+ * so a client sends `If-Match` without repeating the field name.
725
+ */
726
+ versionField;
1536
727
  queryParser;
1537
728
  _appliedPresets;
1538
729
  _pendingHooks;
@@ -1621,6 +812,8 @@ var ResourceDefinition = class {
1621
812
  this.actionPermissions = config.actionPermissions;
1622
813
  this.aggregations = config.aggregations;
1623
814
  this.rateLimit = config.rateLimit;
815
+ this.idempotency = config.idempotency;
816
+ this.lookupFields = config.lookupFields;
1624
817
  this.audit = config.audit;
1625
818
  this.updateMethod = config.updateMethod;
1626
819
  this.pipe = config.pipe;
@@ -1638,6 +831,7 @@ var ResourceDefinition = class {
1638
831
  onTenantDelete: this.onTenantDelete
1639
832
  });
1640
833
  this.idField = config.idField;
834
+ if (config.concurrency) this.versionField = config.concurrency.versionField ?? "__v";
1641
835
  this.queryParser = config.queryParser;
1642
836
  this._appliedPresets = config._appliedPresets ?? [];
1643
837
  this._pendingHooks = config._pendingHooks ?? [];
@@ -1754,161 +948,6 @@ function freezeActions(actions) {
1754
948
  return Object.freeze(frozen);
1755
949
  }
1756
950
  //#endregion
1757
- //#region src/core/defineResource/schemas.ts
1758
- /**
1759
- * OpenAPI schema resolution — Phase 7 of `defineResource()`.
1760
- *
1761
- * Pipeline (each step is a pure function over `OpenApiSchemas | undefined`):
1762
- *
1763
- * adapter.generateSchemas()
1764
- * → stripSystemManagedFromBodyRequired (from `../schemaOptions.js`)
1765
- * → cleanLegacyObjectIdParams (idField safety net)
1766
- * → layerQueryParserListQuery (kit's listQuery JSON Schema)
1767
- * → mergeUserOpenApiOverrides (per-resource overrides)
1768
- * → convertOpenApiSchemas (Zod → JSON Schema if needed)
1769
- *
1770
- * Non-fatal: if any step throws, the orchestrator returns `undefined` so
1771
- * the resource still boots — docs / introspection / MCP tool schemas
1772
- * degrade visibly instead of silently drifting.
1773
- *
1774
- * Pulled out of `defineResource.ts` so the central function reads as
1775
- * orchestration only; the schema mechanics live next to each other and
1776
- * are easier to evolve in isolation.
1777
- */
1778
- /**
1779
- * Phase 7 orchestrator — runs the schema pipeline and returns the
1780
- * registry metadata for the resource. Returns `undefined` (with a
1781
- * structured warn log) if any step throws.
1782
- */
1783
- function resolveOpenApiSchemas(resolvedConfig) {
1784
- try {
1785
- let openApiSchemas = generateAdapterSchemas(resolvedConfig);
1786
- openApiSchemas = stripSystemManagedFromBodyRequired(openApiSchemas, resolvedConfig.schemaOptions);
1787
- openApiSchemas = cleanLegacyObjectIdParams(openApiSchemas, resolvedConfig.idField);
1788
- openApiSchemas = layerQueryParserListQuery(openApiSchemas, resolvedConfig.queryParser);
1789
- openApiSchemas = applyResourcePaginationCaps(openApiSchemas, resolvedConfig);
1790
- openApiSchemas = mergeUserOpenApiOverrides(openApiSchemas, resolvedConfig.openApiSchemas);
1791
- if (openApiSchemas) openApiSchemas = convertOpenApiSchemas(openApiSchemas);
1792
- return {
1793
- module: resolvedConfig.module,
1794
- openApiSchemas
1795
- };
1796
- } catch (err) {
1797
- 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.`);
1798
- return;
1799
- }
1800
- }
1801
- /**
1802
- * Step 1 — delegate to the adapter's `generateSchemas`. Returns
1803
- * `undefined` when the adapter doesn't implement the optional method.
1804
- */
1805
- function generateAdapterSchemas(resolvedConfig) {
1806
- if (!resolvedConfig.adapter?.generateSchemas) return void 0;
1807
- const adapterContext = {
1808
- idField: resolvedConfig.idField,
1809
- resourceName: resolvedConfig.name
1810
- };
1811
- return resolvedConfig.adapter.generateSchemas(resolvedConfig.schemaOptions, adapterContext);
1812
- }
1813
- /**
1814
- * Safety net: when `idField` is overridden to a non-default value (UUIDs,
1815
- * slugs, ORD-2026-0001), strip any ObjectId pattern left on `params.id` by
1816
- * legacy adapters or plugins that didn't honor `AdapterSchemaContext.idField`.
1817
- * Custom IDs must not be rejected by AJV before BaseController runs the
1818
- * actual lookup.
1819
- */
1820
- function cleanLegacyObjectIdParams(openApiSchemas, idField) {
1821
- if (!openApiSchemas || !idField || idField === "_id") return openApiSchemas;
1822
- const params = openApiSchemas.params;
1823
- if (!params || typeof params !== "object") return openApiSchemas;
1824
- const properties = params.properties;
1825
- const idProp = properties?.id;
1826
- if (!idProp || typeof idProp !== "object") return openApiSchemas;
1827
- const pattern = idProp.pattern;
1828
- 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 openApiSchemas;
1829
- const cleanedId = { ...idProp };
1830
- delete cleanedId.pattern;
1831
- delete cleanedId.minLength;
1832
- delete cleanedId.maxLength;
1833
- if (!cleanedId.description) cleanedId.description = `${idField} (custom ID field)`;
1834
- return {
1835
- ...openApiSchemas,
1836
- params: {
1837
- ...params,
1838
- properties: {
1839
- ...properties,
1840
- id: cleanedId
1841
- }
1842
- }
1843
- };
1844
- }
1845
- /**
1846
- * Layer the query parser's `getQuerySchema()` output as `listQuery` so
1847
- * the kit's filterable-fields surface flows into OpenAPI / MCP without
1848
- * the user re-declaring it.
1849
- */
1850
- function layerQueryParserListQuery(openApiSchemas, queryParser) {
1851
- const qp = queryParser;
1852
- if (!qp?.getQuerySchema) return openApiSchemas;
1853
- const querySchema = qp.getQuerySchema();
1854
- if (!querySchema) return openApiSchemas;
1855
- return {
1856
- ...openApiSchemas,
1857
- listQuery: querySchema
1858
- };
1859
- }
1860
- /**
1861
- * Apply resource-level `defaultLimit` / `maxLimit` (2.17.0) onto the
1862
- * listQuery schema. Resource-level caps win over both the framework
1863
- * default and the parser-derived value because they're the most-explicit
1864
- * declaration — a host that wrote `defaultLimit: 1000` on the resource
1865
- * means "this resource specifically returns up to 1000", regardless of
1866
- * what the parser's default would have been. The `referenceData: true`
1867
- * shorthand lifts both to 1000 so reference-data resources don't need
1868
- * a custom queryParser just to get the caps right.
1869
- *
1870
- * Bootstraps a minimal listQuery slot when none exists (no queryParser
1871
- * registered, no adapter-generated schema) so the AJV layer still gets
1872
- * a `maximum:` and surfaces the cap in the 400 envelope via
1873
- * `errorHandler.describePaginationCap()` instead of producing the
1874
- * generic "Bad Request" we ate pre-2.17.0.
1875
- */
1876
- function applyResourcePaginationCaps(openApiSchemas, resolvedConfig) {
1877
- const cfg = resolvedConfig;
1878
- if (cfg.defaultLimit === void 0 && cfg.maxLimit === void 0) return openApiSchemas;
1879
- const existing = openApiSchemas?.listQuery;
1880
- const existingProps = existing?.properties ?? {};
1881
- const patchedLimit = { ...existingProps.limit ?? {
1882
- type: "integer",
1883
- minimum: 1
1884
- } };
1885
- if (cfg.maxLimit !== void 0) patchedLimit.maximum = cfg.maxLimit;
1886
- if (cfg.defaultLimit !== void 0) patchedLimit.default = cfg.defaultLimit;
1887
- return {
1888
- ...openApiSchemas,
1889
- listQuery: {
1890
- type: "object",
1891
- ...existing ?? {},
1892
- properties: {
1893
- ...existingProps,
1894
- limit: patchedLimit
1895
- }
1896
- }
1897
- };
1898
- }
1899
- /**
1900
- * Apply per-resource `openApiSchemas` overrides on top of the kit's
1901
- * generated schemas. Shallow merge by slot — users who want field-level
1902
- * surgery should compose at the schema-options layer before this point.
1903
- */
1904
- function mergeUserOpenApiOverrides(openApiSchemas, userOverrides) {
1905
- if (!userOverrides) return openApiSchemas;
1906
- return {
1907
- ...openApiSchemas,
1908
- ...userOverrides
1909
- };
1910
- }
1911
- //#endregion
1912
951
  //#region src/core/validateResourceConfig.ts
1913
952
  /**
1914
953
  * Resource Configuration Validator
@@ -2268,11 +1307,26 @@ const CRUD_OP_NAMES = /* @__PURE__ */ new Set([
2268
1307
  * resulting `ResourceDefinition` for the plugin layer to flush through
2269
1308
  * `fastify.log.warn` on first mount.
2270
1309
  */
2271
- function validateDefineResourceConfig(config) {
2272
- assertValidConfig(config, { skipControllerCheck: true });
1310
+ /**
1311
+ * The checks NO flag may skip — `skipValidation` / `customRoutesOnly` skip structural and advisory
1312
+ * checks only. A route without a permission would otherwise mount PUBLIC.
1313
+ */
1314
+ function validateSecurityInvariants(config) {
2273
1315
  validatePermissionsShape(config);
2274
1316
  validateCustomRoutePermissions(config);
2275
1317
  validateActionsShape(config);
1318
+ validateLookupPolicies(config);
1319
+ }
1320
+ /** Every allowed join names the server scope of its joined side — a collection name alone authorizes nothing. */
1321
+ function validateLookupPolicies(config) {
1322
+ const allowed = config.schemaOptions?.query?.allowedLookups;
1323
+ if (allowed === void 0) return;
1324
+ if (Array.isArray(allowed) || typeof allowed !== "object" || allowed === null) throw new Error(`[Arc] Resource '${config.name}': schemaOptions.query.allowedLookups must map each collection to { scope: ({ scope }) => filter } — the joined side is not tenant-scoped by arc, so a name list authorizes nothing.`);
1325
+ for (const [collection, policy] of Object.entries(allowed)) if (typeof policy?.scope !== "function") throw new Error(`[Arc] Resource '${config.name}': allowedLookups.${collection} needs a scope function — the filter on its joined rows.`);
1326
+ }
1327
+ function validateDefineResourceConfig(config) {
1328
+ assertValidConfig(config, { skipControllerCheck: true });
1329
+ validateSecurityInvariants(config);
2276
1330
  validateRouteCapabilities(config);
2277
1331
  return [
2278
1332
  ...collectRedundantFieldRuleDiagnostics(config),
@@ -2449,15 +1503,49 @@ function validateActionsShape(config) {
2449
1503
  }
2450
1504
  //#endregion
2451
1505
  //#region src/core/defineResource.ts
1506
+ /**
1507
+ * Resource Definition — database-agnostic single source of truth.
1508
+ *
1509
+ * `defineResource()` is the main entry point for arc resources. It
1510
+ * runs a fixed seven-phase pipeline that produces a fully-validated
1511
+ * `ResourceDefinition`:
1512
+ *
1513
+ * 1. validate — fail-fast structural checks
1514
+ * 2. resolveIdField — auto-derive `idField` from repository
1515
+ * 3. applyPresetsAndAutoInject — clone + apply presets + tenant rules
1516
+ * 4. resolveController — reuse user controller or auto-build
1517
+ * 5. buildResource — construct ResourceDefinition + verify
1518
+ * 6. wireHooks — preset + inline `config.hooks`
1519
+ * 7. resolveOpenApiSchemas — adapter → parser listQuery → user
1520
+ *
1521
+ * Each phase has its own module under `./defineResource/`. This file
1522
+ * is the orchestrator only — it threads `resolvedConfig` between
1523
+ * phases and delegates every responsibility. The `ResourceDefinition`
1524
+ * class itself lives in `./defineResource/ResourceDefinition.ts`.
1525
+ *
1526
+ * The adapter is the only kit-specific part — every kit's factory plugs in
1527
+ * identically.
1528
+ *
1529
+ * @example
1530
+ * ```typescript
1531
+ * export default defineResource({
1532
+ * name: 'product',
1533
+ * adapter: createMongooseAdapter({ model: ProductModel, repository: repo }),
1534
+ * presets: ['softDelete', 'slugLookup'],
1535
+ * permissions: { list: allowPublic(), create: requireRoles(['admin']) },
1536
+ * });
1537
+ * ```
1538
+ */
2452
1539
  function defineResource(config) {
2453
1540
  const normalisedConfig = normalizeResourceConfig(config);
2454
1541
  let diagnostics = [];
2455
1542
  if (!normalisedConfig.skipValidation) diagnostics = validateDefineResourceConfig(normalisedConfig);
1543
+ else validateSecurityInvariants(normalisedConfig);
2456
1544
  const repository = normalisedConfig.adapter?.repository;
2457
1545
  const configWithId = resolveIdField(normalisedConfig, repository);
2458
1546
  const resolvedConfig = applyPresetsAndAutoInject(configWithId);
2459
1547
  const hasCrudRoutes = computeHasCrudRoutes(resolvedConfig);
2460
- const strict = resolvedConfig.strictPermissions ?? process.env.ARC_STRICT_PERMISSIONS === "true";
1548
+ const strict = resolvedConfig.strictPermissions ?? readEnv(process.env).bool("ARC_STRICT_PERMISSIONS", true);
2461
1549
  diagnostics = diagnostics.concat(collectUngatedCrudDiagnostics(resolvedConfig, strict));
2462
1550
  const fatal = diagnostics.filter((d) => d.severity === "error");
2463
1551
  if (fatal.length > 0) throw new Error(fatal.map((d) => d.message).join("\n\n"));
@@ -2475,12 +1563,18 @@ function defineResource(config) {
2475
1563
  const registryMeta = resolveOpenApiSchemas(narrowedConfig);
2476
1564
  if (registryMeta) resource._registryMeta = registryMeta;
2477
1565
  }
1566
+ const deferred = [];
2478
1567
  if (resolvedConfig.transactional === true) {
2479
1568
  const name = resolvedConfig.name;
2480
- resource._deferredChecks = [() => assertTransactionCapability(repository, name)];
1569
+ deferred.push(() => assertTransactionCapability(repository, name));
1570
+ }
1571
+ if (resolvedConfig.concurrency) {
1572
+ const name = resolvedConfig.name;
1573
+ deferred.push(() => assertConcurrencyCapability(repository, name));
2481
1574
  }
1575
+ if (deferred.length > 0) resource._deferredChecks = deferred;
2482
1576
  if (diagnostics.length > 0) resource._diagnostics = diagnostics;
2483
1577
  return resource;
2484
1578
  }
2485
1579
  //#endregion
2486
- export { ResourceDefinition as a, validateResourceConfig as i, assertValidConfig as n, createCrudRouter as o, formatValidationErrors as r, createPermissionMiddleware as s, defineResource as t };
1580
+ export { ResourceDefinition as a, validateResourceConfig as i, assertValidConfig as n, formatValidationErrors as r, defineResource as t };