@classytic/arc 2.31.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 (194) hide show
  1. package/README.md +1 -1
  2. package/bin/arc.js +58 -17
  3. package/dist/{BaseController-De7GqHck.mjs → BaseController-CaQC5EXX.mjs} +298 -35
  4. package/dist/{EventTransport-2uVV34WU.d.mts → EventTransport-BEVP50ka.d.mts} +16 -0
  5. package/dist/{EventTransport-9mlTgqzE.mjs → EventTransport-tq4CEB-q.mjs} +39 -2
  6. package/dist/{HookSystem-BcmPyq5I.mjs → HookSystem-DqXkMXkB.mjs} +2 -2
  7. package/dist/{ResourceRegistry-qq0mn2s9.mjs → ResourceRegistry-DoALUlOO.mjs} +18 -15
  8. package/dist/{actionPermissions-BE5NwaQk.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 +44 -11
  14. package/dist/auth/redis-session.mjs +1 -1
  15. package/dist/{authorizationDecision-ChvUQGG1.mjs → authorizationDecision-B3mP_QTy.mjs} +2 -2
  16. package/dist/{types-B7XeLEgK.d.mts → base-Csjc2oF-.d.mts} +74 -74
  17. package/dist/{buildHandler-CDrCJzmB.mjs → buildHandler-DLE9xX-V.mjs} +6 -5
  18. package/dist/cache/index.mjs +4 -4
  19. package/dist/{caching-BFHA6Scf.mjs → caching-BcGmvnHh.mjs} +2 -2
  20. package/dist/cleanup/index.d.mts +52 -18
  21. package/dist/cleanup/index.mjs +281 -32
  22. package/dist/cli/commands/describe.d.mts +1 -1
  23. package/dist/cli/commands/describe.mjs +4 -6
  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-D3Dv4LAV.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-BGpIxINN.mjs → core-dxhcHhUD.mjs} +3 -3
  35. package/dist/{createActionRouter-CDXEdasl.mjs → createActionRouter-DVpoA0do.mjs} +14 -11
  36. package/dist/{createAggregationRouter-KrQDzx-k.mjs → createAggregationRouter-B_B48JZ-.mjs} +10 -8
  37. package/dist/{createApp-Df89BEni.mjs → createApp-CLGegqiG.mjs} +287 -62
  38. package/dist/{defineAggregation-DbPPS8RI.mjs → defineAggregation-Nv-DwfOf.mjs} +1 -1
  39. package/dist/{defineResource-Bv6EtN1x.mjs → defineResource-DR9vsUYy.mjs} +318 -35
  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-D2l1mh4g.mjs → elevation-B0tlY8y1.mjs} +4 -4
  44. package/dist/encryption/index.mjs +5 -4
  45. package/dist/{errorHandler-C5NeD6bT.mjs → errorHandler-C7xBqHsI.mjs} +42 -5
  46. package/dist/{errorHandler-CpYUJmPA.d.mts → errorHandler-DI5AN2b5.d.mts} +14 -3
  47. package/dist/{errors-Cj42AntO.mjs → errors-Cd_yy7aW.mjs} +1 -1
  48. package/dist/{eventPlugin-GF5nnBIq.d.mts → eventPlugin-DSNEZWfV.d.mts} +29 -1
  49. package/dist/{eventPlugin-CkffEihm.mjs → eventPlugin-Dar8qRaj.mjs} +68 -11
  50. package/dist/events/index.d.mts +18 -297
  51. package/dist/events/index.mjs +7 -758
  52. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  53. package/dist/events/transports/redis-stream-entry.mjs +14 -10
  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 +131 -8
  58. package/dist/fastify-augmentation-DVsTAy6X.d.mts +179 -0
  59. package/dist/{fields-CB5Cuzk9.mjs → fields-CojeUkS4.mjs} +6 -11
  60. package/dist/{generate-EcA6ui4i.mjs → generate-DiB0nHPR.mjs} +2 -2
  61. package/dist/{headers-3zvE0rM3.mjs → headers-CveNO9gR.mjs} +2 -2
  62. package/dist/{health-DjZYjM7H.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 +95 -11
  67. package/dist/idempotency/redis.mjs +1 -1
  68. package/dist/{index-CEJNZFtX.d.mts → index-B-DvUzsr.d.mts} +82 -3
  69. package/dist/{index-CpzxqePp.d.mts → index-BcR2bFZQ.d.mts} +707 -373
  70. package/dist/{index-Bc2ox8_U.d.mts → index-Brin0ODU.d.mts} +4 -4
  71. package/dist/{index-CFThn1K9.d.mts → index-C5NnMEKF.d.mts} +70 -4
  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-BEiNZBsJ.mjs → init-mdJ-v4Xp.mjs} +24 -16
  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 +100 -98
  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 +38 -35
  92. package/dist/{interface-DYzM0q-u.mjs → interface-DznJrm9R.mjs} +1 -1
  93. package/dist/{jsonBody-BYbLDi5D.mjs → jsonBody-CfGH-GxO.mjs} +2 -2
  94. package/dist/{keys-C0_pa1tY.mjs → keys-WeKvY1dE.mjs} +1 -1
  95. package/dist/{loadResources-yawi9tAE.mjs → loadResources-Bj-aSsXi.mjs} +4 -3
  96. package/dist/{loadResourcesFromEntry-BAjYZonx.mjs → loadResourcesFromEntry-DwaxNcE2.mjs} +2 -2
  97. package/dist/logger/index.mjs +1 -1
  98. package/dist/{mcpPlugin-B_es0hiF.mjs → mcpPlugin-aJg5TqHi.mjs} +76 -36
  99. package/dist/{memory-DR6wpa9F.mjs → memory-qYSB7rMQ.mjs} +5 -5
  100. package/dist/{metrics-CUdw_wLB.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 +3 -3
  104. package/dist/{module-0pFJA6YW.mjs → module-CwKsgZXp.mjs} +1 -1
  105. package/dist/{multipartBody-wOgj_fnj.mjs → multipartBody-4w-S3-PU.mjs} +3 -3
  106. package/dist/{openapi-B2L4js1v.mjs → openapi-BN5cQaO2.mjs} +6 -6
  107. package/dist/outbox-admin/index.d.mts +85 -0
  108. package/dist/outbox-admin/index.mjs +115 -0
  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 +3 -3
  112. package/dist/permissions/index.mjs +6 -6
  113. package/dist/{permissions-L--QDAir.mjs → permissions-BAfwgBxc.mjs} +120 -8
  114. package/dist/{pipe-DRDNsI2T.mjs → pipe-BzzoEhHj.mjs} +3 -5
  115. package/dist/pipeline/index.d.mts +1 -1
  116. package/dist/pipeline/index.mjs +2 -2
  117. package/dist/{pipeline-UPQFatit.mjs → pipeline-q69DZzgG.mjs} +17 -15
  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-O0FZgESl.mjs → pluralize-B7xf8qZF.mjs} +1 -1
  123. package/dist/{preloadResources-Dz6BjzM6.mjs → preloadResources-BkyqeTDz.mjs} +1 -1
  124. package/dist/{preloadResources-CyoJMSiO.d.mts → preloadResources-g07ldtzh.d.mts} +30 -7
  125. package/dist/presets/filesUpload.d.mts +2 -2
  126. package/dist/presets/filesUpload.mjs +6 -6
  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-C4-g4_4P.mjs → presets-D-h0uWtP.mjs} +6 -5
  134. package/dist/projection-BKM0Ui-s.mjs +42 -0
  135. package/dist/{queryCachePlugin-BZr8v46E.mjs → queryCachePlugin-DhsZ0ZIc.mjs} +50 -8
  136. package/dist/{rateLimit-CMPY5Qms.mjs → rateLimit-D4-ASwsI.mjs} +2 -2
  137. package/dist/{rateLimitKey-Dn5JNmkj.mjs → rateLimitKey-XngqVNEV.mjs} +1 -1
  138. package/dist/{realtime-C5NoxWFY.mjs → realtime-CTkv3O59.mjs} +10 -10
  139. package/dist/{redis-stream-qN6A-RFv.d.mts → redis-stream-mwLWlfHG.d.mts} +1 -1
  140. package/dist/{registerRoute-CBN84fc7.mjs → registerRoute-DNnd3vkz.mjs} +10 -8
  141. package/dist/registry/index.d.mts +3 -3
  142. package/dist/registry/index.mjs +3 -3
  143. package/dist/{registry-4213U4zb.mjs → registry-CLxCR5LF.mjs} +2 -2
  144. package/dist/{renewingLease-Dll8mGgc.mjs → renewingLease-BnWtbA2U.mjs} +1 -1
  145. package/dist/{replyHelpers-DIiZKGUt.mjs → replyHelpers-BSBVCwL3.mjs} +2 -2
  146. package/dist/{repositoryFilter-f9eAfq_l.mjs → repositoryFilter-DKny7PDn.mjs} +1 -1
  147. package/dist/{requestContext-2TxXOJ7T.mjs → requestContext-uYE35JxI.mjs} +1 -1
  148. package/dist/{rolldown-runtime-CUsjNX9R.mjs → rolldown-runtime-DNg-KNCk.mjs} +1 -1
  149. package/dist/runtimeCapabilities-Dm88DaTW.mjs +49 -0
  150. package/dist/{schedules-DdtlICTf.mjs → schedules-CAcZgia0.mjs} +3 -3
  151. package/dist/{schemaConverter-D--wdJM-.mjs → schemaConverter-XH1RZRyu.mjs} +1 -1
  152. package/dist/{schemaIR-Bm2enbnh.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-DHx5Fous.mjs → scope-BaWcqBvr.mjs} +4 -4
  157. package/dist/{simpleEqualityMatcher-DjQJ7FKA.mjs → simpleEqualityMatcher-Se8VrsaP.mjs} +1 -1
  158. package/dist/{sse-CUTo9y0X.mjs → sse-Cf0jUHsh.mjs} +7 -6
  159. package/dist/{sseStream-B_5XcVVd.mjs → sseStream-Bvpi-UXQ.mjs} +2 -2
  160. package/dist/{store-helpers-D1MP_gRG.mjs → store-helpers-B88p99Z2.mjs} +1 -1
  161. package/dist/{streaming-PSZwi8JV.mjs → streaming-DsT1B5zJ.mjs} +1 -1
  162. package/dist/{subscribe-helpers-C0Tb2aUp.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 +12 -6
  166. package/dist/testing/index.d.mts +52 -13
  167. package/dist/testing/index.mjs +110 -40
  168. package/dist/testing/outboxStoreContract.d.mts +11 -2
  169. package/dist/testing/outboxStoreContract.mjs +110 -21
  170. package/dist/testing/storageContract.mjs +11 -6
  171. package/dist/{transactionContext-dAfBVUrk.mjs → transactionContext-BYtWnwAS.mjs} +1 -1
  172. package/dist/{typeGuards-B0ryPo3I.mjs → typeGuards-jjKJDN13.mjs} +1 -1
  173. package/dist/types/index.d.mts +3 -3
  174. package/dist/types/index.mjs +1 -1
  175. package/dist/types/storage.mjs +1 -1
  176. package/dist/{types-CBgvkp8O.d.mts → types-BcTumAz_.d.mts} +128 -18
  177. package/dist/{types-CwcUQ610.d.mts → types-BfC83KsP.d.mts} +15 -1
  178. package/dist/{types-Cys0ko9Z.mjs → types-CoqIRrkY.mjs} +4 -4
  179. package/dist/{types-C4QE8qnm.d.mts → types-DVLddu2q.d.mts} +144 -1
  180. package/dist/usage/index.d.mts +1 -1
  181. package/dist/usage/index.mjs +0 -0
  182. package/dist/{userHelpers-ChYJ6CdK.mjs → userHelpers-CWF5pbZD.mjs} +1 -1
  183. package/dist/utils/index.d.mts +57 -5
  184. package/dist/utils/index.mjs +12 -11
  185. package/dist/{utils-vL8BrDE2.mjs → utils-h1V_sT-t.mjs} +38 -7
  186. package/dist/{validate-_KbifpHF.mjs → validate-CdQlz52F.mjs} +2 -2
  187. package/dist/{versioning-D_rhHfZm.d.mts → versioning-DEs-_hFi.d.mts} +1 -1
  188. package/dist/{versioning-CHDTor3P.mjs → versioning-rZbyLVD-.mjs} +4 -4
  189. package/package.json +31 -20
  190. package/skills/arc/SKILL.md +96 -4
  191. package/skills/arc/references/api-reference.md +19 -0
  192. package/skills/arc/references/production.md +2 -2
  193. package/dist/fastify-augmentation-zh0_AYHa.d.mts +0 -85
  194. package/dist/projection-BHw6xlOQ.mjs +0 -18
@@ -1,22 +1,23 @@
1
- /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
2
- import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-D3Dv4LAV.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-Cj42AntO.mjs";
5
- import { n as getUserRoles } from "./userHelpers-ChYJ6CdK.mjs";
6
- import { b as isElevated, n as PUBLIC_SCOPE, x as isMember } from "./types-Cys0ko9Z.mjs";
7
- import { d as DEFAULT_FIELD_WRITE_DENIAL_POLICY, t as BaseController } from "./BaseController-De7GqHck.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-CMPY5Qms.mjs";
9
- import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-CB5Cuzk9.mjs";
10
- import { r as buildCrudPermissionMw, t as tryRegisterRoute } from "./registerRoute-CBN84fc7.mjs";
11
- import { c as getDefaultCrudSchemas } from "./utils-vL8BrDE2.mjs";
12
- import { n as convertRouteSchema, r as ensureSchemaConverter, t as convertOpenApiSchemas } from "./schemaConverter-D--wdJM-.mjs";
13
- import { i as pipeUIMessageStreamToReply, r as isReadableStream } from "./streaming-PSZwi8JV.mjs";
14
- import { t as hasEvents } from "./typeGuards-B0ryPo3I.mjs";
15
- import { a as createFastifyHandler, i as createCrudHandlers, n as buildPipelineHandler, o as createRequestContext, r as resolvePipelineSteps } from "./pipeline-UPQFatit.mjs";
16
- import { I as requireAuth } from "./permissions-L--QDAir.mjs";
17
- import { r as getAvailablePresets, t as applyPresets } from "./presets-C4-g4_4P.mjs";
18
- import { t as pluralize } from "./pluralize-O0FZgESl.mjs";
19
- import { t as resolveActionPermission } from "./actionPermissions-BE5NwaQk.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.
@@ -42,7 +43,10 @@ function buildFieldWritePreHandler(fieldPermissions, policy = DEFAULT_FIELD_WRIT
42
43
  if (!body || typeof body !== "object" || Array.isArray(body)) return;
43
44
  const scope = request.scope ?? PUBLIC_SCOPE;
44
45
  if (isElevated(scope)) return;
45
- const { body: filtered, deniedFields } = applyFieldWritePermissions(body, fieldPermissions, resolveEffectiveRoles(getUserRoles(request.user), isMember(scope) ? scope.orgRoles : []));
46
+ const globalRoles = getUserRoles(request.user);
47
+ const orgRoles = isMember(scope) ? scope.orgRoles : [];
48
+ const effectiveRoles = resolveEffectiveRoles(globalRoles, orgRoles);
49
+ const { body: filtered, deniedFields } = applyFieldWritePermissions(body, fieldPermissions, effectiveRoles);
46
50
  if (deniedFields.length > 0 && policy === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
47
51
  request.body = filtered;
48
52
  };
@@ -58,6 +62,18 @@ function methodCarriesBody(method) {
58
62
  //#endregion
59
63
  //#region src/core/createCrudRouter.ts
60
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
+ /**
61
77
  * Mount custom routes (from presets or user-defined `routes`) on Fastify.
62
78
  * `wrapHandler` is derived inline from whether `rawHandler` was set.
63
79
  */
@@ -114,8 +130,9 @@ function createCustomRoutes(fastify, routes, controller, options) {
114
130
  const fieldWriteMw = buildFieldWritePreHandler(fieldPermissions, onFieldWriteDenied);
115
131
  if (fieldWriteMw) customPreHandlers.push(fieldWriteMw);
116
132
  }
133
+ const preAuthHandlers = route.preAuth ?? [];
117
134
  const hooks = buildRouteHooks({
118
- preAuth: route.preAuth ?? [],
135
+ preAuth: preAuthHandlers,
119
136
  arcDecorator,
120
137
  authMw: buildAuthMiddleware(fastify, route.permissions),
121
138
  permissionMw: buildCrudPermissionMw(route.permissions, resourceName, opName),
@@ -140,7 +157,25 @@ function createCustomRoutes(fastify, routes, controller, options) {
140
157
  }
141
158
  return result;
142
159
  } : handler,
143
- ...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))
144
179
  }, {
145
180
  resourceName,
146
181
  op: opName
@@ -287,7 +322,8 @@ function buildCrudHandlers(ctrl, pipeline, resourceName) {
287
322
  for (const op of CRUD_OPERATIONS) {
288
323
  const steps = resolvePipelineSteps(pipeline, op);
289
324
  if (steps.length === 0) continue;
290
- wrapped[op] = buildPipelineHandler(ctrl[op].bind(ctrl), steps, op, resourceName);
325
+ const method = ctrl[op].bind(ctrl);
326
+ wrapped[op] = buildPipelineHandler(method, steps, op, resourceName);
291
327
  }
292
328
  return wrapped;
293
329
  }
@@ -300,6 +336,77 @@ function createPermissionMiddleware(permission, resourceName, action) {
300
336
  return buildCrudPermissionMw(permission, resourceName, action);
301
337
  }
302
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
303
410
  //#region src/core/defineResource/controller.ts
304
411
  /**
305
412
  * Phase 4 — pick (or auto-create) the resource's controller.
@@ -319,17 +426,33 @@ function createPermissionMiddleware(permission, resourceName, action) {
319
426
  * lists the dropped options, and points at the canonical fix. All
320
427
  * warns honour `ARC_SUPPRESS_WARNINGS=1` via `arcLog()`.
321
428
  */
429
+ /** CRUD routes refused over a read-only repository. */
430
+ const READ_ONLY_REFUSED_ROUTES = [
431
+ "create",
432
+ "update",
433
+ "delete"
434
+ ];
322
435
  /**
323
436
  * Resolve the controller for the resource. See module docstring for
324
437
  * branch semantics.
325
438
  */
326
439
  function resolveOrAutoCreateController(resolvedConfig, adapter, repository, hasCrudRoutes) {
327
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
+ }
328
449
  if (userController) {
329
450
  threadQueryParser(userController, resolvedConfig);
451
+ enforceWriteVerbReachability(userController, resolvedConfig);
330
452
  threadConfigureLifecycle(userController, resolvedConfig, adapter);
331
453
  warnOnDroppedAuthorOptions(resolvedConfig);
332
454
  warnOnDroppedPresetOptions(resolvedConfig);
455
+ warnOnWriteMethodOverride(userController, resolvedConfig);
333
456
  return userController;
334
457
  }
335
458
  if (!hasCrudRoutes || !repository) return void 0;
@@ -365,7 +488,16 @@ function threadConfigureLifecycle(controller, resolvedConfig, adapter) {
365
488
  if (resolvedConfig.defaultSort !== void 0 && wasDeclared("defaultSort")) opts.defaultSort = resolvedConfig.defaultSort;
366
489
  if (resolvedConfig.cache !== void 0 && wasDeclared("cache")) opts.cache = resolvedConfig.cache;
367
490
  if (resolvedConfig.onFieldWriteDenied !== void 0 && wasDeclared("onFieldWriteDenied")) opts.onFieldWriteDenied = resolvedConfig.onFieldWriteDenied;
491
+ if (resolvedConfig.onImmutableWrite !== void 0 && wasDeclared("onImmutableWrite")) opts.onImmutableWrite = resolvedConfig.onImmutableWrite;
368
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;
369
501
  if (adapter?.matchesFilter !== void 0) opts.matchesFilter = adapter.matchesFilter;
370
502
  if (resolvedConfig._controllerOptions) opts.presetFields = {
371
503
  slugField: resolvedConfig._controllerOptions.slugField,
@@ -415,6 +547,7 @@ function warnOnDroppedAuthorOptions(resolvedConfig) {
415
547
  if (resolvedConfig.defaultSort !== void 0 && isDeclared("defaultSort")) dropped.push("defaultSort");
416
548
  if (resolvedConfig.cache !== void 0 && isDeclared("cache")) dropped.push("cache");
417
549
  if (resolvedConfig.onFieldWriteDenied !== void 0 && isDeclared("onFieldWriteDenied")) dropped.push("onFieldWriteDenied");
550
+ if (resolvedConfig.onImmutableWrite !== void 0 && isDeclared("onImmutableWrite")) dropped.push("onImmutableWrite");
418
551
  if (dropped.length === 0) return;
419
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.`);
420
553
  }
@@ -459,12 +592,34 @@ function buildBaseController(resolvedConfig, adapter, repository) {
459
592
  matchesFilter: adapter?.matchesFilter,
460
593
  cache: resolvedConfig.cache,
461
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,
462
603
  presetFields: resolvedConfig._controllerOptions ? {
463
604
  slugField: resolvedConfig._controllerOptions.slugField,
464
605
  parentField: resolvedConfig._controllerOptions.parentField
465
606
  } : void 0
466
607
  });
467
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
+ }
468
623
  //#endregion
469
624
  //#region src/core/defineResource/hooks.ts
470
625
  /**
@@ -965,7 +1120,7 @@ function collectUngatedCrudDiagnostics(config, strict = false) {
965
1120
  code: "crud-public-by-omission",
966
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
967
1122
  permissions: permissions.authenticated() // require auth on every op
968
- 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." : "")
969
1124
  }];
970
1125
  }
971
1126
  //#endregion
@@ -1209,6 +1364,7 @@ function buildResourcePlugin(resource) {
1209
1364
  const sharedRoot = fastify.server;
1210
1365
  const isFirstMount = !resource._sharedStateRegisteredOn.has(sharedRoot);
1211
1366
  if (isFirstMount) resource._sharedStateRegisteredOn.add(sharedRoot);
1367
+ if (isFirstMount && resource._deferredChecks?.length) for (const check of resource._deferredChecks) check();
1212
1368
  if (isFirstMount && resource._diagnostics?.length) for (const diagnostic of resource._diagnostics) {
1213
1369
  const level = diagnostic.severity === "info" ? "info" : diagnostic.severity === "error" ? "error" : "warn";
1214
1370
  fastify.log?.[level]?.(diagnostic.message);
@@ -1226,11 +1382,13 @@ function buildResourcePlugin(resource) {
1226
1382
  handler: hook.handler,
1227
1383
  priority: hook.priority
1228
1384
  });
1229
- if (isFirstMount && resource.cache?.invalidateOn) if (fastify.registerCacheInvalidationRule) for (const [pattern, tags] of Object.entries(resource.cache.invalidateOn)) fastify.registerCacheInvalidationRule({
1230
- pattern,
1231
- tags
1232
- });
1233
- 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.`);
1385
+ if (isFirstMount && resource.cache?.invalidateOn) {
1386
+ if (fastify.registerCacheInvalidationRule) for (const [pattern, tags] of Object.entries(resource.cache.invalidateOn)) fastify.registerCacheInvalidationRule({
1387
+ pattern,
1388
+ tags
1389
+ });
1390
+ 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.`);
1391
+ }
1234
1392
  if (isFirstMount && resource.audit) {
1235
1393
  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.`);
1236
1394
  }
@@ -1271,7 +1429,7 @@ function buildResourcePlugin(resource) {
1271
1429
  extensions: resource.extensions
1272
1430
  });
1273
1431
  if (resource.actions && Object.keys(resource.actions).length > 0) {
1274
- const { createActionRouter } = await import("./createActionRouter-CDXEdasl.mjs").then((n) => n.n);
1432
+ const { createActionRouter } = await import("./createActionRouter-DVpoA0do.mjs").then((n) => n.n);
1275
1433
  createActionRouter(typedInstance, {
1276
1434
  ...normalizeActionsToRouterConfig(resource.actions, resource.actionPermissions, resource.tag, resource.permissions, resource.name, typedInstance.log),
1277
1435
  resourceName: resource.name,
@@ -1286,7 +1444,7 @@ function buildResourcePlugin(resource) {
1286
1444
  });
1287
1445
  }
1288
1446
  if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
1289
- const [{ createAggregationRouter }, { adapterSupportsAggregate, ArcAggregationConfigError }] = await Promise.all([import("./createAggregationRouter-KrQDzx-k.mjs"), import("./validate-_KbifpHF.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)]);
1290
1448
  const repoForAgg = resource.controller?.repository ?? resource.repository;
1291
1449
  if (!adapterSupportsAggregate(repoForAgg)) {
1292
1450
  const undispatchable = Object.entries(resource.aggregations).filter(([, cfg]) => !cfg.materialized).map(([name]) => name);
@@ -1399,6 +1557,22 @@ var ResourceDefinition = class {
1399
1557
  */
1400
1558
  _diagnostics;
1401
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
+ /**
1402
1576
  * Public view of {@link _diagnostics} for programmatic consumption.
1403
1577
  *
1404
1578
  * Returns the collected define-time diagnostics for this resource.
@@ -1486,10 +1660,12 @@ var ResourceDefinition = class {
1486
1660
  _validateControllerMethods() {
1487
1661
  const errors = [];
1488
1662
  const enabledCrudRoutes = CRUD_OPERATIONS.filter((route) => !this.disabledRoutes.includes(route));
1489
- if (!this.disableDefaultRoutes && enabledCrudRoutes.length > 0) if (!this.controller) errors.push("Controller is required when CRUD routes are enabled");
1490
- else {
1491
- const ctrl = this.controller;
1492
- for (const method of enabledCrudRoutes) if (typeof ctrl[method] !== "function") errors.push(`CRUD method '${method}' not found on controller`);
1663
+ if (!this.disableDefaultRoutes && enabledCrudRoutes.length > 0) {
1664
+ if (!this.controller) errors.push("Controller is required when CRUD routes are enabled");
1665
+ else {
1666
+ const ctrl = this.controller;
1667
+ for (const method of enabledCrudRoutes) if (typeof ctrl[method] !== "function") errors.push(`CRUD method '${method}' not found on controller`);
1668
+ }
1493
1669
  }
1494
1670
  for (const route of this.routes) {
1495
1671
  if (typeof route.handler !== "string") continue;
@@ -1542,6 +1718,7 @@ var ResourceDefinition = class {
1542
1718
  summary: r.summary,
1543
1719
  description: r.description,
1544
1720
  permissions: r.permissions,
1721
+ capability: r.capability,
1545
1722
  raw: r.rawHandler !== void 0,
1546
1723
  schema: r.schema
1547
1724
  })),
@@ -1784,6 +1961,41 @@ function validateResourceConfig(config, options = {}) {
1784
1961
  message: "Resource has no adapter and no routes",
1785
1962
  suggestion: "Provide either adapter for CRUD or routes for custom logic"
1786
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
+ }
1787
1999
  if (config.controller && !options.skipControllerCheck && !config.disableDefaultRoutes) {
1788
2000
  const ctrl = config.controller;
1789
2001
  const requiredMethods = CRUD_OPERATIONS;
@@ -2065,7 +2277,12 @@ function validateDefineResourceConfig(config) {
2065
2277
  validatePermissionsShape(config);
2066
2278
  validateCustomRoutePermissions(config);
2067
2279
  validateActionsShape(config);
2068
- return [...collectRedundantFieldRuleDiagnostics(config), ...collectReservedFilterNameDiagnostics(config)];
2280
+ validateRouteCapabilities(config);
2281
+ return [
2282
+ ...collectRedundantFieldRuleDiagnostics(config),
2283
+ ...collectReservedFilterNameDiagnostics(config),
2284
+ ...collectPaginationCapDiagnostics(config)
2285
+ ];
2069
2286
  }
2070
2287
  /**
2071
2288
  * Query-string param names arc's `QueryParser` reserves for
@@ -2102,6 +2319,37 @@ function collectReservedFilterNameDiagnostics(config) {
2102
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.`
2103
2320
  }];
2104
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
+ }
2105
2353
  /** Permissions must be `PermissionCheck` functions, not arbitrary values. */
2106
2354
  function validatePermissionsShape(config) {
2107
2355
  if (!config.permissions) return;
@@ -2157,6 +2405,37 @@ function collectRedundantFieldRuleDiagnostics(config) {
2157
2405
  return diagnostics;
2158
2406
  }
2159
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
+ /**
2160
2439
  * Actions (v2.8) — name must not collide with CRUD ops; handler +
2161
2440
  * permissions must have the right shapes. Fail at boot so production
2162
2441
  * never ships a misconfigured action endpoint.
@@ -2202,6 +2481,10 @@ function defineResource(config) {
2202
2481
  const registryMeta = resolveOpenApiSchemas(narrowedConfig);
2203
2482
  if (registryMeta) resource._registryMeta = registryMeta;
2204
2483
  }
2484
+ if (resolvedConfig.transactional === true) {
2485
+ const name = resolvedConfig.name;
2486
+ resource._deferredChecks = [() => assertTransactionCapability(repository, name)];
2487
+ }
2205
2488
  if (diagnostics.length > 0) resource._diagnostics = diagnostics;
2206
2489
  return resource;
2207
2490
  }
@@ -1,4 +1,4 @@
1
- /*! @classytic/arc v2.31.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.31.0 | MIT | https://classytic.com */
2
- import { n as getUserRoles } from "../userHelpers-ChYJ6CdK.mjs";
3
- import { n as openApiPlugin, r as openapi_default, t as buildOpenApiSpec } from "../openapi-B2L4js1v.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.31.0 | MIT | https://classytic.com */
2
- import { t as __exportAll } from "./rolldown-runtime-CUsjNX9R.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-3zvE0rM3.mjs";
5
- import { n as getUserRoles } from "./userHelpers-ChYJ6CdK.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.31.0 | MIT | https://classytic.com */
2
- import { t as ArcError } from "../errors-Cj42AntO.mjs";
3
- import { t as parseJsonBody } from "../jsonBody-BYbLDi5D.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
@@ -71,7 +71,8 @@ function parseFieldEnvelope(token) {
71
71
  }
72
72
  /** Decrypt a parsed `arc.v1` envelope with the resolved key. Throws on tamper. */
73
73
  function decryptField(envelope, key) {
74
- const decipher = createDecipheriv("aes-256-gcm", toKeyBuffer(key), envelope.iv);
74
+ const secret = toKeyBuffer(key);
75
+ const decipher = createDecipheriv("aes-256-gcm", secret, envelope.iv);
75
76
  decipher.setAuthTag(envelope.tag);
76
77
  return Buffer.concat([decipher.update(envelope.ciphertext), decipher.final()]).toString("utf8");
77
78
  }