@classytic/arc 2.32.0 → 2.33.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 (192) hide show
  1. package/README.md +1 -1
  2. package/bin/arc.js +58 -17
  3. package/dist/{BaseController-Dk34dxhI.mjs → BaseController-CaQC5EXX.mjs} +293 -33
  4. package/dist/{EventTransport-2uVV34WU.d.mts → EventTransport-BEVP50ka.d.mts} +16 -0
  5. package/dist/{EventTransport-BV10D0oG.mjs → EventTransport-tq4CEB-q.mjs} +39 -2
  6. package/dist/{HookSystem-vimw5D7Z.mjs → HookSystem-DqXkMXkB.mjs} +2 -2
  7. package/dist/{ResourceRegistry-BVr7PgA0.mjs → ResourceRegistry-DoALUlOO.mjs} +3 -2
  8. package/dist/{actionPermissions-Dti7ebyv.mjs → actionPermissions-CUeCmpUd.mjs} +1 -1
  9. package/dist/audit/index.d.mts +2 -2
  10. package/dist/audit/index.mjs +2 -2
  11. package/dist/auth/audit.mjs +1 -1
  12. package/dist/auth/index.d.mts +2 -2
  13. package/dist/auth/index.mjs +39 -7
  14. package/dist/auth/redis-session.mjs +1 -1
  15. package/dist/{authorizationDecision-__T3EJDA.mjs → authorizationDecision-B3mP_QTy.mjs} +2 -2
  16. package/dist/{types-B7XeLEgK.d.mts → base-Csjc2oF-.d.mts} +74 -74
  17. package/dist/{buildHandler-Dn7rzUJo.mjs → buildHandler-DLE9xX-V.mjs} +4 -4
  18. package/dist/cache/index.mjs +4 -4
  19. package/dist/{caching-DFJqHyFs.mjs → caching-BcGmvnHh.mjs} +2 -2
  20. package/dist/cleanup/index.d.mts +17 -5
  21. package/dist/cleanup/index.mjs +240 -7
  22. package/dist/cli/commands/describe.d.mts +1 -1
  23. package/dist/cli/commands/describe.mjs +3 -3
  24. package/dist/cli/commands/docs.mjs +3 -3
  25. package/dist/cli/commands/doctor.mjs +1 -1
  26. package/dist/cli/commands/generate.mjs +2 -2
  27. package/dist/cli/commands/init.mjs +2 -2
  28. package/dist/cli/commands/introspect.mjs +2 -2
  29. package/dist/cli/index.mjs +3 -3
  30. package/dist/{constants-J99LGTJI.mjs → constants-BxCoxmiN.mjs} +1 -1
  31. package/dist/context/index.mjs +3 -3
  32. package/dist/core/index.d.mts +2 -2
  33. package/dist/core/index.mjs +7 -7
  34. package/dist/{core-BwwmNSp7.mjs → core-dxhcHhUD.mjs} +3 -3
  35. package/dist/{createActionRouter-DkJZWo0m.mjs → createActionRouter-DVpoA0do.mjs} +8 -8
  36. package/dist/{createAggregationRouter-B1exOVD8.mjs → createAggregationRouter-B_B48JZ-.mjs} +7 -7
  37. package/dist/{createApp-Bv0m-Q6_.mjs → createApp-CLGegqiG.mjs} +128 -24
  38. package/dist/{defineAggregation-gX4No1gt.mjs → defineAggregation-Nv-DwfOf.mjs} +1 -1
  39. package/dist/{defineResource-XFPPUu2c.mjs → defineResource-DR9vsUYy.mjs} +297 -23
  40. package/dist/discovery/index.mjs +1 -1
  41. package/dist/docs/index.d.mts +1 -1
  42. package/dist/docs/index.mjs +3 -3
  43. package/dist/{elevation-DNXXxgjl.mjs → elevation-B0tlY8y1.mjs} +4 -4
  44. package/dist/encryption/index.mjs +3 -3
  45. package/dist/{errorHandler-B2AZUJUj.mjs → errorHandler-C7xBqHsI.mjs} +42 -5
  46. package/dist/{errorHandler-CpYUJmPA.d.mts → errorHandler-DI5AN2b5.d.mts} +14 -3
  47. package/dist/{errors-Bh1KK5ps.mjs → errors-Cd_yy7aW.mjs} +1 -1
  48. package/dist/{eventPlugin-GF5nnBIq.d.mts → eventPlugin-DSNEZWfV.d.mts} +29 -1
  49. package/dist/{eventPlugin-Dk_TyXeE.mjs → eventPlugin-Dar8qRaj.mjs} +65 -9
  50. package/dist/events/index.d.mts +5 -351
  51. package/dist/events/index.mjs +5 -780
  52. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  53. package/dist/events/transports/redis-stream-entry.mjs +1 -1
  54. package/dist/events/transports/redis.d.mts +1 -1
  55. package/dist/events/transports/redis.mjs +1 -1
  56. package/dist/factory/index.d.mts +20 -5
  57. package/dist/factory/index.mjs +7 -7
  58. package/dist/{fastify-augmentation-BppG5bMT.d.mts → fastify-augmentation-DVsTAy6X.d.mts} +3 -3
  59. package/dist/{fields-DEi65pGj.mjs → fields-CojeUkS4.mjs} +1 -1
  60. package/dist/{generate-CRSG0jTv.mjs → generate-DiB0nHPR.mjs} +2 -2
  61. package/dist/{headers-BXjQYbeC.mjs → headers-CveNO9gR.mjs} +2 -2
  62. package/dist/{health-5XnTa2uo.mjs → health-CasG2A28.mjs} +1 -1
  63. package/dist/hooks/index.d.mts +1 -1
  64. package/dist/hooks/index.mjs +2 -2
  65. package/dist/idempotency/index.d.mts +45 -1
  66. package/dist/idempotency/index.mjs +91 -9
  67. package/dist/idempotency/redis.mjs +1 -1
  68. package/dist/{index-zq6VrjaB.d.mts → index-B-DvUzsr.d.mts} +12 -2
  69. package/dist/{index-CpzxqePp.d.mts → index-BcR2bFZQ.d.mts} +707 -373
  70. package/dist/{index-DXlHwvuE.d.mts → index-Brin0ODU.d.mts} +4 -4
  71. package/dist/{index-ZWhLTp_s.d.mts → index-C5NnMEKF.d.mts} +1 -1
  72. package/dist/{index-C-481Zf2.d.mts → index-ugr1tQlW.d.mts} +17 -1
  73. package/dist/index.d.mts +6 -6
  74. package/dist/index.mjs +11 -11
  75. package/dist/{init-BbfP8aky.mjs → init-mdJ-v4Xp.mjs} +18 -12
  76. package/dist/integrations/event-gateway.d.mts +1 -1
  77. package/dist/integrations/event-gateway.mjs +2 -2
  78. package/dist/integrations/index.d.mts +2 -2
  79. package/dist/integrations/index.mjs +1 -1
  80. package/dist/integrations/jobs/index.d.mts +1 -1
  81. package/dist/integrations/jobs/index.mjs +99 -97
  82. package/dist/integrations/mcp/index.d.mts +12 -3
  83. package/dist/integrations/mcp/index.mjs +2 -2
  84. package/dist/integrations/mcp/testing.d.mts +1 -1
  85. package/dist/integrations/mcp/testing.mjs +2 -2
  86. package/dist/integrations/streamline.mjs +3 -3
  87. package/dist/integrations/webhooks.d.mts +124 -1
  88. package/dist/integrations/webhooks.mjs +180 -2
  89. package/dist/integrations/websocket-pushref-redis.mjs +1 -1
  90. package/dist/integrations/websocket-redis.mjs +1 -1
  91. package/dist/integrations/websocket.mjs +1 -1
  92. package/dist/{interface-BP4IJCBO.mjs → interface-DznJrm9R.mjs} +1 -1
  93. package/dist/{jsonBody-DDSR2HFl.mjs → jsonBody-CfGH-GxO.mjs} +2 -2
  94. package/dist/{keys-B-HBTGXl.mjs → keys-WeKvY1dE.mjs} +1 -1
  95. package/dist/{loadResources-DisEAmkm.mjs → loadResources-Bj-aSsXi.mjs} +2 -2
  96. package/dist/{loadResourcesFromEntry-D1MJp13f.mjs → loadResourcesFromEntry-DwaxNcE2.mjs} +2 -2
  97. package/dist/logger/index.mjs +1 -1
  98. package/dist/{mcpPlugin-CpfVpKHE.mjs → mcpPlugin-aJg5TqHi.mjs} +60 -21
  99. package/dist/{memory-DsuWnj4T.mjs → memory-qYSB7rMQ.mjs} +2 -2
  100. package/dist/{metrics-DdQL8syd.mjs → metrics-D7uchLjN.mjs} +2 -2
  101. package/dist/middleware/index.d.mts +1 -1
  102. package/dist/middleware/index.mjs +3 -3
  103. package/dist/migrations/index.mjs +2 -2
  104. package/dist/{module-Br81am6m.mjs → module-CwKsgZXp.mjs} +1 -1
  105. package/dist/{multipartBody-BnkBLN7v.mjs → multipartBody-4w-S3-PU.mjs} +1 -1
  106. package/dist/{openapi-DIRzvxC0.mjs → openapi-BN5cQaO2.mjs} +6 -6
  107. package/dist/outbox-admin/index.d.mts +2 -2
  108. package/dist/outbox-admin/index.mjs +4 -4
  109. package/dist/outbox-module-CXB79bvt.mjs +839 -0
  110. package/dist/outbox-module-D-VFq37T.d.mts +368 -0
  111. package/dist/permissions/index.d.mts +2 -2
  112. package/dist/permissions/index.mjs +5 -5
  113. package/dist/{permissions-Dl_m0NCe.mjs → permissions-BAfwgBxc.mjs} +25 -6
  114. package/dist/{pipe-ELHCP3zj.mjs → pipe-BzzoEhHj.mjs} +2 -2
  115. package/dist/pipeline/index.d.mts +1 -1
  116. package/dist/pipeline/index.mjs +2 -2
  117. package/dist/{pipeline-CPA-jx0M.mjs → pipeline-q69DZzgG.mjs} +6 -6
  118. package/dist/plugins/index.d.mts +6 -6
  119. package/dist/plugins/index.mjs +16 -16
  120. package/dist/plugins/response-cache.mjs +2 -2
  121. package/dist/plugins/tracing-entry.mjs +2 -2
  122. package/dist/{pluralize-C2ZkKbsn.mjs → pluralize-B7xf8qZF.mjs} +1 -1
  123. package/dist/{preloadResources-CDs87fTw.mjs → preloadResources-BkyqeTDz.mjs} +1 -1
  124. package/dist/{preloadResources-T1hmlTEZ.d.mts → preloadResources-g07ldtzh.d.mts} +30 -7
  125. package/dist/presets/filesUpload.d.mts +2 -2
  126. package/dist/presets/filesUpload.mjs +5 -5
  127. package/dist/presets/index.d.mts +2 -2
  128. package/dist/presets/index.mjs +2 -2
  129. package/dist/presets/multiTenant.d.mts +1 -1
  130. package/dist/presets/multiTenant.mjs +3 -3
  131. package/dist/presets/search.d.mts +2 -2
  132. package/dist/presets/search.mjs +2 -2
  133. package/dist/{presets-Dntd_pUO.mjs → presets-D-h0uWtP.mjs} +4 -4
  134. package/dist/projection-BKM0Ui-s.mjs +42 -0
  135. package/dist/{queryCachePlugin-Dhps-Ldt.mjs → queryCachePlugin-DhsZ0ZIc.mjs} +49 -7
  136. package/dist/{rateLimit-C7mmAGs3.mjs → rateLimit-D4-ASwsI.mjs} +2 -2
  137. package/dist/{rateLimitKey-CccEFBfk.mjs → rateLimitKey-XngqVNEV.mjs} +1 -1
  138. package/dist/{realtime-DVvVZZFz.mjs → realtime-CTkv3O59.mjs} +9 -9
  139. package/dist/{redis-stream-qN6A-RFv.d.mts → redis-stream-mwLWlfHG.d.mts} +1 -1
  140. package/dist/{registerRoute-B_QrjYZS.mjs → registerRoute-DNnd3vkz.mjs} +4 -4
  141. package/dist/registry/index.d.mts +3 -3
  142. package/dist/registry/index.mjs +3 -3
  143. package/dist/{registry-DqUmzA9W.mjs → registry-CLxCR5LF.mjs} +2 -2
  144. package/dist/{renewingLease-BpP9wwj7.mjs → renewingLease-BnWtbA2U.mjs} +1 -1
  145. package/dist/{replyHelpers-DS-P0b32.mjs → replyHelpers-BSBVCwL3.mjs} +2 -2
  146. package/dist/{repositoryFilter-DotlxG3L.mjs → repositoryFilter-DKny7PDn.mjs} +1 -1
  147. package/dist/{requestContext-B6vQF-sy.mjs → requestContext-uYE35JxI.mjs} +1 -1
  148. package/dist/{rolldown-runtime-BhflRrBs.mjs → rolldown-runtime-DNg-KNCk.mjs} +1 -1
  149. package/dist/runtimeCapabilities-Dm88DaTW.mjs +49 -0
  150. package/dist/{schedules-C2Q3uv2d.mjs → schedules-CAcZgia0.mjs} +3 -3
  151. package/dist/{schemaConverter-Crjvczi_.mjs → schemaConverter-XH1RZRyu.mjs} +1 -1
  152. package/dist/{schemaIR-CnyKK_1j.mjs → schemaIR-CDvclAew.mjs} +2 -2
  153. package/dist/schemas/index.mjs +1 -1
  154. package/dist/scim/index.mjs +2 -2
  155. package/dist/scope/index.mjs +5 -5
  156. package/dist/{scope-afz_Ieek.mjs → scope-BaWcqBvr.mjs} +4 -4
  157. package/dist/{simpleEqualityMatcher-C4KiMWUa.mjs → simpleEqualityMatcher-Se8VrsaP.mjs} +1 -1
  158. package/dist/{sse-B9P4Y-UI.mjs → sse-Cf0jUHsh.mjs} +5 -5
  159. package/dist/{sseStream-B3zb_d1h.mjs → sseStream-Bvpi-UXQ.mjs} +2 -2
  160. package/dist/{store-helpers-DmEMHlPQ.mjs → store-helpers-B88p99Z2.mjs} +1 -1
  161. package/dist/{streaming-DPZdQmDz.mjs → streaming-DsT1B5zJ.mjs} +1 -1
  162. package/dist/{subscribe-helpers-sp5xTDVA.mjs → subscribe-helpers-CLIOHsOK.mjs} +2 -2
  163. package/dist/sync/changelog.mjs +1 -1
  164. package/dist/testing/cleanupStoreContract.d.mts +1 -1
  165. package/dist/testing/cleanupStoreContract.mjs +3 -1
  166. package/dist/testing/index.d.mts +24 -12
  167. package/dist/testing/index.mjs +24 -12
  168. package/dist/testing/outboxStoreContract.mjs +1 -1
  169. package/dist/testing/storageContract.mjs +1 -1
  170. package/dist/{transactionContext-BvLKvOIL.mjs → transactionContext-BYtWnwAS.mjs} +1 -1
  171. package/dist/{typeGuards-B_5-hKjJ.mjs → typeGuards-jjKJDN13.mjs} +1 -1
  172. package/dist/types/index.d.mts +3 -3
  173. package/dist/types/index.mjs +1 -1
  174. package/dist/types/storage.mjs +1 -1
  175. package/dist/{types-CT5vu6M0.d.mts → types-BcTumAz_.d.mts} +41 -7
  176. package/dist/{types-CwcUQ610.d.mts → types-BfC83KsP.d.mts} +15 -1
  177. package/dist/{types-DXYX7dIR.mjs → types-CoqIRrkY.mjs} +4 -4
  178. package/dist/{types-C4QE8qnm.d.mts → types-DVLddu2q.d.mts} +144 -1
  179. package/dist/usage/index.d.mts +1 -1
  180. package/dist/usage/index.mjs +0 -0
  181. package/dist/{userHelpers-BNsOXzOd.mjs → userHelpers-CWF5pbZD.mjs} +1 -1
  182. package/dist/utils/index.d.mts +57 -5
  183. package/dist/utils/index.mjs +12 -11
  184. package/dist/{utils-BtOha_Sp.mjs → utils-h1V_sT-t.mjs} +38 -7
  185. package/dist/{validate-PMCPR9El.mjs → validate-CdQlz52F.mjs} +2 -2
  186. package/dist/{versioning-D_rhHfZm.d.mts → versioning-DEs-_hFi.d.mts} +1 -1
  187. package/dist/{versioning-CKv2jz_U.mjs → versioning-rZbyLVD-.mjs} +3 -3
  188. package/package.json +17 -16
  189. package/skills/arc/SKILL.md +96 -4
  190. package/skills/arc/references/api-reference.md +19 -0
  191. package/skills/arc/references/production.md +2 -2
  192. package/dist/projection-CjHhM5W1.mjs +0 -18
