@classytic/arc 2.32.0 → 2.34.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 (193) hide show
  1. package/README.md +1 -1
  2. package/bin/arc.js +58 -17
  3. package/dist/{BaseController-Dk34dxhI.mjs → BaseController-CbkuhNI2.mjs} +329 -35
  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-CMMLWmuZ.mjs} +6 -2
  16. package/dist/{types-B7XeLEgK.d.mts → base-Csjc2oF-.d.mts} +74 -74
  17. package/dist/{buildHandler-Dn7rzUJo.mjs → buildHandler-BrdYrG_1.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 +3 -3
  33. package/dist/core/index.mjs +9 -8
  34. package/dist/{core-BwwmNSp7.mjs → core-BYKa8wHu.mjs} +3 -3
  35. package/dist/{createActionRouter-DkJZWo0m.mjs → createActionRouter-D6T0jWhS.mjs} +8 -8
  36. package/dist/{createAggregationRouter-B1exOVD8.mjs → createAggregationRouter-QLV2PrWP.mjs} +7 -7
  37. package/dist/{createApp-Bv0m-Q6_.mjs → createApp-zR-DEesr.mjs} +128 -24
  38. package/dist/{defineAggregation-gX4No1gt.mjs → defineAggregation-Nv-DwfOf.mjs} +1 -1
  39. package/dist/{defineResource-XFPPUu2c.mjs → defineResource-ZhuLonLm.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 +90 -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-ZWhLTp_s.d.mts → index-C5NnMEKF.d.mts} +1 -1
  71. package/dist/{index-DXlHwvuE.d.mts → index-CFAJEWCX.d.mts} +18 -5
  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 +13 -12
  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-DX3PHD7K.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-CkV5mQ53.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-C1Bs_FXF.d.mts +382 -0
  110. package/dist/outbox-module-D1QfHqO4.mjs +846 -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-CP47jdi7.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-BAPijK_A.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-G1TC6Yzc.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-Ba8v0PCa.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-BANpz6BX.mjs +78 -0
  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
  193. package/dist/repositoryFilter-DotlxG3L.mjs +0 -45
@@ -1,18 +1,436 @@
1
1
 
2
- import { a as QueryCacheConfig } from "./QueryCache-DFA-QrU9.mjs";
3
2
  import { P as ArcHeaders, i as RequestScope } from "./types-ChuShpCP.mjs";
4
- import { a as UserBase, l as AnyRecord, r as PermissionCheck } from "./types-B7XeLEgK.mjs";
3
+ import { d as UserBase, l as PermissionCheck, t as AnyRecord } from "./base-Csjc2oF-.mjs";
4
+ import { a as QueryCacheConfig } from "./QueryCache-DFA-QrU9.mjs";
5
5
  import "./elevation-BXCfVcNG.mjs";
6
6
  import { n as FieldPermissionMap } from "./fields-I_EeRWND.mjs";
7
7
  import { n as EncryptionDirective } from "./types-ByHn9rEO.mjs";
8
- import { n as DomainEvent } from "./EventTransport-2uVV34WU.mjs";
8
+ import { n as DomainEvent } from "./EventTransport-BEVP50ka.mjs";
9
9
  import { Filter } from "@classytic/repo-core/filter";
10
10
  import { KeysetPaginationResult, OffsetPaginationResult } from "@classytic/repo-core/pagination";
11
+ import { AggDateBucket, AggMeasure, AggTopN, QueryOptions, StandardRepo, TenantPurgeStrategy, TransactionHandle } from "@classytic/repo-core/repository";
11
12
  import { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest, RouteHandlerMethod, RouteHandlerMethod as RouteHandlerMethod$1 } from "fastify";
12
13
  import { DataAdapter, RepositoryLike } from "@classytic/repo-core/adapter";
13
- import { AggDateBucket, AggMeasure, AggTopN, QueryOptions, StandardRepo, TenantPurgeStrategy } from "@classytic/repo-core/repository";
14
14
  import { LookupSpec } from "@classytic/repo-core/lookup";
15
15
  import { FieldRule, SchemaBuilderOptions } from "@classytic/repo-core/schema";
16
+ //#region src/types/handlers.d.ts
17
+ /**
18
+ * Minimal server accessor — exposes safe, read-only server decorators.
19
+ * Allows controller handlers to publish events, log, and audit
20
+ * without switching to `rawHandler`.
21
+ */
22
+ interface ServerAccessor {
23
+ /** Event bus — publish domain events from any handler */
24
+ events?: {
25
+ publish: <T>(type: string, payload: T, meta?: Partial<Record<string, unknown>>) => Promise<void>;
26
+ };
27
+ /** Audit logger — log custom audit entries */
28
+ audit?: {
29
+ create: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
30
+ update: (resource: string, documentId: string, before: Record<string, unknown>, after: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
31
+ delete: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
32
+ custom: (resource: string, documentId: string, action: string, data?: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
33
+ };
34
+ /** Logger — structured logging */
35
+ log?: {
36
+ info: (...args: unknown[]) => void;
37
+ warn: (...args: unknown[]) => void;
38
+ error: (...args: unknown[]) => void;
39
+ debug: (...args: unknown[]) => void;
40
+ };
41
+ /** QueryCache — stale-while-revalidate data cache */
42
+ queryCache?: {
43
+ get: <T>(key: string) => Promise<{
44
+ data: T;
45
+ status: "fresh" | "stale" | "miss";
46
+ }>;
47
+ set: <T>(key: string, data: T, config: {
48
+ staleTime?: number;
49
+ gcTime?: number;
50
+ tags?: string[];
51
+ }) => Promise<void>;
52
+ getResourceVersion: (resource: string) => Promise<number>;
53
+ bumpResourceVersion: (resource: string) => Promise<void>;
54
+ };
55
+ }
56
+ /**
57
+ * Request context passed to controller handlers.
58
+ *
59
+ * **Generic parameters** (all default to safe permissive types so existing code keeps working):
60
+ * - `TBody` — request body shape (default: `unknown`)
61
+ * - `TParams` — route param shape (default: `Record<string, string>`)
62
+ * - `TQuery` — query string shape (default: `Record<string, unknown>`)
63
+ * - `TUser` — authenticated user shape (default: `UserBase`)
64
+ * - `TMetadata` — internal metadata shape (default: `Record<string, unknown>`;
65
+ * override with `ArcInternalMetadata` or your own augmentation when you
66
+ * need typed access to `_scope`, `_policyFilters`, custom hook context, etc.)
67
+ *
68
+ * @example
69
+ * ```typescript
70
+ * // Untyped (default) — req.body is `unknown`, must be narrowed
71
+ * async create(req: IRequestContext) {
72
+ * const data = req.body as Partial<Product>;
73
+ * return { success: true, data: await productRepo.create(data) };
74
+ * }
75
+ *
76
+ * // Typed body — req.body is `CreateProductInput`, narrowing not needed
77
+ * async create(req: IRequestContext<CreateProductInput>) {
78
+ * return { success: true, data: await productRepo.create(req.body) };
79
+ * }
80
+ *
81
+ * // Fully typed — body, route params, query, and metadata
82
+ * async update(
83
+ * req: IRequestContext<
84
+ * Partial<Product>,
85
+ * { id: string },
86
+ * { fields?: string },
87
+ * ArcInternalMetadata
88
+ * >,
89
+ * ) {
90
+ * const fields = req.query.fields?.split(',');
91
+ * const orgId = req.metadata?._scope ? getOrgId(req.metadata._scope) : undefined;
92
+ * return { success: true, data: await productRepo.update(req.params.id, req.body) };
93
+ * }
94
+ * ```
95
+ */
96
+ /**
97
+ * First-class projection of `request._scope` for controller handlers.
98
+ *
99
+ * Lifts the two fields most hosts need directly onto `req`, so a controller
100
+ * override never digs through `req.metadata._scope` and calls
101
+ * `getOrgId(scope)` / `getUserId(user)` by hand:
102
+ *
103
+ * ```ts
104
+ * async create(req: IRequestContext) {
105
+ * const flowCtx = { organizationId: req.scope?.organizationId, actorId: req.scope?.userId };
106
+ * }
107
+ * ```
108
+ *
109
+ * Full scope shape (discriminated union of `member` / `service` / `elevated` / `public`)
110
+ * still lives on `req.metadata._scope` for code that needs to branch on
111
+ * `scope.kind` — this projection just surfaces the two keys every
112
+ * tenant-scoped resource reaches for.
113
+ */
114
+ interface RequestScopeProjection$1 {
115
+ /** Tenant the caller is scoped to (org member, service key bound to an org, or elevated admin's target org). */
116
+ organizationId?: string;
117
+ /** Caller's user id when authenticated — undefined for public / service-only scopes. */
118
+ userId?: string;
119
+ /** Org-level roles (e.g. `['admin', 'warehouse-manager']`) — separate from global `user.roles`. */
120
+ orgRoles?: string[];
121
+ }
122
+ interface IRequestContext<TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>, TUser extends UserBase = UserBase, TMetadata extends Record<string, unknown> = Record<string, unknown>> {
123
+ /** Route parameters (e.g., { id: '123' }) */
124
+ params: TParams;
125
+ /** Query string parameters */
126
+ query: TQuery;
127
+ /** Request body */
128
+ body: TBody;
129
+ /** Authenticated user or null */
130
+ user: TUser | null;
131
+ /**
132
+ * Request headers — `ArcHeaders` (`string | string[] | undefined` per
133
+ * name), the HONEST type: on the HTTP path this is Fastify's headers
134
+ * object passed through, and repeated headers arrive as `string[]` at
135
+ * runtime (2.24 widening — the previous `Record<string, string |
136
+ * undefined>` lied about that). Read via `getHeader(ctx.headers, name)`
137
+ * from `@classytic/arc/utils` for array-safe, duplicate-deterministic
138
+ * access, or `requireSingleHeaderValue` for security-sensitive headers.
139
+ */
140
+ headers: ArcHeaders;
141
+ /** Organization ID (for multi-tenant apps) */
142
+ organizationId?: string;
143
+ /** Team ID (for team-scoped resources) */
144
+ teamId?: string;
145
+ /**
146
+ * First-class tenant/user scope projection — lifted from `metadata._scope`
147
+ * so controller overrides don't have to dig through Arc internals.
148
+ * See {@link RequestScopeProjection}. `undefined` for routes that run
149
+ * without auth / scope resolution.
150
+ */
151
+ scope?: RequestScopeProjection$1;
152
+ /**
153
+ * Organization/auth context from middleware.
154
+ * Contains orgRoles, orgScope, organizationId, and any custom fields
155
+ * set by the auth adapter or org-scope plugin.
156
+ *
157
+ * @example
158
+ * ```typescript
159
+ * async create(req: IRequestContext) {
160
+ * const roles = req.context?.orgRoles ?? [];
161
+ * if (roles.includes('manager')) { ... }
162
+ * }
163
+ * ```
164
+ */
165
+ context?: RequestContext;
166
+ /**
167
+ * Internal metadata (includes context + Arc internals like `_policyFilters`,
168
+ * `_scope`, `log`). Type as `ArcInternalMetadata` for typed access to Arc's
169
+ * built-in fields, or supply your own interface to layer custom fields.
170
+ */
171
+ metadata?: TMetadata;
172
+ /**
173
+ * Fastify server accessor — publish events, log, and audit
174
+ * from any handler without switching to `rawHandler`.
175
+ *
176
+ * @example
177
+ * ```typescript
178
+ * async reschedule(req: IRequestContext) {
179
+ * const result = await repo.reschedule(req.params.id, req.body);
180
+ * await req.server?.events?.publish('interview.rescheduled', { data: result });
181
+ * return { data: result };
182
+ * }
183
+ * ```
184
+ */
185
+ server?: ServerAccessor;
186
+ }
187
+ /**
188
+ * Controller response shape — the success-path return from any handler.
189
+ *
190
+ * Errors throw `ArcError` (or any `HttpError`-shaped class); the global
191
+ * error handler catches them and emits an `ErrorContract`. There is no
192
+ * `success` discriminator on the response — HTTP status is the wire
193
+ * discriminator (2xx = data, 4xx/5xx = ErrorContract).
194
+ */
195
+ interface IControllerResponse<T = unknown> {
196
+ /** Response payload — emitted directly to the wire (no envelope wrap). */
197
+ data: T;
198
+ /** HTTP status code. Defaults to 200. */
199
+ status?: number;
200
+ /** Custom response headers (e.g. X-Total-Count, Link, ETag). */
201
+ headers?: Record<string, string>;
202
+ /** Top-level metadata merged into list-shaped responses (e.g. `{ took }`). */
203
+ meta?: Record<string, unknown>;
204
+ }
205
+ /**
206
+ * Controller handler — Arc's standard pattern.
207
+ *
208
+ * Receives a request context object, returns IControllerResponse.
209
+ * Use with `handler` in routes.
210
+ *
211
+ * **Generic parameters:**
212
+ * - `TResponse` — shape of `IControllerResponse.data` (default: `unknown`)
213
+ * - `TBody` — shape of `req.body` (default: `unknown`)
214
+ * - `TParams` — shape of `req.params` (default: `Record<string, string>`)
215
+ * - `TQuery` — shape of `req.query` (default: `Record<string, unknown>`)
216
+ *
217
+ * Backward-compatible: `ControllerHandler<Product>` still works (only the
218
+ * response data is typed); add more generics as needed when you want
219
+ * type-safe access to the request body, params, or query string.
220
+ *
221
+ * @example
222
+ * ```typescript
223
+ * // Untyped req — body is unknown, must be narrowed
224
+ * const createProduct: ControllerHandler<Product> = async (req) => {
225
+ * const product = await productRepo.create(req.body as Partial<Product>);
226
+ * return { data: product, status: 201 };
227
+ * };
228
+ *
229
+ * // Fully typed — body, params, query, and response all inferred
230
+ * const updateProduct: ControllerHandler<
231
+ * Product,
232
+ * Partial<Product>,
233
+ * { id: string },
234
+ * { upsert?: string }
235
+ * > = async (req) => {
236
+ * const upsert = req.query.upsert === "true";
237
+ * const product = await productRepo.update(req.params.id, req.body, { upsert });
238
+ * return { data: product };
239
+ * };
240
+ *
241
+ * routes: [{
242
+ * method: 'POST',
243
+ * path: '/products',
244
+ * handler: createProduct,
245
+ * permissions: requireAuth(),
246
+ * }]
247
+ * ```
248
+ */
249
+ type ControllerHandler<TResponse = unknown, TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>> = (req: IRequestContext<TBody, TParams, TQuery>) => Promise<IControllerResponse<TResponse>>;
250
+ /**
251
+ * Fastify native handler
252
+ *
253
+ * Standard Fastify request/reply pattern.
254
+ * Use with `rawHandler` in routes.
255
+ *
256
+ * @example
257
+ * ```typescript
258
+ * const downloadFile: FastifyHandler = async (request, reply) => {
259
+ * const file = await getFile(request.params.id);
260
+ * reply.header('Content-Type', file.mimeType);
261
+ * return reply.send(file.buffer);
262
+ * };
263
+ *
264
+ * routes: [{
265
+ * method: 'GET',
266
+ * path: '/files/:id/download',
267
+ * rawHandler: downloadFile,
268
+ * permissions: requireAuth(),
269
+ * }]
270
+ * ```
271
+ */
272
+ type RawRouteHandler = {
273
+ /**
274
+ * Fastify-native route handler — the type behind `RouteDefinition.rawHandler`.
275
+ *
276
+ * Declared with METHOD syntax and read back through an indexed access, which
277
+ * buys two properties a plain arrow alias can't have at once:
278
+ *
279
+ * - **Bivariant parameters.** `strictFunctionTypes` exempts method
280
+ * declarations, so a handler annotated `FastifyRequest<{ Body: CreateBody }>`
281
+ * assigns without a cast. Fastify's own `RouteHandlerMethod` is an arrow
282
+ * alias, so it rejects those — hosts used to write `as any`.
283
+ * - **One call signature.** TypeScript contextually types parameters only
284
+ * when a single constituent is callable, so inline
285
+ * `rawHandler: async (request, reply) => …` infers instead of reporting
286
+ * TS7006.
287
+ *
288
+ * The runtime always passes the real `(request, reply)` pair.
289
+ */
290
+ handler(this: FastifyInstance, request: FastifyRequest, reply: FastifyReply): unknown;
291
+ }["handler"];
292
+ type FastifyHandler<RouteGeneric extends Record<string, unknown> = Record<string, unknown>> = (request: FastifyRequest<RouteGeneric>, reply: FastifyReply) => Promise<unknown> | unknown;
293
+ /**
294
+ * Union type for route handlers
295
+ */
296
+ type RouteHandler = ControllerHandler | FastifyHandler;
297
+ /**
298
+ * Controller interface for CRUD operations (strict).
299
+ *
300
+ * `list`'s return type aligns with repo-core's `MinimalRepo.getAll()`
301
+ * contract — the kit MAY return an offset envelope, a keyset envelope,
302
+ * or a raw array. Arc's `BaseController` forwards the kit's response
303
+ * verbatim; consumers narrow on shape
304
+ * (`Array.isArray(data)` → bare array, presence of `total` → offset,
305
+ * presence of `nextCursor` → keyset).
306
+ */
307
+ interface IController<TDoc = unknown> {
308
+ list(req: IRequestContext): Promise<IControllerResponse<unknown>>;
309
+ get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
310
+ create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
311
+ update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
312
+ delete(req: IRequestContext): Promise<IControllerResponse<{
313
+ message: string;
314
+ }>>;
315
+ }
316
+ /**
317
+ * Flexible controller interface — accepts controllers with any handler style,
318
+ * including class instances with extra methods / private fields.
319
+ *
320
+ * Deliberately NO index signature: one would make real class instances fail
321
+ * structural assignment (`new ScrapController()` would need an
322
+ * `as unknown as ControllerLike` cast, because class instances don't have
323
+ * one). Arc only invokes the CRUD methods at runtime, so the rest of the
324
+ * shape is the caller's concern.
325
+ *
326
+ * The five CRUD slots stay optional so partial controllers (e.g. read-only)
327
+ * assign too. Additional domain methods on the controller are allowed by
328
+ * construction (they're just not part of this contract).
329
+ */
330
+ interface ControllerLike {
331
+ list?: unknown;
332
+ get?: unknown;
333
+ create?: unknown;
334
+ update?: unknown;
335
+ delete?: unknown;
336
+ }
337
+ //#endregion
338
+ //#region src/types/resource/writes.d.ts
339
+ /**
340
+ * What every write verb receives besides its data.
341
+ *
342
+ * `create` gets exactly this shape; `update` / `delete` get the
343
+ * {@link MutationWriteContext} extension, which adds the loaded target.
344
+ */
345
+ interface WriteContext<TDoc = AnyRecord> {
346
+ /**
347
+ * The arc request context. Resolve identity from `ctx.req.scope` via
348
+ * `getUserId` / `getOrgId` — never from headers, and never from `req.user`.
349
+ */
350
+ req: IRequestContext;
351
+ /**
352
+ * The repository arc would have called. Passed so a verb can COMPOSE with
353
+ * the default rather than replace it wholesale (validate, then delegate).
354
+ * Typed as the repo-core contract; a verb owning a richer kit repository
355
+ * usually closes over it directly instead of downcasting this one.
356
+ */
357
+ repository: RepositoryLike<TDoc>;
358
+ /**
359
+ * Present ONLY under `transactional: true`: repo-core's canonical
360
+ * `TransactionHandle` for the transaction this write runs in — imported,
361
+ * never restated, so a field added to the contract reaches write verbs
362
+ * instead of being silently dropped at this boundary.
363
+ * `uow.session` is the raw driver handle — the join point for work OUTSIDE
364
+ * the repository, canonically an outbox row:
365
+ * `outbox.store(event, { session: ctx.uow.session })` commits the event
366
+ * atomically with the business write. Connection-bound backends (SQLite)
367
+ * provide an empty handle; their tx-bound `ctx.repository` is the only
368
+ * join point.
369
+ */
370
+ uow?: TransactionHandle;
371
+ }
372
+ /**
373
+ * Context for `update` and `delete` verbs — operations with a mutable target.
374
+ *
375
+ * Both fields are REQUIRED, not optional: arc's `loadMutableTarget` preflight
376
+ * (access control, tenant scope, ownership) THROWS before the verb when the
377
+ * target cannot be produced, so by the time a verb runs the target exists.
378
+ * Typing them optional would push a defensive null-check into every command
379
+ * for a case that cannot occur.
380
+ */
381
+ interface MutationWriteContext<TDoc = AnyRecord> extends WriteContext<TDoc> {
382
+ /**
383
+ * The REPOSITORY PRIMARY KEY — identical to the verb's own `id` argument.
384
+ *
385
+ * This is what `repository.update(id, …)` / `.delete(id, …)` would have
386
+ * received, which repo-core types as "update by primary key". It is NOT
387
+ * necessarily the value in the URL: a resource declaring `idField: 'slug'`
388
+ * routes on the slug while its repository keys off `_id`, and arc translates
389
+ * between them (`resolveMutationRepoId`) before either the repository or a
390
+ * verb is called. Handing a verb the raw route param would give a domain
391
+ * command a value its own repository cannot resolve.
392
+ *
393
+ * The route param is still reachable — `ctx.req.params.id`.
394
+ */
395
+ id: string;
396
+ /**
397
+ * The document as arc loaded it to run its permission and tenant checks.
398
+ * Handed over rather than re-fetched by the verb on purpose: a second read
399
+ * can observe a different document than the one the request was authorised
400
+ * against.
401
+ */
402
+ existing: TDoc;
403
+ }
404
+ /**
405
+ * Per-slot write commands. Every field is optional and independent: declare
406
+ * `update` alone and `create` / `delete` keep calling the repository.
407
+ *
408
+ * **Return contract, and it differs from the repository's on purpose.**
409
+ * A repository signals "not found" by returning `null`; a domain command
410
+ * signals failure by THROWING a typed error. So when a verb is declared:
411
+ *
412
+ * - `create` — must return the created document. A nullish return is a
413
+ * CONTRACT VIOLATION and arc throws (a 201 carrying `undefined` data and
414
+ * after-hooks fed a non-document would be worse than the loud failure).
415
+ * - `update` — returning the updated document is preferred (it becomes the
416
+ * response directly). A `void` return is also success — arc issues ONE
417
+ * repository re-read to answer the request, so a void-returning command
418
+ * costs one extra read per update. It is never translated into a 404.
419
+ * - `delete` — `undefined` is success. `deleteDraft()` returns `void`, and
420
+ * treating that as a miss would answer 404 for a delete that happened.
421
+ */
422
+ interface ResourceWrites<TDoc = AnyRecord> {
423
+ create?(data: Partial<TDoc>, ctx: WriteContext<TDoc>): Promise<TDoc>;
424
+ update?(id: string, data: Partial<TDoc>, ctx: MutationWriteContext<TDoc>): Promise<TDoc | null | undefined>;
425
+ delete?(id: string, ctx: MutationWriteContext<TDoc>): Promise<unknown>;
426
+ }
427
+ /**
428
+ * The three slots a write verb may be declared for. Kept in lockstep with the
429
+ * runtime list (`MUTATION_OPERATIONS` in `constants.ts`) by a `satisfies`
430
+ * check at its use sites — drift is a compile error, not a runtime surprise.
431
+ */
432
+ type WriteVerbKey = "create" | "update" | "delete";
433
+ //#endregion
16
434
  //#region src/core/BodySanitizer.d.ts
17
435
  /**
18
436
  * Policy for handling fields the caller lacks write permission for.
@@ -31,10 +449,25 @@ interface BodySanitizerConfig {
31
449
  * Default: `'reject'` — surface the misconfiguration as a 403.
32
450
  */
33
451
  onFieldWriteDenied?: FieldWriteDenialPolicy;
452
+ /**
453
+ * What to do when an update body carries an `immutable` /
454
+ * `immutableAfterCreate` field. Default: `'strip'` — today's behaviour.
455
+ *
456
+ * `'reject'` throws a 403 instead of returning 200 with the field silently
457
+ * unchanged (observed live: `PATCH {"type":"rent"}` → 200, `"type":"purchase"`).
458
+ *
459
+ * NOT folded into {@link onFieldWriteDenied}, which defaults to `'reject'`:
460
+ * a full-object PATCH echoing an immutable field back UNCHANGED is legitimate,
461
+ * and the sanitizer has no stored document to distinguish it from a real
462
+ * change. Sharing that default would break those callers. Opt in per resource,
463
+ * or fleet-wide via `ARC_STRICT_IMMUTABLE_WRITES`.
464
+ */
465
+ onImmutableWrite?: FieldWriteDenialPolicy;
34
466
  }
35
467
  declare class BodySanitizer {
36
468
  private schemaOptions;
37
469
  private onFieldWriteDenied;
470
+ private onImmutableWrite;
38
471
  constructor(config: BodySanitizerConfig);
39
472
  /**
40
473
  * Strip readonly and system-managed fields from request body.
@@ -182,6 +615,25 @@ interface ControllerConfigurableOptions {
182
615
  * - `'strip'`: legacy silent-drop.
183
616
  */
184
617
  onFieldWriteDenied?: FieldWriteDenialPolicy;
618
+ /** Immutable-write policy — see `onImmutableWrite` on the resource config. */
619
+ onImmutableWrite?: FieldWriteDenialPolicy;
620
+ /**
621
+ * Domain commands bound to the create / update / delete slots. When a slot
622
+ * declares one, the controller runs its full pipeline and calls the verb
623
+ * instead of `repository.<op>` — see `ResourceWrites`.
624
+ *
625
+ * Configurable rather than construction-only so a HOST-SUPPLIED controller
626
+ * receives it through `configure()` too. A custom controller that keeps
627
+ * `BaseCrudController`'s write methods is the common case, and it must not
628
+ * silently lose the resource's verbs just because it was hand-constructed.
629
+ */
630
+ writes?: ResourceWrites;
631
+ /**
632
+ * Transactional write envelope — see `ResourceConfig.transactional`.
633
+ * Configurable so a host-supplied controller receives it through
634
+ * `configure()` like `writes`.
635
+ */
636
+ transactional?: boolean;
185
637
  }
186
638
  /**
187
639
  * Full constructor options — union of construction-only + configurable.
@@ -197,13 +649,17 @@ interface BaseControllerOptions extends ControllerConstructionOptions, Controlle
197
649
  * still available through the underlying `RequestScope` for advanced
198
650
  * branching on `scope.kind`.
199
651
  */
200
- interface RequestScopeProjection$1 {
652
+ interface RequestScopeProjection {
201
653
  /** Tenant the caller is scoped to (member, pinned elevated admin, service key bound to an org). */
202
654
  organizationId?: string;
203
655
  /** Caller's user id when authenticated — undefined for public / service-only scopes. */
204
656
  userId?: string;
205
657
  /** Org-level roles (e.g. `['admin', 'warehouse-manager']`) — separate from global `user.roles`. */
206
658
  orgRoles?: string[];
659
+ /** Active team for member scopes. */
660
+ teamId?: string;
661
+ /** Host-defined project / branch / region / workspace dimensions. */
662
+ context?: Readonly<Record<string, string>>;
207
663
  }
208
664
  //#endregion
209
665
  //#region src/hooks/HookSystem.d.ts
@@ -229,7 +685,7 @@ interface HookContext<T = AnyRecord> {
229
685
  * routes. Branch on `scope.kind` via `ctx.context._scope` when you need the
230
686
  * full discriminated union.
231
687
  */
232
- scope?: RequestScopeProjection$1;
688
+ scope?: RequestScopeProjection;
233
689
  }
234
690
  type HookHandler<T = AnyRecord> = (ctx: HookContext<T>) => void | Promise<void> | T | Promise<T>;
235
691
  /**
@@ -588,9 +1044,12 @@ interface QueryResolverConfig {
588
1044
  declare class QueryResolver {
589
1045
  private queryParser;
590
1046
  private maxLimit;
1047
+ /** Set only when the HOST passed one — it outranks any parser's cap, at any time. */
1048
+ private readonly explicitMaxLimit;
591
1049
  private defaultLimit;
592
1050
  /** `undefined` means "no default sort" (caller passed `false`). */
593
1051
  private defaultSort;
1052
+ private readonly explicitDefaultSort;
594
1053
  private schemaOptions;
595
1054
  private tenantField;
596
1055
  constructor(config?: QueryResolverConfig);
@@ -601,6 +1060,22 @@ declare class QueryResolver {
601
1060
  * truth — pairs with `BaseCrudController.setQueryParser()`.
602
1061
  */
603
1062
  setParser(parser: QueryParserInterface): void;
1063
+ /**
1064
+ * The default sort, but only when the parser permits the field.
1065
+ *
1066
+ * A resource declaring `allowedSortFields` has stated exactly what may be
1067
+ * sorted on. Arc then applied its OWN default of `-createdAt` on top, and
1068
+ * mongokit 3.31 REJECTS a sort outside the allowlist rather than dropping it —
1069
+ * so every list call on such a resource answered 400, `Blocked sort field not
1070
+ * in allowlist: createdAt`. REST and MCP alike; the endpoint was unusable, and
1071
+ * nothing the caller sent could avoid it.
1072
+ *
1073
+ * A general default must never outrank a specific declaration, so the
1074
+ * FRAMEWORK default withdraws. A HOST-declared `defaultSort` is left alone —
1075
+ * that contradiction is its author's to resolve, and `defineResource` reports
1076
+ * it as a boot diagnostic instead of arc silently picking a winner.
1077
+ */
1078
+ private permittedDefaultSort;
604
1079
  /**
605
1080
  * Resolve a request into parsed query options -- ONE parse per request.
606
1081
  * Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
@@ -690,6 +1165,16 @@ declare class BaseCrudController<TDoc = AnyRecord, TRepository extends Repositor
690
1165
  */
691
1166
  protected _defaultSort?: string | false;
692
1167
  protected _onFieldWriteDenied?: FieldWriteDenialPolicy;
1168
+ protected _onImmutableWrite?: FieldWriteDenialPolicy;
1169
+ /**
1170
+ * Domain commands bound to the write slots (`ResourceWrites`). Consulted at
1171
+ * the PERSISTENCE step only — everything upstream of it (sanitizer, tenant
1172
+ * injection, actor stamp, hook sandwich) runs exactly as it does without
1173
+ * one, which is the entire point of the seam.
1174
+ */
1175
+ protected _writes?: ResourceWrites;
1176
+ /** Transactional write envelope — see `ResourceConfig.transactional`. */
1177
+ protected _transactional: boolean;
693
1178
  constructor(repository: TRepository, options?: BaseControllerOptions);
694
1179
  /**
695
1180
  * Swap the controller's query parser. Mutates the existing `QueryResolver`
@@ -823,6 +1308,35 @@ declare class BaseCrudController<TDoc = AnyRecord, TRepository extends Repositor
823
1308
  meta?: Record<string, unknown>;
824
1309
  pipeProcessedData?: boolean;
825
1310
  }, executor: (processed: TInput) => Promise<TResult>): Promise<TResult>;
1311
+ /**
1312
+ * Run a write's PERSISTENCE step, transactionally when the resource asked.
1313
+ *
1314
+ * Plain mode hands `fn` the live repository. Transactional mode runs it
1315
+ * through repo-core's `retryingTransaction`: the callback receives the
1316
+ * TX-BOUND repository (so declared verbs and repository calls join the same
1317
+ * transaction), transient conflicts re-run `fn` with jittered bounded
1318
+ * backoff, everything else — including `VersionConflictError` — surfaces
1319
+ * once. Hooks stay OUTSIDE: before/around ran already and must not re-run;
1320
+ * after-hooks run post-commit, which they already do by running after this.
1321
+ */
1322
+ protected runWritePersistence<T>(fn: (repo: TRepository, uow?: TransactionHandle) => Promise<T>): Promise<T>;
1323
+ /** Assemble the `WriteContext` a `create` verb receives. */
1324
+ protected writeContext(req: IRequestContext, repo?: TRepository, uow?: TransactionHandle): WriteContext;
1325
+ /**
1326
+ * Assemble the `MutationWriteContext` an `update` / `delete` verb receives.
1327
+ *
1328
+ * `existing` is the document arc ALREADY loaded to run its permission and
1329
+ * tenant checks — `loadMutableTarget` throws before any verb runs when it
1330
+ * cannot produce one, which is why the context types it required. Handing
1331
+ * it over rather than letting the verb re-fetch is deliberate: a second
1332
+ * read can observe a different document than the one the request was
1333
+ * authorised against.
1334
+ *
1335
+ * `id` is the REPOSITORY PRIMARY KEY (`repoId`), never the raw route param —
1336
+ * see the note on `MutationWriteContext.id`. Callers pass `resolveRepoId`'s
1337
+ * output.
1338
+ */
1339
+ protected mutationWriteContext(req: IRequestContext, id: string, existing: unknown, repo?: TRepository, uow?: TransactionHandle): MutationWriteContext;
826
1340
  /**
827
1341
  * Run `executeAfter` for the given op + data. No-op when hooks aren't
828
1342
  * wired or `resourceName` isn't set. Caller passes the data shape it
@@ -1186,396 +1700,74 @@ interface AuthPluginOptions {
1186
1700
  /**
1187
1701
  * Custom auth failure handler. Customize the 401 response when
1188
1702
  * authentication fails.
1189
- */
1190
- onFailure?: (request: FastifyRequest, reply: FastifyReply, error?: Error) => void | Promise<void>;
1191
- /**
1192
- * Expose detailed auth error messages in 401 responses. When `false`
1193
- * (default), returns generic "Authentication required". Decoupled from
1194
- * log level — set explicitly per environment.
1195
- */
1196
- exposeAuthErrors?: boolean;
1197
- /** Property name to store user on request (default: 'user') */
1198
- userProperty?: string;
1199
- /**
1200
- * Custom token extractor for the built-in JWT auth path. Defaults to
1201
- * extracting Bearer token from Authorization header. Use when tokens
1202
- * are in HttpOnly cookies, custom headers, or query params.
1203
- *
1204
- * @example
1205
- * ```typescript
1206
- * tokenExtractor: (request) => request.cookies?.['auth-token'] ?? null,
1207
- * ```
1208
- */
1209
- tokenExtractor?: (request: FastifyRequest) => string | null;
1210
- /**
1211
- * Token revocation check — called after JWT verification succeeds.
1212
- * Return `true` to reject the token (revoked), `false` to allow.
1213
- *
1214
- * **Fail-closed**: if the check throws, the token is treated as revoked.
1215
- *
1216
- * @example
1217
- * ```typescript
1218
- * isRevoked: async (decoded) => {
1219
- * return await redis.sismember('revoked-tokens', decoded.jti ?? decoded.id);
1220
- * },
1221
- * ```
1222
- */
1223
- isRevoked?: (decoded: Record<string, unknown>) => boolean | Promise<boolean>;
1224
- /**
1225
- * Enforce strict JWT `type` claim validation (default: `true`).
1226
- *
1227
- * When enabled, `authenticate` requires `decoded.type === "access"`.
1228
- * Tokens with a missing or unexpected `type` claim are rejected —
1229
- * defence in depth for apps that reuse the JWT secret to sign other
1230
- * token kinds (invite links, one-time verification codes).
1231
- *
1232
- * Arc's own `issueTokens` always sets `type: "access"` or
1233
- * `type: "refresh"`, so this default is safe for Arc-generated tokens.
1234
- *
1235
- * Set to `false` ONLY when you must accept tokens signed without a
1236
- * `type` claim (e.g. a legacy issuer you don't control). In that mode
1237
- * Arc still rejects tokens explicitly marked `type: "refresh"`.
1238
- */
1239
- strictTokenType?: boolean;
1240
- }
1241
- //#endregion
1242
- //#region src/core/middlewares/rateLimit.d.ts
1243
- /**
1244
- * Per-route CORS override, read by `@fastify/cors` off `routeOptions.config.cors`.
1245
- *
1246
- * The app-wide policy cannot serve both an API and public assets: an API wants
1247
- * `credentials: true` with a pinned origin list, while a public asset wants
1248
- * `origin: "*"` — and `*` with credentials is forbidden by the CORS spec (arc
1249
- * throws at boot on that combination). Per-route is the granularity that
1250
- * resolves it. `false` disables CORS for the route entirely.
1251
- *
1252
- * Arc does not interpret the value; `@fastify/cors` owns the semantics. Typed
1253
- * loosely on purpose so a host is never blocked by arc lagging the plugin's
1254
- * option surface.
1255
- */
1256
- type RouteCorsConfig = Record<string, unknown> | false;
1257
- //#endregion
1258
- //#region src/types/handlers.d.ts
1259
- /**
1260
- * Minimal server accessor — exposes safe, read-only server decorators.
1261
- * Allows controller handlers to publish events, log, and audit
1262
- * without switching to `rawHandler`.
1263
- */
1264
- interface ServerAccessor {
1265
- /** Event bus — publish domain events from any handler */
1266
- events?: {
1267
- publish: <T>(type: string, payload: T, meta?: Partial<Record<string, unknown>>) => Promise<void>;
1268
- };
1269
- /** Audit logger — log custom audit entries */
1270
- audit?: {
1271
- create: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
1272
- update: (resource: string, documentId: string, before: Record<string, unknown>, after: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
1273
- delete: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
1274
- custom: (resource: string, documentId: string, action: string, data?: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
1275
- };
1276
- /** Logger — structured logging */
1277
- log?: {
1278
- info: (...args: unknown[]) => void;
1279
- warn: (...args: unknown[]) => void;
1280
- error: (...args: unknown[]) => void;
1281
- debug: (...args: unknown[]) => void;
1282
- };
1283
- /** QueryCache — stale-while-revalidate data cache */
1284
- queryCache?: {
1285
- get: <T>(key: string) => Promise<{
1286
- data: T;
1287
- status: "fresh" | "stale" | "miss";
1288
- }>;
1289
- set: <T>(key: string, data: T, config: {
1290
- staleTime?: number;
1291
- gcTime?: number;
1292
- tags?: string[];
1293
- }) => Promise<void>;
1294
- getResourceVersion: (resource: string) => Promise<number>;
1295
- bumpResourceVersion: (resource: string) => Promise<void>;
1296
- };
1297
- }
1298
- /**
1299
- * Request context passed to controller handlers.
1300
- *
1301
- * **Generic parameters** (all default to safe permissive types so existing code keeps working):
1302
- * - `TBody` — request body shape (default: `unknown`)
1303
- * - `TParams` — route param shape (default: `Record<string, string>`)
1304
- * - `TQuery` — query string shape (default: `Record<string, unknown>`)
1305
- * - `TUser` — authenticated user shape (default: `UserBase`)
1306
- * - `TMetadata` — internal metadata shape (default: `Record<string, unknown>`;
1307
- * override with `ArcInternalMetadata` or your own augmentation when you
1308
- * need typed access to `_scope`, `_policyFilters`, custom hook context, etc.)
1309
- *
1310
- * @example
1311
- * ```typescript
1312
- * // Untyped (default) — req.body is `unknown`, must be narrowed
1313
- * async create(req: IRequestContext) {
1314
- * const data = req.body as Partial<Product>;
1315
- * return { success: true, data: await productRepo.create(data) };
1316
- * }
1317
- *
1318
- * // Typed body — req.body is `CreateProductInput`, narrowing not needed
1319
- * async create(req: IRequestContext<CreateProductInput>) {
1320
- * return { success: true, data: await productRepo.create(req.body) };
1321
- * }
1322
- *
1323
- * // Fully typed — body, route params, query, and metadata
1324
- * async update(
1325
- * req: IRequestContext<
1326
- * Partial<Product>,
1327
- * { id: string },
1328
- * { fields?: string },
1329
- * ArcInternalMetadata
1330
- * >,
1331
- * ) {
1332
- * const fields = req.query.fields?.split(',');
1333
- * const orgId = req.metadata?._scope ? getOrgId(req.metadata._scope) : undefined;
1334
- * return { success: true, data: await productRepo.update(req.params.id, req.body) };
1335
- * }
1336
- * ```
1337
- */
1338
- /**
1339
- * First-class projection of `request._scope` for controller handlers.
1340
- *
1341
- * Lifts the two fields most hosts need directly onto `req`, so a controller
1342
- * override never digs through `req.metadata._scope` and calls
1343
- * `getOrgId(scope)` / `getUserId(user)` by hand:
1344
- *
1345
- * ```ts
1346
- * async create(req: IRequestContext) {
1347
- * const flowCtx = { organizationId: req.scope?.organizationId, actorId: req.scope?.userId };
1348
- * }
1349
- * ```
1350
- *
1351
- * Full scope shape (discriminated union of `member` / `service` / `elevated` / `public`)
1352
- * still lives on `req.metadata._scope` for code that needs to branch on
1353
- * `scope.kind` — this projection just surfaces the two keys every
1354
- * tenant-scoped resource reaches for.
1355
- */
1356
- interface RequestScopeProjection {
1357
- /** Tenant the caller is scoped to (org member, service key bound to an org, or elevated admin's target org). */
1358
- organizationId?: string;
1359
- /** Caller's user id when authenticated — undefined for public / service-only scopes. */
1360
- userId?: string;
1361
- /** Org-level roles (e.g. `['admin', 'warehouse-manager']`) — separate from global `user.roles`. */
1362
- orgRoles?: string[];
1363
- }
1364
- interface IRequestContext<TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>, TUser extends UserBase = UserBase, TMetadata extends Record<string, unknown> = Record<string, unknown>> {
1365
- /** Route parameters (e.g., { id: '123' }) */
1366
- params: TParams;
1367
- /** Query string parameters */
1368
- query: TQuery;
1369
- /** Request body */
1370
- body: TBody;
1371
- /** Authenticated user or null */
1372
- user: TUser | null;
1373
- /**
1374
- * Request headers — `ArcHeaders` (`string | string[] | undefined` per
1375
- * name), the HONEST type: on the HTTP path this is Fastify's headers
1376
- * object passed through, and repeated headers arrive as `string[]` at
1377
- * runtime (2.24 widening — the previous `Record<string, string |
1378
- * undefined>` lied about that). Read via `getHeader(ctx.headers, name)`
1379
- * from `@classytic/arc/utils` for array-safe, duplicate-deterministic
1380
- * access, or `requireSingleHeaderValue` for security-sensitive headers.
1381
- */
1382
- headers: ArcHeaders;
1383
- /** Organization ID (for multi-tenant apps) */
1384
- organizationId?: string;
1385
- /** Team ID (for team-scoped resources) */
1386
- teamId?: string;
1703
+ */
1704
+ onFailure?: (request: FastifyRequest, reply: FastifyReply, error?: Error) => void | Promise<void>;
1387
1705
  /**
1388
- * First-class tenant/user scope projection — lifted from `metadata._scope`
1389
- * so controller overrides don't have to dig through Arc internals.
1390
- * See {@link RequestScopeProjection}. `undefined` for routes that run
1391
- * without auth / scope resolution.
1706
+ * Expose detailed auth error messages in 401 responses. When `false`
1707
+ * (default), returns generic "Authentication required". Decoupled from
1708
+ * log level — set explicitly per environment.
1392
1709
  */
1393
- scope?: RequestScopeProjection;
1710
+ exposeAuthErrors?: boolean;
1711
+ /** Property name to store user on request (default: 'user') */
1712
+ userProperty?: string;
1394
1713
  /**
1395
- * Organization/auth context from middleware.
1396
- * Contains orgRoles, orgScope, organizationId, and any custom fields
1397
- * set by the auth adapter or org-scope plugin.
1714
+ * Custom token extractor for the built-in JWT auth path. Defaults to
1715
+ * extracting Bearer token from Authorization header. Use when tokens
1716
+ * are in HttpOnly cookies, custom headers, or query params.
1398
1717
  *
1399
1718
  * @example
1400
1719
  * ```typescript
1401
- * async create(req: IRequestContext) {
1402
- * const roles = req.context?.orgRoles ?? [];
1403
- * if (roles.includes('manager')) { ... }
1404
- * }
1720
+ * tokenExtractor: (request) => request.cookies?.['auth-token'] ?? null,
1405
1721
  * ```
1406
1722
  */
1407
- context?: RequestContext;
1408
- /**
1409
- * Internal metadata (includes context + Arc internals like `_policyFilters`,
1410
- * `_scope`, `log`). Type as `ArcInternalMetadata` for typed access to Arc's
1411
- * built-in fields, or supply your own interface to layer custom fields.
1412
- */
1413
- metadata?: TMetadata;
1723
+ tokenExtractor?: (request: FastifyRequest) => string | null;
1414
1724
  /**
1415
- * Fastify server accessor — publish events, log, and audit
1416
- * from any handler without switching to `rawHandler`.
1725
+ * Token revocation check — called after JWT verification succeeds.
1726
+ * Return `true` to reject the token (revoked), `false` to allow.
1727
+ *
1728
+ * **Fail-closed**: if the check throws, the token is treated as revoked.
1417
1729
  *
1418
1730
  * @example
1419
1731
  * ```typescript
1420
- * async reschedule(req: IRequestContext) {
1421
- * const result = await repo.reschedule(req.params.id, req.body);
1422
- * await req.server?.events?.publish('interview.rescheduled', { data: result });
1423
- * return { data: result };
1424
- * }
1732
+ * isRevoked: async (decoded) => {
1733
+ * return await redis.sismember('revoked-tokens', decoded.jti ?? decoded.id);
1734
+ * },
1425
1735
  * ```
1426
1736
  */
1427
- server?: ServerAccessor;
1428
- }
1429
- /**
1430
- * Controller response shape — the success-path return from any handler.
1431
- *
1432
- * Errors throw `ArcError` (or any `HttpError`-shaped class); the global
1433
- * error handler catches them and emits an `ErrorContract`. There is no
1434
- * `success` discriminator on the response — HTTP status is the wire
1435
- * discriminator (2xx = data, 4xx/5xx = ErrorContract).
1436
- */
1437
- interface IControllerResponse<T = unknown> {
1438
- /** Response payload — emitted directly to the wire (no envelope wrap). */
1439
- data: T;
1440
- /** HTTP status code. Defaults to 200. */
1441
- status?: number;
1442
- /** Custom response headers (e.g. X-Total-Count, Link, ETag). */
1443
- headers?: Record<string, string>;
1444
- /** Top-level metadata merged into list-shaped responses (e.g. `{ took }`). */
1445
- meta?: Record<string, unknown>;
1446
- }
1447
- /**
1448
- * Controller handler — Arc's standard pattern.
1449
- *
1450
- * Receives a request context object, returns IControllerResponse.
1451
- * Use with `handler` in routes.
1452
- *
1453
- * **Generic parameters:**
1454
- * - `TResponse` — shape of `IControllerResponse.data` (default: `unknown`)
1455
- * - `TBody` — shape of `req.body` (default: `unknown`)
1456
- * - `TParams` — shape of `req.params` (default: `Record<string, string>`)
1457
- * - `TQuery` — shape of `req.query` (default: `Record<string, unknown>`)
1458
- *
1459
- * Backward-compatible: `ControllerHandler<Product>` still works (only the
1460
- * response data is typed); add more generics as needed when you want
1461
- * type-safe access to the request body, params, or query string.
1462
- *
1463
- * @example
1464
- * ```typescript
1465
- * // Untyped req — body is unknown, must be narrowed
1466
- * const createProduct: ControllerHandler<Product> = async (req) => {
1467
- * const product = await productRepo.create(req.body as Partial<Product>);
1468
- * return { data: product, status: 201 };
1469
- * };
1470
- *
1471
- * // Fully typed — body, params, query, and response all inferred
1472
- * const updateProduct: ControllerHandler<
1473
- * Product,
1474
- * Partial<Product>,
1475
- * { id: string },
1476
- * { upsert?: string }
1477
- * > = async (req) => {
1478
- * const upsert = req.query.upsert === "true";
1479
- * const product = await productRepo.update(req.params.id, req.body, { upsert });
1480
- * return { data: product };
1481
- * };
1482
- *
1483
- * routes: [{
1484
- * method: 'POST',
1485
- * path: '/products',
1486
- * handler: createProduct,
1487
- * permissions: requireAuth(),
1488
- * }]
1489
- * ```
1490
- */
1491
- type ControllerHandler<TResponse = unknown, TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>> = (req: IRequestContext<TBody, TParams, TQuery>) => Promise<IControllerResponse<TResponse>>;
1492
- /**
1493
- * Fastify native handler
1494
- *
1495
- * Standard Fastify request/reply pattern.
1496
- * Use with `rawHandler` in routes.
1497
- *
1498
- * @example
1499
- * ```typescript
1500
- * const downloadFile: FastifyHandler = async (request, reply) => {
1501
- * const file = await getFile(request.params.id);
1502
- * reply.header('Content-Type', file.mimeType);
1503
- * return reply.send(file.buffer);
1504
- * };
1505
- *
1506
- * routes: [{
1507
- * method: 'GET',
1508
- * path: '/files/:id/download',
1509
- * rawHandler: downloadFile,
1510
- * permissions: requireAuth(),
1511
- * }]
1512
- * ```
1513
- */
1514
- type RawRouteHandler = {
1737
+ isRevoked?: (decoded: Record<string, unknown>) => boolean | Promise<boolean>;
1515
1738
  /**
1516
- * Fastify-native route handler — the type behind `RouteDefinition.rawHandler`.
1739
+ * Enforce strict JWT `type` claim validation (default: `true`).
1517
1740
  *
1518
- * Declared with METHOD syntax and read back through an indexed access, which
1519
- * buys two properties a plain arrow alias can't have at once:
1741
+ * When enabled, `authenticate` requires `decoded.type === "access"`.
1742
+ * Tokens with a missing or unexpected `type` claim are rejected —
1743
+ * defence in depth for apps that reuse the JWT secret to sign other
1744
+ * token kinds (invite links, one-time verification codes).
1520
1745
  *
1521
- * - **Bivariant parameters.** `strictFunctionTypes` exempts method
1522
- * declarations, so a handler annotated `FastifyRequest<{ Body: CreateBody }>`
1523
- * assigns without a cast. Fastify's own `RouteHandlerMethod` is an arrow
1524
- * alias, so it rejects those — hosts used to write `as any`.
1525
- * - **One call signature.** TypeScript contextually types parameters only
1526
- * when a single constituent is callable, so inline
1527
- * `rawHandler: async (request, reply) => …` infers instead of reporting
1528
- * TS7006.
1746
+ * Arc's own `issueTokens` always sets `type: "access"` or
1747
+ * `type: "refresh"`, so this default is safe for Arc-generated tokens.
1529
1748
  *
1530
- * The runtime always passes the real `(request, reply)` pair.
1749
+ * Set to `false` ONLY when you must accept tokens signed without a
1750
+ * `type` claim (e.g. a legacy issuer you don't control). In that mode
1751
+ * Arc still rejects tokens explicitly marked `type: "refresh"`.
1531
1752
  */
1532
- handler(this: FastifyInstance, request: FastifyRequest, reply: FastifyReply): unknown;
1533
- }["handler"];
1534
- type FastifyHandler<RouteGeneric extends Record<string, unknown> = Record<string, unknown>> = (request: FastifyRequest<RouteGeneric>, reply: FastifyReply) => Promise<unknown> | unknown;
1535
- /**
1536
- * Union type for route handlers
1537
- */
1538
- type RouteHandler = ControllerHandler | FastifyHandler;
1539
- /**
1540
- * Controller interface for CRUD operations (strict).
1541
- *
1542
- * `list`'s return type aligns with repo-core's `MinimalRepo.getAll()`
1543
- * contract — the kit MAY return an offset envelope, a keyset envelope,
1544
- * or a raw array. Arc's `BaseController` forwards the kit's response
1545
- * verbatim; consumers narrow on shape
1546
- * (`Array.isArray(data)` → bare array, presence of `total` → offset,
1547
- * presence of `nextCursor` → keyset).
1548
- */
1549
- interface IController<TDoc = unknown> {
1550
- list(req: IRequestContext): Promise<IControllerResponse<unknown>>;
1551
- get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1552
- create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1553
- update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1554
- delete(req: IRequestContext): Promise<IControllerResponse<{
1555
- message: string;
1556
- }>>;
1753
+ strictTokenType?: boolean;
1557
1754
  }
1755
+ //#endregion
1756
+ //#region src/core/middlewares/rateLimit.d.ts
1558
1757
  /**
1559
- * Flexible controller interface — accepts controllers with any handler style,
1560
- * including class instances with extra methods / private fields.
1758
+ * Per-route CORS override, read by `@fastify/cors` off `routeOptions.config.cors`.
1561
1759
  *
1562
- * Deliberately NO index signature: one would make real class instances fail
1563
- * structural assignment (`new ScrapController()` would need an
1564
- * `as unknown as ControllerLike` cast, because class instances don't have
1565
- * one). Arc only invokes the CRUD methods at runtime, so the rest of the
1566
- * shape is the caller's concern.
1760
+ * The app-wide policy cannot serve both an API and public assets: an API wants
1761
+ * `credentials: true` with a pinned origin list, while a public asset wants
1762
+ * `origin: "*"` — and `*` with credentials is forbidden by the CORS spec (arc
1763
+ * throws at boot on that combination). Per-route is the granularity that
1764
+ * resolves it. `false` disables CORS for the route entirely.
1567
1765
  *
1568
- * The five CRUD slots stay optional so partial controllers (e.g. read-only)
1569
- * assign too. Additional domain methods on the controller are allowed by
1570
- * construction (they're just not part of this contract).
1766
+ * Arc does not interpret the value; `@fastify/cors` owns the semantics. Typed
1767
+ * loosely on purpose so a host is never blocked by arc lagging the plugin's
1768
+ * option surface.
1571
1769
  */
1572
- interface ControllerLike {
1573
- list?: unknown;
1574
- get?: unknown;
1575
- create?: unknown;
1576
- update?: unknown;
1577
- delete?: unknown;
1578
- }
1770
+ type RouteCorsConfig = Record<string, unknown> | false;
1579
1771
  //#endregion
1580
1772
  //#region src/types/resource/rate-limit.d.ts
1581
1773
  /**
@@ -1672,6 +1864,41 @@ interface RouteDefinition {
1672
1864
  readonly controllerMethod?: (controller: unknown) => ControllerHandler;
1673
1865
  /** Permission check — REQUIRED */
1674
1866
  readonly permissions: PermissionCheck;
1867
+ /**
1868
+ * PUBLISH this route's gate in the permission matrix, under this key.
1869
+ *
1870
+ * ## Why a route needs to opt in, when CRUD and actions do not
1871
+ *
1872
+ * `introspectRegistry` walks `permissions`, `actions` and `aggregations` —
1873
+ * each of which has a stable NAME to key by. A route has only a method and a
1874
+ * path, and a path is not an identity: `/recompute` becoming
1875
+ * `/classification/recompute` would silently break every client gating on it.
1876
+ * So routes were skipped, and the consequence was not a missing feature but an
1877
+ * invisible one — a resource whose gates live entirely on `routes[]` publishes
1878
+ * NOTHING, is absent from the matrix, and every `can()` against it returns
1879
+ * `false`. A UI that gates on it hides the feature from everyone including
1880
+ * admins, which reads as "my permissions are broken" rather than "this key was
1881
+ * never published".
1882
+ *
1883
+ * Declaring `capability` gives the gate the stable name it was missing. The key
1884
+ * is BARE, sitting alongside `list` / `get` / `create` — a consumer asks
1885
+ * `can('sku-classification', 'recompute')` and does not need to know whether
1886
+ * the verb is implemented as a route, an action or a CRUD slot.
1887
+ *
1888
+ * Omit it for routes nothing needs to gate a UI on (a health check, a public
1889
+ * manifest). The route is still ENFORCED either way — this only controls
1890
+ * whether clients can SEE the decision in advance.
1891
+ *
1892
+ * Collides with a CRUD slot, an `action:`, an `agg:` or another route on the
1893
+ * same resource ⇒ `defineResource()` THROWS at boot. A silent overwrite would
1894
+ * let one gate answer for a different verb, and `introspectRegistry` refuses
1895
+ * the same collision as a backstop for hand-built registry entries.
1896
+ *
1897
+ * @example
1898
+ * { method: 'POST', path: '/recompute', capability: 'recompute',
1899
+ * permissions: requireRoles(['admin']) }
1900
+ */
1901
+ readonly capability?: string;
1675
1902
  /**
1676
1903
  * Fastify-native handler — receives `(request, reply)` and owns the response.
1677
1904
  * Bypasses arc's pipeline entirely: no `IControllerResponse` shaping, no
@@ -1811,6 +2038,35 @@ interface RouteDefinition {
1811
2038
  response?: Record<number | string, unknown>;
1812
2039
  [key: string]: unknown;
1813
2040
  };
2041
+ /**
2042
+ * Fastify route CONFIG — `reply.routeOptions.config` at request time.
2043
+ *
2044
+ * Fastify's own first-class route option (Reference/Routes.md § Config), passed
2045
+ * straight through. Arc adds no semantics: it is the documented seam every Fastify
2046
+ * plugin uses to take per-route options, and arc forwarding it is what makes those
2047
+ * plugins reachable from a `defineResource` route at all.
2048
+ *
2049
+ * ## Why this is not optional polish
2050
+ *
2051
+ * Arc REGISTERS `fastify-raw-body` with `global: false` (see `registerSecurity`),
2052
+ * which means a route opts in with `config: { rawBody: true }`. Without this field
2053
+ * that opt-in had nowhere to live, so the plugin arc itself installs was
2054
+ * unreachable from any resource — and a route that wrote it got no error, just a
2055
+ * silently absent `request.rawBody`.
2056
+ *
2057
+ * The cost of that was found end-to-end, not by review: every HMAC-signed webhook
2058
+ * verified its signature against a body it never received, so a real provider
2059
+ * callback 401'd in production while every unit test passed.
2060
+ *
2061
+ * @example
2062
+ * // Signature verification needs the exact transmitted bytes.
2063
+ * { method: 'POST', path: '/webhooks', config: { rawBody: true }, rawHandler }
2064
+ *
2065
+ * @example
2066
+ * // Anything a plugin reads per-route — rate-limit overrides, feature flags.
2067
+ * { method: 'GET', path: '/report', config: { rateLimit: { max: 5 } } }
2068
+ */
2069
+ readonly config?: Record<string, unknown>;
1814
2070
  /**
1815
2071
  * MCP tool generation:
1816
2072
  * - omitted/true: auto-generate (non-raw routes only)
@@ -2843,6 +3099,44 @@ interface ResourceConfig<TDoc = AnyRecord> {
2843
3099
  adapter?: DataAdapter<TDoc>;
2844
3100
  /** Controller instance — accepts any object with CRUD methods. */
2845
3101
  controller?: IController<TDoc> | ControllerLike;
3102
+ /**
3103
+ * Bind a CRUD write slot to a DOMAIN COMMAND, keeping arc's request
3104
+ * pipeline. Arc still sanitizes against the resource's field rules, injects
3105
+ * the tenant field, stamps the actor and runs the hook sandwich — then calls
3106
+ * your verb where it would have called `repository.create/update/delete`.
3107
+ *
3108
+ * Reach for this whenever the kernel behind the resource owns a GUARDED
3109
+ * write (a verb that refuses once a document is posted / closed / locked).
3110
+ * Overriding the controller method instead silently drops the pipeline —
3111
+ * see `ResourceWrites` for the two-way trap this closes.
3112
+ *
3113
+ * @example
3114
+ * ```typescript
3115
+ * writes: {
3116
+ * create: (data, ctx) => engine.invoices.createDraft(data, resolveCtx(ctx.req)),
3117
+ * update: (id, data, ctx) => engine.invoices.updateDraft(id, data, resolveCtx(ctx.req)),
3118
+ * delete: (id, ctx) => engine.invoices.deleteDraft(id, resolveCtx(ctx.req)),
3119
+ * }
3120
+ * ```
3121
+ */
3122
+ writes?: ResourceWrites<TDoc>;
3123
+ /**
3124
+ * Run every CRUD write (and its declared verb) inside a repository
3125
+ * transaction with TRANSIENT-conflict retry (repo-core
3126
+ * `retryingTransaction`): begin → persistence/verb → commit, re-run on the
3127
+ * kit-classified conflicts only, jittered bounded backoff.
3128
+ *
3129
+ * Contract: before/around hooks run ONCE (outside the retry); the
3130
+ * persistence step may re-run, which is safe by construction when side
3131
+ * effects go through the tx-bound repository (they roll back) or an outbox
3132
+ * row; after-hooks run once, post-commit. A `VersionConflictError`
3133
+ * (`ifVersion` CAS) is NOT transient and surfaces as 409.
3134
+ *
3135
+ * Boot-fatal when the adapter's repository has no `withTransaction`
3136
+ * (capability `transactions: false`) — a requested transactional envelope
3137
+ * that silently degrades is the defect, not a mode.
3138
+ */
3139
+ transactional?: boolean;
2846
3140
  queryParser?: unknown;
2847
3141
  permissions?: ResourcePermissions;
2848
3142
  schemaOptions?: RouteSchemaOptions;
@@ -2888,6 +3182,18 @@ interface ResourceConfig<TDoc = AnyRecord> {
2888
3182
  * pre-2.9 code that relied on the permissive default.
2889
3183
  */
2890
3184
  onFieldWriteDenied?: "reject" | "strip";
3185
+ /**
3186
+ * What to do when an UPDATE body carries an `immutable` /
3187
+ * `immutableAfterCreate` field. Default `'strip'`.
3188
+ *
3189
+ * `'reject'` answers 403 instead of 200-with-the-field-unchanged. Kept
3190
+ * separate from `onFieldWriteDenied` (which defaults to `'reject'`) because a
3191
+ * full-object PATCH echoing an immutable field back UNCHANGED is legitimate,
3192
+ * and the sanitizer has no stored document to tell that from a real change.
3193
+ * Hosts sending partial patches should turn it on; `ARC_STRICT_IMMUTABLE_WRITES`
3194
+ * sets it fleet-wide.
3195
+ */
3196
+ onImmutableWrite?: "reject" | "strip";
2891
3197
  middlewares?: MiddlewareConfig;
2892
3198
  /**
2893
3199
  * PreHandler guards auto-applied to **every** route on this resource
@@ -3444,6 +3750,22 @@ declare class ResourceDefinition<TDoc = AnyRecord> {
3444
3750
  * snapshot so callers can't accidentally mutate the diagnostic list.
3445
3751
  */
3446
3752
  _diagnostics?: ResourceDiagnostic[];
3753
+ /**
3754
+ * Boot-time assertions that CANNOT be settled at define time because
3755
+ * they read state a connection establishes.
3756
+ *
3757
+ * `defineResource()` frequently runs at module-import time — before
3758
+ * `beforeBoot()` opens the database. A repository's capability
3759
+ * descriptor is therefore not yet authoritative then: mongokit
3760
+ * deliberately reports `transactions: false` for an unresolved topology
3761
+ * (fail-closed), so asserting at define time would reject a correctly
3762
+ * configured app for the crime of importing its resources early.
3763
+ *
3764
+ * `buildResourcePlugin` runs these on first mount — lifecycle slot 5,
3765
+ * after `beforeBoot` (0) and module resolution (0.5) — where the same
3766
+ * fail-closed rule finally means what it says.
3767
+ */
3768
+ _deferredChecks?: Array<() => void>;
3447
3769
  /**
3448
3770
  * Public view of {@link _diagnostics} for programmatic consumption.
3449
3771
  *
@@ -4016,6 +4338,13 @@ interface QueryParserInterface {
4016
4338
  * is not explicitly configured.
4017
4339
  */
4018
4340
  allowedFilterFields?: readonly string[];
4341
+ /**
4342
+ * Optional: the parser's own page-size cap.
4343
+ *
4344
+ * A parser that declares one has ANSWERED the question, and `QueryResolver` must
4345
+ * defer to it rather than applying its own default on top — see the note there.
4346
+ */
4347
+ maxLimit?: number;
4019
4348
  /**
4020
4349
  * Optional: Allowed filter operators whitelist. Used by MCP to enrich
4021
4350
  * list-tool descriptions. Values are human-readable keys: 'eq', 'ne',
@@ -4062,6 +4391,11 @@ interface ResourceMetadata {
4062
4391
  summary?: string;
4063
4392
  description?: string;
4064
4393
  permissions?: PermissionCheck;
4394
+ /**
4395
+ * Matrix key this route's gate is published under. Absent ⇒ enforced but not
4396
+ * advertised — see `capability` on `RouteDefinition`.
4397
+ */
4398
+ capability?: string;
4065
4399
  raw?: boolean;
4066
4400
  schema?: unknown;
4067
4401
  }>;
@@ -4295,4 +4629,4 @@ interface ValidateOptions {
4295
4629
  strict?: boolean;
4296
4630
  }
4297
4631
  //#endregion
4298
- export { OpenApiSchemas as $, TreeMixin as $t, RequestIdOptions as A, BaseControllerOptions as An, RouteDefinition as At, defineResource as B, RawRouteHandler as Bt, RequestContext as C, createHookSystem as Cn, PipelineStep as Ct, HealthCheck as D, ArcGetResult as Dn, ActionEntry as Dt, GracefulShutdownOptions as E, ArcDeleteResult as En, ActionDefinition as Et, FastifyWithDecorators as F, BodySanitizerConfig as Fn, ControllerLike as Ft, OnTenantDeleteConfig as G, Authenticator as Gt, CrudController as H, ServerAccessor as Ht, MiddlewareHandler as I, FastifyHandler as It, PresetHook as J, TokenPair as Jt, ResolvedTenantPurge as K, AuthenticatorContext as Kt, RequestWithExtras as L, IController as Lt, EventsDecorator as M, ControllerConstructionOptions as Mn, RouteMethod as Mt, FastifyRequestExtras as N, ListResult as Nn, RateLimitConfig as Nt, HealthOptions as O, ArcListResult as On, ActionHandlerFn as Ot, FastifyWithAuth as P, BodySanitizer as Pn, ControllerHandler as Pt, MiddlewareConfig as Q, TreeExt as Qt, RegisterOptions as R, IControllerResponse as Rt, QueryParserInterface as S, beforeUpdate as Sn, PipelineContext as St, CrudRouterOptions as T, ArcCreateResult as Tn, ResourceCacheConfig as Tt, ResourceConfig as U, AuthHelpers as Ut, ResourceDefinition as V, RouteHandler as Vt, ResourcePermissions as W, AuthPluginOptions as Wt, CrudRouteKey as X, SoftDeleteExt as Xt, PresetResult as Y, BaseController as Yt, CrudSchemas as Z, SoftDeleteMixin as Zt, ControllerQueryOptions as _, afterCreate as _n, Guard as _t, InferAdapterDoc as a, QueryResolver as an, ResourceExtensions as at, ParsedQuery as b, beforeCreate as bn, OperationFilter as bt, TypedController as c, AccessControlConfig as cn, AggMeasureShorthand as ct, PaginationResult as d, HookHandler as dn, AggregationDateRangeRequirement as dt, SlugExt as en, RouteSchemaOptions as et, IntrospectionData as f, HookOperation as fn, AggregationIndexHint as ft, ArcInternalMetadata as g, HookSystemOptions as gn, AggregationsMap as gt, ResourceMetadata as h, HookSystem as hn, AggregationRateLimit as ht, ValidationResult as i, BaseCrudController as in, ResourceHooks as it, ArcDecorator as j, ControllerConfigurableOptions as jn, RouteMcpConfig as jt, IntrospectionPluginOptions as k, ArcUpdateResult as kn, ActionsMap as kt, TypedRepository as l, DefineHookOptions as ln, AggregationCacheConfig as lt, RegistryStats as m, HookRegistration as mn, AggregationMaterializedResult as mt, ConfigError as n, BulkExt as nn, FieldRule$1 as nt, InferDocType as o, QueryResolverConfig as on, EventDefinition as ot, RegistryEntry as p, HookPhase as pn, AggregationMaterializedContext as pt, PresetFunction as q, JwtContext as qt, ValidateOptions as r, BulkMixin as rn, ResourceHookContext as rt, InferResourceDoc as s, AccessControl as sn, AggMeasureInput as st, RouteHandlerMethod$1 as t, SlugMixin as tn, ArcFieldRule as tt, TypedResourceConfig as u, HookContext as un, AggregationConfig as ut, LookupOption as v, afterDelete as vn, Interceptor as vt, ServiceContext as w, defineHook as wn, Transform as wt, PopulateOption as x, beforeDelete as xn, PipelineConfig as xt, OwnershipCheck as y, afterUpdate as yn, NextFunction as yt, ResourceRegistry as z, IRequestContext as zt };
4632
+ export { OpenApiSchemas as $, AccessControlConfig as $t, RequestIdOptions as A, WriteVerbKey as An, RouteDefinition as At, defineResource as B, BaseController as Bt, RequestContext as C, ControllerConstructionOptions as Cn, PipelineStep as Ct, HealthCheck as D, MutationWriteContext as Dn, ActionEntry as Dt, GracefulShutdownOptions as E, BodySanitizerConfig as En, ActionDefinition as Et, FastifyWithDecorators as F, IControllerResponse as Fn, AuthPluginOptions as Ft, OnTenantDeleteConfig as G, SlugExt as Gt, CrudController as H, SoftDeleteMixin as Ht, MiddlewareHandler as I, IRequestContext as In, Authenticator as It, PresetHook as J, BulkMixin as Jt, ResolvedTenantPurge as K, SlugMixin as Kt, RequestWithExtras as L, RawRouteHandler as Ln, AuthenticatorContext as Lt, EventsDecorator as M, ControllerLike as Mn, RouteMethod as Mt, FastifyRequestExtras as N, FastifyHandler as Nn, RateLimitConfig as Nt, HealthOptions as O, ResourceWrites as On, ActionHandlerFn as Ot, FastifyWithAuth as P, IController as Pn, AuthHelpers as Pt, MiddlewareConfig as Q, AccessControl as Qt, RegisterOptions as R, RouteHandler as Rn, JwtContext as Rt, QueryParserInterface as S, ControllerConfigurableOptions as Sn, PipelineContext as St, CrudRouterOptions as T, BodySanitizer as Tn, ResourceCacheConfig as Tt, ResourceConfig as U, TreeExt as Ut, ResourceDefinition as V, SoftDeleteExt as Vt, ResourcePermissions as W, TreeMixin as Wt, CrudRouteKey as X, QueryResolver as Xt, PresetResult as Y, BaseCrudController as Yt, CrudSchemas as Z, QueryResolverConfig as Zt, ControllerQueryOptions as _, ArcDeleteResult as _n, Guard as _t, InferAdapterDoc as a, HookRegistration as an, ResourceExtensions as at, ParsedQuery as b, ArcUpdateResult as bn, OperationFilter as bt, TypedController as c, afterCreate as cn, AggMeasureShorthand as ct, PaginationResult as d, beforeCreate as dn, AggregationDateRangeRequirement as dt, DefineHookOptions as en, RouteSchemaOptions as et, IntrospectionData as f, beforeDelete as fn, AggregationIndexHint as ft, ArcInternalMetadata as g, ArcCreateResult as gn, AggregationsMap as gt, ResourceMetadata as h, defineHook as hn, AggregationRateLimit as ht, ValidationResult as i, HookPhase as in, ResourceHooks as it, ArcDecorator as j, ControllerHandler as jn, RouteMcpConfig as jt, IntrospectionPluginOptions as k, WriteContext as kn, ActionsMap as kt, TypedRepository as l, afterDelete as ln, AggregationCacheConfig as lt, RegistryStats as m, createHookSystem as mn, AggregationMaterializedResult as mt, ConfigError as n, HookHandler as nn, FieldRule$1 as nt, InferDocType as o, HookSystem as on, EventDefinition as ot, RegistryEntry as p, beforeUpdate as pn, AggregationMaterializedContext as pt, PresetFunction as q, BulkExt as qt, ValidateOptions as r, HookOperation as rn, ResourceHookContext as rt, InferResourceDoc as s, HookSystemOptions as sn, AggMeasureInput as st, RouteHandlerMethod$1 as t, HookContext as tn, ArcFieldRule as tt, TypedResourceConfig as u, afterUpdate as un, AggregationConfig as ut, LookupOption as v, ArcGetResult as vn, Interceptor as vt, ServiceContext as w, ListResult as wn, Transform as wt, PopulateOption as x, BaseControllerOptions as xn, PipelineConfig as xt, OwnershipCheck as y, ArcListResult as yn, NextFunction as yt, ResourceRegistry as z, ServerAccessor as zn, TokenPair as zt };