@@ -1,22 +1,23 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
- import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-J99LGTJI.mjs";
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
+ import { p as MUTATION_OPERATIONS, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-BxCoxmiN.mjs";
3
3
  import { arcLog } from "./logger/index.mjs";
4
- import { r as ForbiddenError } from "./errors-Bh1KK5ps.mjs";
5
- import { n as getUserRoles } from "./userHelpers-BNsOXzOd.mjs";
6
- import { b as isElevated, n as PUBLIC_SCOPE, x as isMember } from "./types-DXYX7dIR.mjs";
7
- import { d as DEFAULT_FIELD_WRITE_DENIAL_POLICY, t as BaseController } from "./BaseController-Dk34dxhI.mjs";
8
- import { a as buildRouteHooks, c as buildAuthMiddleware, i as selectPluginMw, n as buildRouteConfig, o as resolveRoutePreHandlers, r as resolveRouterPluginMw, s as routeHookOptions, t as buildRateLimitConfig, u as buildArcDecorator } from "./rateLimit-C7mmAGs3.mjs";
9
- import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-DEi65pGj.mjs";
10
- import { r as buildCrudPermissionMw, t as tryRegisterRoute } from "./registerRoute-B_QrjYZS.mjs";
11
- import { c as getDefaultCrudSchemas } from "./utils-BtOha_Sp.mjs";
12
- import { n as convertRouteSchema, r as ensureSchemaConverter, t as convertOpenApiSchemas } from "./schemaConverter-Crjvczi_.mjs";
13
- import { i as pipeUIMessageStreamToReply, r as isReadableStream } from "./streaming-DPZdQmDz.mjs";
14
- import { t as hasEvents } from "./typeGuards-B_5-hKjJ.mjs";
15
- import { a as createFastifyHandler, i as createCrudHandlers, n as buildPipelineHandler, o as createRequestContext, r as resolvePipelineSteps } from "./pipeline-CPA-jx0M.mjs";
16
- import { B as requireAuth } from "./permissions-Dl_m0NCe.mjs";
17
- import { r as getAvailablePresets, t as applyPresets } from "./presets-Dntd_pUO.mjs";
18
- import { t as pluralize } from "./pluralize-C2ZkKbsn.mjs";
19
- import { t as resolveActionPermission } from "./actionPermissions-Dti7ebyv.mjs";
4
+ import { r as ForbiddenError } from "./errors-Cd_yy7aW.mjs";
5
+ import { n as getUserRoles } from "./userHelpers-CWF5pbZD.mjs";
6
+ import { b as isElevated, n as PUBLIC_SCOPE, x as isMember } from "./types-CoqIRrkY.mjs";
7
+ import { d as isWriteVerbCapable, p as DEFAULT_FIELD_WRITE_DENIAL_POLICY, t as BaseController, u as detectOverriddenWriteSlots } from "./BaseController-CaQC5EXX.mjs";
8
+ import { a as buildRouteHooks, c as buildAuthMiddleware, i as selectPluginMw, n as buildRouteConfig, o as resolveRoutePreHandlers, r as resolveRouterPluginMw, s as routeHookOptions, t as buildRateLimitConfig, u as buildArcDecorator } from "./rateLimit-D4-ASwsI.mjs";
9
+ import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-CojeUkS4.mjs";
10
+ import { r as buildCrudPermissionMw, t as tryRegisterRoute } from "./registerRoute-DNnd3vkz.mjs";
11
+ import { c as getDefaultCrudSchemas } from "./utils-h1V_sT-t.mjs";
12
+ import { n as convertRouteSchema, r as ensureSchemaConverter, t as convertOpenApiSchemas } from "./schemaConverter-XH1RZRyu.mjs";
13
+ import { i as pipeUIMessageStreamToReply, r as isReadableStream } from "./streaming-DsT1B5zJ.mjs";
14
+ import { t as hasEvents } from "./typeGuards-jjKJDN13.mjs";
15
+ import { a as createFastifyHandler, i as createCrudHandlers, n as buildPipelineHandler, o as createRequestContext, r as resolvePipelineSteps } from "./pipeline-q69DZzgG.mjs";
16
+ import { B as requireAuth } from "./permissions-BAfwgBxc.mjs";
17
+ import { r as getAvailablePresets, t as applyPresets } from "./presets-D-h0uWtP.mjs";
18
+ import { t as pluralize } from "./pluralize-B7xf8qZF.mjs";
19
+ import { t as resolveActionPermission } from "./actionPermissions-CUeCmpUd.mjs";
20
+ import { isReadOnlyRepo } from "@classytic/repo-core/repository";
20
21
  //#region src/core/middlewares/fieldWrite.ts
21
22
  /**
22
23
  * Build a preHandler that enforces field-write permissions on the request body.
@@ -61,6 +62,18 @@ function methodCarriesBody(method) {
61
62
  //#endregion
62
63
  //#region src/core/createCrudRouter.ts
63
64
  /**
65
+ * Fold a route's host-supplied `config` together with arc's derived one into the
66
+ * SINGLE `config` key Fastify reads. Returns arc's object untouched when the host
67
+ * supplied none, so the route options stay literally what was declared.
68
+ */
69
+ function mergeRouteConfig(hostConfig, arcConfig) {
70
+ if (!hostConfig) return arcConfig;
71
+ return { config: {
72
+ ...hostConfig,
73
+ ...arcConfig.config ?? {}
74
+ } };
75
+ }
76
+ /**
64
77
  * Mount custom routes (from presets or user-defined `routes`) on Fastify.
65
78
  * `wrapHandler` is derived inline from whether `rawHandler` was set.
66
79
  */
@@ -144,7 +157,25 @@ function createCustomRoutes(fastify, routes, controller, options) {
144
157
  }
145
158
  return result;
146
159
  } : handler,
147
- ...buildRouteConfig(route.rateLimit !== void 0 ? buildRateLimitConfig(route.rateLimit) : rateLimitConfig, extensions, route.cors)
160
+ /**
161
+ * ONE `config` object, merging the host's `route.config` with arc's own
162
+ * derived keys (per-route rate limit, cors, `arcExtensions`).
163
+ *
164
+ * It has to be merged HERE because Fastify does not merge for us: it
165
+ * builds the context from a single `opts.config`
166
+ * (`{ ...opts.config, url, method }` — lib/route.js), so two spreads
167
+ * writing `config` means the later one silently replaces the earlier.
168
+ * Emitting them separately dropped `route.config` on every resource
169
+ * that declared extensions, a rate limit or per-route cors — which is
170
+ * the same silent-absence bug `config` was added to fix, just narrowed
171
+ * to the resources most likely to have it.
172
+ *
173
+ * Arc's derived keys win on collision: they come from the declared
174
+ * `route.rateLimit` / `route.cors` / `extensions` fields, so letting a
175
+ * hand-written `config.rateLimit` override them would give one setting
176
+ * two sources that can disagree.
177
+ */
178
+ ...mergeRouteConfig(route.config, buildRouteConfig(route.rateLimit !== void 0 ? buildRateLimitConfig(route.rateLimit) : rateLimitConfig, extensions, route.cors))
148
179
  }, {
149
180
  resourceName,
150
181
  op: opName
@@ -305,6 +336,77 @@ function createPermissionMiddleware(permission, resourceName, action) {
305
336
  return buildCrudPermissionMw(permission, resourceName, action);
306
337
  }
307
338
  //#endregion
339
+ //#region src/core/defineResource/writeVerbs.ts
340
+ /**
341
+ * Phase 4b — write-verb registration policy.
342
+ *
343
+ * A declared command that never executes is the defect `writes` exists to
344
+ * close, one layer up: the resource READS as though the kernel's guarded verb
345
+ * owns the slot while generic CRUD serves the route. So reachability is
346
+ * enforced at REGISTRATION, fail-loud (same posture as `threadQueryParser`):
347
+ *
348
+ * 1. the controller must DISPATCH `_writes` — proven by a prototype
349
+ * capability mark, not by a duck-typed `configure()`, which shows an
350
+ * options channel exists and not that anything reads `writes` from it;
351
+ * 2. no declared verb's slot may be OVERRIDDEN — route dispatch calls the
352
+ * override, so the verb behind it is dead code. Holds even for overrides
353
+ * that delegate to `super`: unprovable statically, so the combination is
354
+ * forbidden. Wrap behaviour with `hooks` instead.
355
+ *
356
+ * Config-SHAPE validation (unknown keys, non-function entries, disabled slots)
357
+ * needs no controller and lives in `validateResourceConfig`.
358
+ */
359
+ /**
360
+ * The write slots, as a runtime list. `MUTATION_OPERATIONS` is the canonical
361
+ * constant; `satisfies` pins it to the `WriteVerbKey` type so the two cannot
362
+ * drift without a compile error.
363
+ */
364
+ const WRITE_VERBS = MUTATION_OPERATIONS;
365
+ /** The verbs a resource actually declared (function-valued entries only). */
366
+ function declaredWriteVerbs(writes) {
367
+ if (!writes) return [];
368
+ return WRITE_VERBS.filter((op) => typeof writes[op] === "function");
369
+ }
370
+ /**
371
+ * Boot-fatal reachability check for declared write verbs on a HOST-SUPPLIED
372
+ * controller. Auto-built controllers never call this — they are capable and
373
+ * un-overridden by construction.
374
+ *
375
+ * Throws before any option threading, so a rejected resource has no partial
376
+ * side effects to reason about.
377
+ */
378
+ function enforceWriteVerbReachability(controller, resolvedConfig) {
379
+ const declared = declaredWriteVerbs(resolvedConfig.writes);
380
+ if (declared.length === 0) return;
381
+ if (typeof controller !== "object" || controller === null || !isWriteVerbCapable(controller)) throw new Error(`Resource "${resolvedConfig.name}" declares write verb(s) [${declared.join(", ")}] but its controller is not built on arc's write pipeline, so the verbs would be silently dropped and generic CRUD would serve those routes — the exact bypass \`writes\` exists to close. Extend \`BaseController\` / \`BaseCrudController\` (their write methods dispatch declared verbs), or omit \`controller\` and let arc auto-build one, or move the commands into the controller itself and remove \`writes\`.`);
382
+ const collisions = detectOverriddenWriteSlots(controller, declared);
383
+ if (collisions.length > 0) throw new Error(`Resource "${resolvedConfig.name}" declares write verb(s) [${collisions.join(", ")}] AND overrides the controller method(s) for the same slot(s). Route dispatch calls the override, so the declared verb can never execute — it is dead code that reads as if it guards the route. Keep exactly one owner per slot: remove the override and let the verb own persistence (wrap request-level behaviour with \`hooks\` — before/around/after run around the verb), or remove the \`writes\` entry and let the override own the whole pipeline.`);
384
+ }
385
+ /**
386
+ * Warn when a controller OVERRIDES a write method while the resource declares
387
+ * field rules only arc's pipeline enforces.
388
+ *
389
+ * Measured: a resource with eight `systemManaged` fields overrode `update` to
390
+ * reach its kernel's guarded verb; `PATCH` then wrote `status: "posted"` and a
391
+ * forged `number` straight through, answering 200. The override was added to
392
+ * close one hole and opened a wider one.
393
+ *
394
+ * Scoped so it stays worth reading: write-verb-capable controllers only,
395
+ * BODY-BEARING ops only (`delete` carries no body for the sanitizer), and only
396
+ * when protection is actually declared. A slot WITH a verb never reaches here —
397
+ * `enforceWriteVerbReachability` already threw.
398
+ */
399
+ function warnOnWriteMethodOverride(controller, resolvedConfig) {
400
+ if (typeof controller !== "object" || controller === null || !isWriteVerbCapable(controller)) return;
401
+ const fieldRules = resolvedConfig.schemaOptions?.fieldRules;
402
+ const protectedFields = fieldRules ? Object.keys(fieldRules) : [];
403
+ const hasFieldPermissions = resolvedConfig.fields !== void 0 && Object.keys(resolvedConfig.fields).length > 0;
404
+ if (protectedFields.length === 0 && !hasFieldPermissions) return;
405
+ const overridden = detectOverriddenWriteSlots(controller, ["create", "update"]);
406
+ if (overridden.length === 0) return;
407
+ arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" overrides controller method(s) [${overridden.join(", ")}] AND declares field protection (${protectedFields.length > 0 ? `fieldRules: ${protectedFields.join(", ")}` : "fields"}). An override replaces arc's entire write pipeline — body sanitization against those field rules, tenant injection, createdBy/updatedBy stamping and the before/around/after hooks all stop running for that op, so the declared protection is NOT enforced. If the override exists to reach a domain command, declare it as a write verb instead (\`writes: { ${overridden[0]}: (…) => engine.<verb>(…) } \`) — arc keeps the pipeline and calls your command in place of the repository. If the override genuinely needs to replace the pipeline, call \`this.bodySanitizer.sanitize(body, op, req, ctx)\` inside it.`);
408
+ }
409
+ //#endregion
308
410
  //#region src/core/defineResource/controller.ts
309
411
  /**
310
412
  * Phase 4 — pick (or auto-create) the resource's controller.
@@ -324,17 +426,33 @@ function createPermissionMiddleware(permission, resourceName, action) {
324
426
  * lists the dropped options, and points at the canonical fix. All
325
427
  * warns honour `ARC_SUPPRESS_WARNINGS=1` via `arcLog()`.
326
428
  */
429
+ /** CRUD routes refused over a read-only repository. */
430
+ const READ_ONLY_REFUSED_ROUTES = [
431
+ "create",
432
+ "update",
433
+ "delete"
434
+ ];
327
435
  /**
328
436
  * Resolve the controller for the resource. See module docstring for
329
437
  * branch semantics.
330
438
  */
331
439
  function resolveOrAutoCreateController(resolvedConfig, adapter, repository, hasCrudRoutes) {
332
440
  const userController = resolvedConfig.controller;
441
+ if (resolvedConfig.transactional === true) {
442
+ if (typeof repository?.withTransaction !== "function") throw new Error(`Resource "${resolvedConfig.name}" declares \`transactional: true\` but its repository has no \`withTransaction\` (capability \`transactions: false\` — D1, standalone Mongo). Wire a transactional kit, or remove the flag; arc will not silently drop a requested transaction envelope.`);
443
+ }
444
+ if (repository && isReadOnlyRepo(repository)) {
445
+ const disabled = new Set(resolvedConfig.disabledRoutes ?? []);
446
+ const enabledWrites = READ_ONLY_REFUSED_ROUTES.filter((op) => !resolvedConfig.disableDefaultRoutes && !disabled.has(op));
447
+ 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.`);
448
+ }
333
449
  if (userController) {
334
450
  threadQueryParser(userController, resolvedConfig);
451
+ enforceWriteVerbReachability(userController, resolvedConfig);
335
452
  threadConfigureLifecycle(userController, resolvedConfig, adapter);
336
453
  warnOnDroppedAuthorOptions(resolvedConfig);
337
454
  warnOnDroppedPresetOptions(resolvedConfig);
455
+ warnOnWriteMethodOverride(userController, resolvedConfig);
338
456
  return userController;
339
457
  }
340
458
  if (!hasCrudRoutes || !repository) return void 0;
@@ -370,7 +488,16 @@ function threadConfigureLifecycle(controller, resolvedConfig, adapter) {
370
488
  if (resolvedConfig.defaultSort !== void 0 && wasDeclared("defaultSort")) opts.defaultSort = resolvedConfig.defaultSort;
371
489
  if (resolvedConfig.cache !== void 0 && wasDeclared("cache")) opts.cache = resolvedConfig.cache;
372
490
  if (resolvedConfig.onFieldWriteDenied !== void 0 && wasDeclared("onFieldWriteDenied")) opts.onFieldWriteDenied = resolvedConfig.onFieldWriteDenied;
491
+ if (resolvedConfig.onImmutableWrite !== void 0 && wasDeclared("onImmutableWrite")) opts.onImmutableWrite = resolvedConfig.onImmutableWrite;
373
492
  if (resolvedConfig.queryParser !== void 0 && wasDeclared("queryParser")) opts.queryParser = resolvedConfig.queryParser;
493
+ /**
494
+ * NOT gated on `_declaredKeys`: `writes` is never inferred or injected by a
495
+ * preset, so its presence IS the declaration. Gating it would mean a
496
+ * resource that declares write verbs alongside its own controller silently
497
+ * kept generic CRUD — the exact bypass the seam exists to close.
498
+ */
499
+ if (resolvedConfig.writes !== void 0) opts.writes = resolvedConfig.writes;
500
+ if (resolvedConfig.transactional !== void 0) opts.transactional = resolvedConfig.transactional;
374
501
  if (adapter?.matchesFilter !== void 0) opts.matchesFilter = adapter.matchesFilter;
375
502
  if (resolvedConfig._controllerOptions) opts.presetFields = {
376
503
  slugField: resolvedConfig._controllerOptions.slugField,
@@ -420,6 +547,7 @@ function warnOnDroppedAuthorOptions(resolvedConfig) {
420
547
  if (resolvedConfig.defaultSort !== void 0 && isDeclared("defaultSort")) dropped.push("defaultSort");
421
548
  if (resolvedConfig.cache !== void 0 && isDeclared("cache")) dropped.push("cache");
422
549
  if (resolvedConfig.onFieldWriteDenied !== void 0 && isDeclared("onFieldWriteDenied")) dropped.push("onFieldWriteDenied");
550
+ if (resolvedConfig.onImmutableWrite !== void 0 && isDeclared("onImmutableWrite")) dropped.push("onImmutableWrite");
423
551
  if (dropped.length === 0) return;
424
552
  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.`);
425
553
  }
@@ -464,12 +592,34 @@ function buildBaseController(resolvedConfig, adapter, repository) {
464
592
  matchesFilter: adapter?.matchesFilter,
465
593
  cache: resolvedConfig.cache,
466
594
  onFieldWriteDenied: resolvedConfig.onFieldWriteDenied,
595
+ onImmutableWrite: resolvedConfig.onImmutableWrite,
596
+ /**
597
+ * Domain commands for the write slots. Threaded here rather than
598
+ * subclassing: the pipeline stays arc's and the command stays the
599
+ * kernel's, so neither has to be re-implemented to reach the other.
600
+ */
601
+ writes: resolvedConfig.writes,
602
+ transactional: resolvedConfig.transactional === true,
467
603
  presetFields: resolvedConfig._controllerOptions ? {
468
604
  slugField: resolvedConfig._controllerOptions.slugField,
469
605
  parentField: resolvedConfig._controllerOptions.parentField
470
606
  } : void 0
471
607
  });
472
608
  }
609
+ /**
610
+ * The DEFERRED half of the `transactional: true` gate — run at resource
611
+ * registration, after `beforeBoot()` has had its chance to connect.
612
+ *
613
+ * Fail-closed: anything other than a confirmed `true` refuses the boot. A
614
+ * repository with NO capability descriptor (hand-written, custom kit) passes
615
+ * — it never promised one, and the define-time presence check already covered
616
+ * the only signal it offers.
617
+ */
618
+ function assertTransactionCapability(repository, resourceName) {
619
+ const capabilities = repository?.capabilities;
620
+ if (!capabilities || capabilities.transactions === true) return;
621
+ throw new Error(`Resource "${resourceName}" declares \`transactional: true\` but its repository reports \`capabilities.transactions: ${String(capabilities.transactions)}\` — this deployment cannot run transactions (standalone MongoDB, D1, or a topology arc could not confirm). \`withTransaction\` exists but every call would fail at BEGIN. Point at a replica set / transactional backend, or remove the flag. Checked at REGISTRATION, so a connection opened in \`beforeBoot()\` is already live by now.`);
622
+ }
473
623
  //#endregion
474
624
  //#region src/core/defineResource/hooks.ts
475
625
  /**
@@ -970,7 +1120,7 @@ function collectUngatedCrudDiagnostics(config, strict = false) {
970
1120
  code: "crud-public-by-omission",
971
1121
  message: `[Arc] Resource '${config.name}' mounts ${ungated.length} CRUD route(s) with no permission gate: ${ungated.join(", ")}.\n` + (severity === "error" ? "strictPermissions is on and an ungated WRITE is fatal. State intent explicitly:\n" : "An omitted permission mounts the route WITHOUT auth. State intent explicitly:\n") + ` permissions: permissions.fullPublic() // intentionally public
972
1122
  permissions: permissions.authenticated() // require auth on every op
973
- permissions: { ${ungated[0]}: requireAuth(), ... } // per-operation`
1123
+ permissions: { ${ungated[0]}: requireAuth(), ... } // per-operation` + (severity === "warn" ? "\nRecommended in production: ARC_STRICT_PERMISSIONS=true (or `strictPermissions: true` on the resource) makes an ungated WRITE fail the boot instead of shipping. This is the arc v3 default." : "")
974
1124
  }];
975
1125
  }
976
1126
  //#endregion
@@ -1214,6 +1364,7 @@ function buildResourcePlugin(resource) {
1214
1364
  const sharedRoot = fastify.server;
1215
1365
  const isFirstMount = !resource._sharedStateRegisteredOn.has(sharedRoot);
1216
1366
  if (isFirstMount) resource._sharedStateRegisteredOn.add(sharedRoot);
1367
+ if (isFirstMount && resource._deferredChecks?.length) for (const check of resource._deferredChecks) check();
1217
1368
  if (isFirstMount && resource._diagnostics?.length) for (const diagnostic of resource._diagnostics) {
1218
1369
  const level = diagnostic.severity === "info" ? "info" : diagnostic.severity === "error" ? "error" : "warn";
1219
1370
  fastify.log?.[level]?.(diagnostic.message);
@@ -1278,7 +1429,7 @@ function buildResourcePlugin(resource) {
1278
1429
  extensions: resource.extensions
1279
1430
  });
1280
1431
  if (resource.actions && Object.keys(resource.actions).length > 0) {
1281
- const { createActionRouter } = await import("./createActionRouter-DkJZWo0m.mjs").then((n) => n.n);
1432
+ const { createActionRouter } = await import("./createActionRouter-DVpoA0do.mjs").then((n) => n.n);
1282
1433
  createActionRouter(typedInstance, {
1283
1434
  ...normalizeActionsToRouterConfig(resource.actions, resource.actionPermissions, resource.tag, resource.permissions, resource.name, typedInstance.log),
1284
1435
  resourceName: resource.name,
@@ -1293,7 +1444,7 @@ function buildResourcePlugin(resource) {
1293
1444
  });
1294
1445
  }
1295
1446
  if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
1296
- const [{ createAggregationRouter }, { adapterSupportsAggregate, ArcAggregationConfigError }] = await Promise.all([import("./createAggregationRouter-B1exOVD8.mjs"), import("./validate-PMCPR9El.mjs").then((n) => n.r)]);
1447
+ const [{ createAggregationRouter }, { adapterSupportsAggregate, ArcAggregationConfigError }] = await Promise.all([import("./createAggregationRouter-B_B48JZ-.mjs"), import("./validate-CdQlz52F.mjs").then((n) => n.r)]);
1297
1448
  const repoForAgg = resource.controller?.repository ?? resource.repository;
1298
1449
  if (!adapterSupportsAggregate(repoForAgg)) {
1299
1450
  const undispatchable = Object.entries(resource.aggregations).filter(([, cfg]) => !cfg.materialized).map(([name]) => name);
@@ -1406,6 +1557,22 @@ var ResourceDefinition = class {
1406
1557
  */
1407
1558
  _diagnostics;
1408
1559
  /**
1560
+ * Boot-time assertions that CANNOT be settled at define time because
1561
+ * they read state a connection establishes.
1562
+ *
1563
+ * `defineResource()` frequently runs at module-import time — before
1564
+ * `beforeBoot()` opens the database. A repository's capability
1565
+ * descriptor is therefore not yet authoritative then: mongokit
1566
+ * deliberately reports `transactions: false` for an unresolved topology
1567
+ * (fail-closed), so asserting at define time would reject a correctly
1568
+ * configured app for the crime of importing its resources early.
1569
+ *
1570
+ * `buildResourcePlugin` runs these on first mount — lifecycle slot 5,
1571
+ * after `beforeBoot` (0) and module resolution (0.5) — where the same
1572
+ * fail-closed rule finally means what it says.
1573
+ */
1574
+ _deferredChecks;
1575
+ /**
1409
1576
  * Public view of {@link _diagnostics} for programmatic consumption.
1410
1577
  *
1411
1578
  * Returns the collected define-time diagnostics for this resource.
@@ -1551,6 +1718,7 @@ var ResourceDefinition = class {
1551
1718
  summary: r.summary,
1552
1719
  description: r.description,
1553
1720
  permissions: r.permissions,
1721
+ capability: r.capability,
1554
1722
  raw: r.rawHandler !== void 0,
1555
1723
  schema: r.schema
1556
1724
  })),
@@ -1793,6 +1961,41 @@ function validateResourceConfig(config, options = {}) {
1793
1961
  message: "Resource has no adapter and no routes",
1794
1962
  suggestion: "Provide either adapter for CRUD or routes for custom logic"
1795
1963
  });
1964
+ if (config.writes) {
1965
+ const writeVerbSlots = MUTATION_OPERATIONS;
1966
+ const entries = Object.entries(config.writes);
1967
+ const unknownKeys = entries.map(([key]) => key).filter((key) => !writeVerbSlots.includes(key));
1968
+ if (unknownKeys.length > 0) errors.push({
1969
+ field: "writes",
1970
+ message: `Unknown write verb slot(s) [${unknownKeys.join(", ")}] — only [${writeVerbSlots.join(", ")}] exist. An unknown slot is silently never called, so the command it names guards nothing`,
1971
+ suggestion: "Rename to a real slot (create/update/delete), or move the behaviour into a custom route / action"
1972
+ });
1973
+ const nonFunction = entries.filter(([key, value]) => writeVerbSlots.includes(key) && typeof value !== "function");
1974
+ for (const [key, value] of nonFunction) errors.push({
1975
+ field: `writes.${key}`,
1976
+ message: `Write verb "${key}" is declared but is ${value === void 0 ? "undefined" : `a ${typeof value}`}, not a function — generic repository CRUD would silently serve that slot instead of the command the config claims guards it`,
1977
+ suggestion: "Pass the command function itself (e.g. `update: (id, data, ctx) => engine.updateDraft(id, data)`), or delete the entry"
1978
+ });
1979
+ const declared = writeVerbSlots.filter((op) => typeof config.writes?.[op] === "function");
1980
+ if (entries.length === 0) warnings.push({
1981
+ field: "writes",
1982
+ message: "`writes` is declared but contains no verb functions",
1983
+ suggestion: "Remove the empty `writes` block, or declare create/update/delete"
1984
+ });
1985
+ if (declared.length > 0 && config.disableDefaultRoutes) errors.push({
1986
+ field: "writes",
1987
+ message: `Resource declares write verb(s) [${declared.join(", ")}] but \`disableDefaultRoutes: true\` means no CRUD slot will call them`,
1988
+ suggestion: "Drop `disableDefaultRoutes` so the slots mount, or move the command into a custom route / action and remove `writes`"
1989
+ });
1990
+ else if (declared.length > 0) {
1991
+ const notMounted = declared.filter((op) => disabledRoutes.has(op));
1992
+ if (notMounted.length > 0) errors.push({
1993
+ field: "writes",
1994
+ message: `Write verb(s) [${notMounted.join(", ")}] are declared but those CRUD slots are disabled, so the verbs can never run`,
1995
+ suggestion: `Remove [${notMounted.join(", ")}] from \`disabledRoutes\`, or delete the matching \`writes\` entr(ies) — a command nothing calls is decoration`
1996
+ });
1997
+ }
1998
+ }
1796
1999
  if (config.controller && !options.skipControllerCheck && !config.disableDefaultRoutes) {
1797
2000
  const ctrl = config.controller;
1798
2001
  const requiredMethods = CRUD_OPERATIONS;
@@ -2074,7 +2277,12 @@ function validateDefineResourceConfig(config) {
2074
2277
  validatePermissionsShape(config);
2075
2278
  validateCustomRoutePermissions(config);
2076
2279
  validateActionsShape(config);
2077
- return [...collectRedundantFieldRuleDiagnostics(config), ...collectReservedFilterNameDiagnostics(config)];
2280
+ validateRouteCapabilities(config);
2281
+ return [
2282
+ ...collectRedundantFieldRuleDiagnostics(config),
2283
+ ...collectReservedFilterNameDiagnostics(config),
2284
+ ...collectPaginationCapDiagnostics(config)
2285
+ ];
2078
2286
  }
2079
2287
  /**
2080
2288
  * Query-string param names arc's `QueryParser` reserves for
@@ -2111,6 +2319,37 @@ function collectReservedFilterNameDiagnostics(config) {
2111
2319
  message: `[Arc] Resource '${config.name}': filterable field(s) [${collisions.join(", ")}] collide with reserved query params (page, limit, cursor, after, sort, search, select, populate). The query parser consumes these names for pagination/projection, so filtering by them via the query string — and via the MCP list tool — will NOT work. Rename the field(s), or drop them from \`filterableFields\` if the collision is intended.`
2112
2320
  }];
2113
2321
  }
2322
+ /**
2323
+ * Warn when the parser's page cap exceeds the repository's.
2324
+ *
2325
+ * Page size is capped in THREE independent places — the query parser, the
2326
+ * repository's pagination engine, and arc — and none of them can see the others.
2327
+ * The lowest silently wins, so a resource that declares 1000 and a repository
2328
+ * left at its default 100 serves 100 rows with a `200` and no signal anywhere.
2329
+ * That is not hypothetical: an account picker read 100 of 696 rows, filtered
2330
+ * that arbitrary slice client-side, and rendered "No accounts found".
2331
+ *
2332
+ * arc is the only layer that holds BOTH, so it is the only one that can notice.
2333
+ * A diagnostic rather than a throw: the mismatch is usually an unconfigured
2334
+ * default rather than a mistake, and a published framework must not refuse to
2335
+ * boot an app that works today — it just works less than the author intended.
2336
+ *
2337
+ * Read structurally, never by importing a kit: `repository._pagination.config`
2338
+ * is mongokit's shape, and arc stays database-agnostic (`check:boundaries`).
2339
+ * Any kit exposing the same shape gets the check for free; one that does not is
2340
+ * simply skipped.
2341
+ */
2342
+ function collectPaginationCapDiagnostics(config) {
2343
+ const parserCap = config.queryParser?.maxLimit;
2344
+ if (typeof parserCap !== "number") return [];
2345
+ const repoCap = (config.adapter?.repository)?._pagination?.config?.maxLimit;
2346
+ if (typeof repoCap !== "number" || parserCap <= repoCap) return [];
2347
+ return [{
2348
+ severity: "warn",
2349
+ code: "pagination-cap-mismatch",
2350
+ message: `[Arc] Resource '${config.name}': the query parser allows ${parserCap} rows per page but the repository caps at ${repoCap}, so ${repoCap} wins and larger pages are truncated with a 200 and no error. Configure the repository's pagination \`maxLimit\` to ${parserCap} as well, or lower the parser's to match.`
2351
+ }];
2352
+ }
2114
2353
  /** Permissions must be `PermissionCheck` functions, not arbitrary values. */
2115
2354
  function validatePermissionsShape(config) {
2116
2355
  if (!config.permissions) return;
@@ -2166,6 +2405,37 @@ function collectRedundantFieldRuleDiagnostics(config) {
2166
2405
  return diagnostics;
2167
2406
  }
2168
2407
  /**
2408
+ * Route `capability` keys — must not collide with a CRUD slot, an action, an
2409
+ * aggregation, or another route on the same resource.
2410
+ *
2411
+ * At BOOT, not at introspection. `introspectRegistry` also refuses a collision,
2412
+ * but nothing inside arc calls it: it is a host API, and a host calls it from a
2413
+ * permission-matrix endpoint. So the only thing catching a duplicate key was a
2414
+ * 500 on that endpoint at REQUEST time — and because a UI reads its whole
2415
+ * permission map from there, one static config typo took every client's gates
2416
+ * out at once, in production, rather than failing the deploy.
2417
+ *
2418
+ * A duplicate `capability` is decidable from the config alone, so it belongs
2419
+ * with the other boot checks. The introspection-side throw stays as a backstop
2420
+ * for callers that build a registry entry by hand instead of via
2421
+ * `defineResource`.
2422
+ */
2423
+ function validateRouteCapabilities(config) {
2424
+ const routes = config.routes;
2425
+ if (!routes?.length) return;
2426
+ const taken = /* @__PURE__ */ new Map();
2427
+ for (const op of Object.keys(config.permissions ?? {})) taken.set(op, "CRUD slot");
2428
+ for (const name of Object.keys(config.actions ?? {})) taken.set(`action:${name}`, "action");
2429
+ for (const name of Object.keys(config.aggregations ?? {})) taken.set(`agg:${name}`, "aggregation");
2430
+ for (const route of routes) {
2431
+ const key = route.capability;
2432
+ if (key === void 0) continue;
2433
+ const owner = taken.get(key);
2434
+ if (owner !== void 0) throw new Error(`[Arc] Resource '${config.name}': route ${route.method} ${route.path} declares capability '${key}', which is already published by a ${owner}. Two gates under one key means one answers for the other verb — rename the route's \`capability\`.`);
2435
+ taken.set(key, `route ${route.method} ${route.path}`);
2436
+ }
2437
+ }
2438
+ /**
2169
2439
  * Actions (v2.8) — name must not collide with CRUD ops; handler +
2170
2440
  * permissions must have the right shapes. Fail at boot so production
2171
2441
  * never ships a misconfigured action endpoint.
@@ -2211,6 +2481,10 @@ function defineResource(config) {
2211
2481
  const registryMeta = resolveOpenApiSchemas(narrowedConfig);
2212
2482
  if (registryMeta) resource._registryMeta = registryMeta;
2213
2483
  }
2484
+ if (resolvedConfig.transactional === true) {
2485
+ const name = resolvedConfig.name;
2486
+ resource._deferredChecks = [() => assertTransactionCapability(repository, name)];
2487
+ }
2214
2488
  if (diagnostics.length > 0) resource._diagnostics = diagnostics;
2215
2489
  return resource;
2216
2490
  }
@@ -1,4 +1,4 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
2
  import { readdir } from "node:fs/promises";
3
3
  import { extname, join, resolve } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
@@ -1,5 +1,5 @@
1
1
 
2
- import { p as RegistryEntry } from "../index-CpzxqePp.mjs";
2
+ import { p as RegistryEntry } from "../index-BcR2bFZQ.mjs";
3
3
  import { t as ExternalOpenApiPaths } from "../externalPaths-CmGOxOtD.mjs";
4
4
  import { FastifyPluginAsync } from "fastify";
5
5
  //#region src/docs/openapi/types.d.ts
@@ -1,6 +1,6 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
- import { n as getUserRoles } from "../userHelpers-BNsOXzOd.mjs";
3
- import { n as openApiPlugin, r as openapi_default, t as buildOpenApiSpec } from "../openapi-DIRzvxC0.mjs";
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
+ import { n as getUserRoles } from "../userHelpers-CWF5pbZD.mjs";
3
+ import { n as openApiPlugin, r as openapi_default, t as buildOpenApiSpec } from "../openapi-BN5cQaO2.mjs";
4
4
  import fp from "fastify-plugin";
5
5
  //#region src/docs/scalar.ts
6
6
  const scalarPlugin = async (fastify, opts = {}) => {
@@ -1,8 +1,8 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
- import { t as __exportAll } from "./rolldown-runtime-BhflRrBs.mjs";
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
+ import { t as __exportAll } from "./rolldown-runtime-DNg-KNCk.mjs";
3
3
  import { arcLog } from "./logger/index.mjs";
4
- import { n as requireSingleHeaderValue } from "./headers-BXjQYbeC.mjs";
5
- import { n as getUserRoles } from "./userHelpers-BNsOXzOd.mjs";
4
+ import { n as requireSingleHeaderValue } from "./headers-CveNO9gR.mjs";
5
+ import { n as getUserRoles } from "./userHelpers-CWF5pbZD.mjs";
6
6
  import fp from "fastify-plugin";
7
7
  //#region src/scope/elevation.ts
8
8
  var elevation_exports = /* @__PURE__ */ __exportAll({
@@ -1,6 +1,6 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
- import { t as ArcError } from "../errors-Bh1KK5ps.mjs";
3
- import { t as parseJsonBody } from "../jsonBody-DDSR2HFl.mjs";
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
+ import { t as ArcError } from "../errors-Cd_yy7aW.mjs";
3
+ import { t as parseJsonBody } from "../jsonBody-CfGH-GxO.mjs";
4
4
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
5
5
  import fp from "fastify-plugin";
6
6
  //#region src/encryption/fieldCipher.ts
@@ -1,9 +1,35 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
2
- import { t as __exportAll } from "./rolldown-runtime-BhflRrBs.mjs";
3
- import { m as statusToArcCode, p as isArcError } from "./errors-Bh1KK5ps.mjs";
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
+ import { t as __exportAll } from "./rolldown-runtime-DNg-KNCk.mjs";
3
+ import { m as statusToArcCode, p as isArcError } from "./errors-Cd_yy7aW.mjs";
4
4
  import { isHttpError, toErrorContract } from "@classytic/repo-core/errors";
5
+ import { isProductionEnv } from "@classytic/primitives/environment";
5
6
  import fp from "fastify-plugin";
6
7
  //#region src/plugins/errorHandler.ts
8
+ /**
9
+ * Global error handler — converts every thrown error into the canonical
10
+ * `ErrorContract` wire shape from `@classytic/repo-core/errors`.
11
+ *
12
+ * Wire shape (no envelope; HTTP status discriminates success vs error):
13
+ * ```
14
+ * { code, message, status, details?, meta?, correlationId? }
15
+ * ```
16
+ *
17
+ * Throw anywhere — controller, hook, middleware — and the response is
18
+ * automatically converted. `ArcError` instances and any `HttpError`-shaped
19
+ * throw (mongokit, sqlitekit, streamline, prismakit) flow through the
20
+ * canonical converter unchanged.
21
+ *
22
+ * Custom domain error classes register via `errorMappers` (instanceof) or
23
+ * `errorMap` (by name). Driver-specific duplicate-key violations
24
+ * (Mongo 11000, Prisma P2002, Postgres 23505, MySQL 1062, SQLite
25
+ * UNIQUE) collapse to a 409 with `code: 'arc.conflict'`.
26
+ *
27
+ * @example
28
+ * await fastify.register(errorHandlerPlugin, {
29
+ * includeStack: process.env.NODE_ENV !== 'production',
30
+ * onError: (err) => Sentry.captureException(err),
31
+ * });
32
+ */
7
33
  var errorHandler_exports = /* @__PURE__ */ __exportAll({
8
34
  defaultIsDuplicateKeyError: () => defaultIsDuplicateKeyError,
9
35
  errorHandlerPlugin: () => errorHandlerPlugin
@@ -94,8 +120,19 @@ function mongooseValidationDetails(errors) {
94
120
  }));
95
121
  }
96
122
  async function errorHandlerPluginFn(fastify, options = {}) {
97
- const isProduction = process.env.NODE_ENV === "production";
98
- const { includeStack = !isProduction, exposeInternalMessages = !isProduction, onError, errorMap = {}, errorMappers = [], isDuplicateKeyError = defaultIsDuplicateKeyError } = options;
123
+ /**
124
+ * The FALLBACK for a plugin registered directly (`fastify.register(errorHandlerPlugin)`), which
125
+ * has no access to `createApp`'s resolved `preset`. Composed through `createApp`, both switches
126
+ * arrive already derived from the preset and this default never applies — see
127
+ * `factory/registerAuth.ts#registerErrorHandler`.
128
+ *
129
+ * Via the shared classifier, NOT `process.env.NODE_ENV === "production"`. That comparison
130
+ * recognises only the long spelling, so a deployment using `NODE_ENV=prod` got BOTH switches
131
+ * true: stack traces AND the raw thrown message of a 500 returned to clients, in production,
132
+ * with nothing to indicate it.
133
+ */
134
+ const productionEnv = isProductionEnv(process.env.NODE_ENV);
135
+ const { includeStack = !productionEnv, exposeInternalMessages = !productionEnv, onError, errorMap = {}, errorMappers = [], isDuplicateKeyError = defaultIsDuplicateKeyError } = options;
99
136
  fastify.setErrorHandler(async (error, request, reply) => {
100
137
  if (onError) try {
101
138
  await onError(error, request);
@@ -17,11 +17,22 @@ interface ErrorMapper<T extends Error = Error> {
17
17
  };
18
18
  }
19
19
  interface ErrorHandlerOptions {
20
- /** Include `meta.stack` on the wire (defaults to `NODE_ENV !== 'production'`). */
20
+ /**
21
+ * Include `meta.stack` on the wire.
22
+ *
23
+ * Under `createApp`, defaults from the resolved `preset` (falling back to the
24
+ * environment via `isProductionEnv`, which knows both `production` and `prod`).
25
+ * Registering this plugin DIRECTLY skips that, and the default is the
26
+ * environment alone.
27
+ */
21
28
  includeStack?: boolean;
22
29
  /**
23
- * Ship the raw `error.message` of UNCLASSIFIED 500s on the wire
24
- * (defaults to `NODE_ENV !== 'production'`).
30
+ * Ship the raw `error.message` of UNCLASSIFIED 500s on the wire.
31
+ *
32
+ * Same derivation as {@link includeStack}: the `preset` under `createApp`,
33
+ * otherwise the environment. Both are host keys, so stating one no longer
34
+ * silently resets the other — `registerErrorHandler` MERGES them over the
35
+ * derived pair instead of replacing it.
25
36
  *
26
37
  * Only the `arc.internal_error` fallback is affected — an unclassified
27
38
  * throw carries whatever some driver/library put in the message (connection
@@ -1,4 +1,4 @@
1
- /*! @classytic/arc v2.32.0 | MIT | https://classytic.com */
1
+ /*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
2
2
  //#region src/utils/errors.ts
3
3
  /**
4
4
  * Base Arc Error. Implements the canonical `HttpError` contract — `status`