@classytic/arc 1.1.0 → 2.1.3

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 (200) hide show
  1. package/README.md +247 -794
  2. package/bin/arc.js +91 -52
  3. package/dist/EventTransport-BkUDYZEb.d.mts +99 -0
  4. package/dist/HookSystem-BsGV-j2l.mjs +404 -0
  5. package/dist/ResourceRegistry-7Ic20ZMw.mjs +249 -0
  6. package/dist/adapters/index.d.mts +5 -0
  7. package/dist/adapters/index.mjs +3 -0
  8. package/dist/audit/index.d.mts +81 -0
  9. package/dist/audit/index.mjs +275 -0
  10. package/dist/audit/mongodb.d.mts +5 -0
  11. package/dist/audit/mongodb.mjs +3 -0
  12. package/dist/audited-CGdLiSlE.mjs +140 -0
  13. package/dist/auth/index.d.mts +188 -0
  14. package/dist/auth/index.mjs +1096 -0
  15. package/dist/auth/redis-session.d.mts +43 -0
  16. package/dist/auth/redis-session.mjs +75 -0
  17. package/dist/betterAuthOpenApi-DjWDddNc.mjs +249 -0
  18. package/dist/cache/index.d.mts +145 -0
  19. package/dist/cache/index.mjs +91 -0
  20. package/dist/caching-GSDJcA6-.mjs +93 -0
  21. package/dist/chunk-C7Uep-_p.mjs +20 -0
  22. package/dist/circuitBreaker-DYhWBW_D.mjs +1096 -0
  23. package/dist/cli/commands/describe.d.mts +18 -0
  24. package/dist/cli/commands/describe.mjs +238 -0
  25. package/dist/cli/commands/docs.d.mts +13 -0
  26. package/dist/cli/commands/docs.mjs +52 -0
  27. package/dist/cli/commands/{generate.d.ts → generate.d.mts} +3 -2
  28. package/dist/cli/commands/generate.mjs +357 -0
  29. package/dist/cli/commands/{init.d.ts → init.d.mts} +11 -8
  30. package/dist/cli/commands/{init.js → init.mjs} +807 -617
  31. package/dist/cli/commands/introspect.d.mts +10 -0
  32. package/dist/cli/commands/introspect.mjs +75 -0
  33. package/dist/cli/index.d.mts +16 -0
  34. package/dist/cli/index.mjs +156 -0
  35. package/dist/constants-DdXFXQtN.mjs +84 -0
  36. package/dist/core/index.d.mts +5 -0
  37. package/dist/core/index.mjs +4 -0
  38. package/dist/createApp-D2D5XXaV.mjs +559 -0
  39. package/dist/defineResource-PXzSJ15_.mjs +2197 -0
  40. package/dist/discovery/index.d.mts +46 -0
  41. package/dist/discovery/index.mjs +109 -0
  42. package/dist/docs/index.d.mts +162 -0
  43. package/dist/docs/index.mjs +74 -0
  44. package/dist/elevation-DGo5shaX.d.mts +87 -0
  45. package/dist/elevation-DSTbVvYj.mjs +113 -0
  46. package/dist/errorHandler-C3GY3_ow.mjs +108 -0
  47. package/dist/errorHandler-CW3OOeYq.d.mts +72 -0
  48. package/dist/errors-DAWRdiYP.d.mts +124 -0
  49. package/dist/errors-DBANPbGr.mjs +211 -0
  50. package/dist/eventPlugin-BEOvaDqo.mjs +229 -0
  51. package/dist/eventPlugin-H6wDDjGO.d.mts +124 -0
  52. package/dist/events/index.d.mts +53 -0
  53. package/dist/events/index.mjs +51 -0
  54. package/dist/events/transports/redis-stream-entry.d.mts +2 -0
  55. package/dist/events/transports/redis-stream-entry.mjs +177 -0
  56. package/dist/events/transports/redis.d.mts +76 -0
  57. package/dist/events/transports/redis.mjs +124 -0
  58. package/dist/externalPaths-SyPF2tgK.d.mts +50 -0
  59. package/dist/factory/index.d.mts +63 -0
  60. package/dist/factory/index.mjs +3 -0
  61. package/dist/fastifyAdapter-C8DlE0YH.d.mts +216 -0
  62. package/dist/fields-Bi_AVKSo.d.mts +109 -0
  63. package/dist/fields-CTd_CrKr.mjs +114 -0
  64. package/dist/hooks/index.d.mts +4 -0
  65. package/dist/hooks/index.mjs +3 -0
  66. package/dist/idempotency/index.d.mts +96 -0
  67. package/dist/idempotency/index.mjs +319 -0
  68. package/dist/idempotency/mongodb.d.mts +2 -0
  69. package/dist/idempotency/mongodb.mjs +114 -0
  70. package/dist/idempotency/redis.d.mts +2 -0
  71. package/dist/idempotency/redis.mjs +103 -0
  72. package/dist/index.d.mts +260 -0
  73. package/dist/index.mjs +104 -0
  74. package/dist/integrations/event-gateway.d.mts +46 -0
  75. package/dist/integrations/event-gateway.mjs +43 -0
  76. package/dist/integrations/index.d.mts +5 -0
  77. package/dist/integrations/index.mjs +1 -0
  78. package/dist/integrations/jobs.d.mts +103 -0
  79. package/dist/integrations/jobs.mjs +123 -0
  80. package/dist/integrations/streamline.d.mts +60 -0
  81. package/dist/integrations/streamline.mjs +125 -0
  82. package/dist/integrations/websocket.d.mts +82 -0
  83. package/dist/integrations/websocket.mjs +288 -0
  84. package/dist/interface-CSNjltAc.d.mts +77 -0
  85. package/dist/interface-DTbsvIWe.d.mts +54 -0
  86. package/dist/interface-e9XfSsUV.d.mts +1097 -0
  87. package/dist/introspectionPlugin-B3JkrjwU.mjs +53 -0
  88. package/dist/keys-DhqDRxv3.mjs +42 -0
  89. package/dist/logger-ByrvQWZO.mjs +78 -0
  90. package/dist/memory-B2v7KrCB.mjs +143 -0
  91. package/dist/migrations/index.d.mts +156 -0
  92. package/dist/migrations/index.mjs +260 -0
  93. package/dist/mongodb-ClykrfGo.d.mts +118 -0
  94. package/dist/mongodb-DNKEExbf.mjs +93 -0
  95. package/dist/mongodb-Dg8O_gvd.d.mts +71 -0
  96. package/dist/openapi-9nB_kiuR.mjs +525 -0
  97. package/dist/org/index.d.mts +68 -0
  98. package/dist/org/index.mjs +513 -0
  99. package/dist/org/types.d.mts +82 -0
  100. package/dist/org/types.mjs +1 -0
  101. package/dist/permissions/index.d.mts +278 -0
  102. package/dist/permissions/index.mjs +579 -0
  103. package/dist/plugins/index.d.mts +172 -0
  104. package/dist/plugins/index.mjs +522 -0
  105. package/dist/plugins/response-cache.d.mts +87 -0
  106. package/dist/plugins/response-cache.mjs +283 -0
  107. package/dist/plugins/tracing-entry.d.mts +2 -0
  108. package/dist/plugins/tracing-entry.mjs +185 -0
  109. package/dist/pluralize-CM-jZg7p.mjs +86 -0
  110. package/dist/policies/{index.d.ts → index.d.mts} +204 -170
  111. package/dist/policies/index.mjs +321 -0
  112. package/dist/presets/{index.d.ts → index.d.mts} +62 -131
  113. package/dist/presets/index.mjs +143 -0
  114. package/dist/presets/multiTenant.d.mts +24 -0
  115. package/dist/presets/multiTenant.mjs +113 -0
  116. package/dist/presets-BTeYbw7h.d.mts +57 -0
  117. package/dist/presets-CeFtfDR8.mjs +119 -0
  118. package/dist/prisma-C3iornoK.d.mts +274 -0
  119. package/dist/prisma-DJbMt3yf.mjs +627 -0
  120. package/dist/queryCachePlugin-B6R0d4av.mjs +138 -0
  121. package/dist/queryCachePlugin-Q6SYuHZ6.d.mts +71 -0
  122. package/dist/redis-UwjEp8Ea.d.mts +49 -0
  123. package/dist/redis-stream-CBg0upHI.d.mts +103 -0
  124. package/dist/registry/index.d.mts +11 -0
  125. package/dist/registry/index.mjs +4 -0
  126. package/dist/requestContext-xi6OKBL-.mjs +55 -0
  127. package/dist/schemaConverter-Dtg0Kt9T.mjs +98 -0
  128. package/dist/schemas/index.d.mts +63 -0
  129. package/dist/schemas/index.mjs +82 -0
  130. package/dist/scope/index.d.mts +21 -0
  131. package/dist/scope/index.mjs +65 -0
  132. package/dist/sessionManager-D_iEHjQl.d.mts +186 -0
  133. package/dist/sse-DkqQ1uxb.mjs +123 -0
  134. package/dist/testing/index.d.mts +907 -0
  135. package/dist/testing/index.mjs +1976 -0
  136. package/dist/tracing-8CEbhF0w.d.mts +70 -0
  137. package/dist/typeGuards-DwxA1t_L.mjs +9 -0
  138. package/dist/types/index.d.mts +946 -0
  139. package/dist/types/index.mjs +14 -0
  140. package/dist/types-B0dhNrnd.d.mts +445 -0
  141. package/dist/types-Beqn1Un7.mjs +38 -0
  142. package/dist/types-DelU6kln.mjs +25 -0
  143. package/dist/types-RLkFVgaw.d.mts +101 -0
  144. package/dist/utils/index.d.mts +747 -0
  145. package/dist/utils/index.mjs +6 -0
  146. package/package.json +194 -68
  147. package/dist/BaseController-DVAiHxEQ.d.ts +0 -233
  148. package/dist/adapters/index.d.ts +0 -237
  149. package/dist/adapters/index.js +0 -668
  150. package/dist/arcCorePlugin-CsShQdyP.d.ts +0 -273
  151. package/dist/audit/index.d.ts +0 -195
  152. package/dist/audit/index.js +0 -319
  153. package/dist/auth/index.d.ts +0 -47
  154. package/dist/auth/index.js +0 -174
  155. package/dist/cli/commands/docs.d.ts +0 -11
  156. package/dist/cli/commands/docs.js +0 -474
  157. package/dist/cli/commands/generate.js +0 -334
  158. package/dist/cli/commands/introspect.d.ts +0 -8
  159. package/dist/cli/commands/introspect.js +0 -338
  160. package/dist/cli/index.d.ts +0 -4
  161. package/dist/cli/index.js +0 -3269
  162. package/dist/core/index.d.ts +0 -220
  163. package/dist/core/index.js +0 -2786
  164. package/dist/createApp-Ce9wl8W9.d.ts +0 -77
  165. package/dist/docs/index.d.ts +0 -166
  166. package/dist/docs/index.js +0 -658
  167. package/dist/errors-8WIxGS_6.d.ts +0 -122
  168. package/dist/events/index.d.ts +0 -117
  169. package/dist/events/index.js +0 -89
  170. package/dist/factory/index.d.ts +0 -38
  171. package/dist/factory/index.js +0 -1652
  172. package/dist/hooks/index.d.ts +0 -4
  173. package/dist/hooks/index.js +0 -199
  174. package/dist/idempotency/index.d.ts +0 -323
  175. package/dist/idempotency/index.js +0 -500
  176. package/dist/index-B4t03KQ0.d.ts +0 -1366
  177. package/dist/index.d.ts +0 -135
  178. package/dist/index.js +0 -4756
  179. package/dist/migrations/index.d.ts +0 -185
  180. package/dist/migrations/index.js +0 -274
  181. package/dist/org/index.d.ts +0 -129
  182. package/dist/org/index.js +0 -220
  183. package/dist/permissions/index.d.ts +0 -144
  184. package/dist/permissions/index.js +0 -103
  185. package/dist/plugins/index.d.ts +0 -46
  186. package/dist/plugins/index.js +0 -1069
  187. package/dist/policies/index.js +0 -196
  188. package/dist/presets/index.js +0 -384
  189. package/dist/presets/multiTenant.d.ts +0 -39
  190. package/dist/presets/multiTenant.js +0 -112
  191. package/dist/registry/index.d.ts +0 -16
  192. package/dist/registry/index.js +0 -253
  193. package/dist/testing/index.d.ts +0 -618
  194. package/dist/testing/index.js +0 -48020
  195. package/dist/types/index.d.ts +0 -4
  196. package/dist/types/index.js +0 -8
  197. package/dist/types-B99TBmFV.d.ts +0 -76
  198. package/dist/types-BvckRbs2.d.ts +0 -143
  199. package/dist/utils/index.d.ts +0 -679
  200. package/dist/utils/index.js +0 -931
@@ -0,0 +1,2197 @@
1
+ import { a as DEFAULT_SORT, d as MAX_REGEX_LENGTH, h as SYSTEM_FIELDS, n as DEFAULT_ID_FIELD, o as DEFAULT_TENANT_FIELD, r as DEFAULT_LIMIT, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-DdXFXQtN.mjs";
2
+ import { c as isElevated, l as isMember, n as PUBLIC_SCOPE, r as getOrgId } from "./types-Beqn1Un7.mjs";
3
+ import { getUserId } from "./types/index.mjs";
4
+ import { i as resolveEffectiveRoles, n as applyFieldWritePermissions, t as applyFieldReadPermissions } from "./fields-CTd_CrKr.mjs";
5
+ import { t as getUserRoles } from "./types-DelU6kln.mjs";
6
+ import { C as ArcQueryParser, u as getDefaultCrudSchemas } from "./circuitBreaker-DYhWBW_D.mjs";
7
+ import { t as buildQueryKey } from "./keys-DhqDRxv3.mjs";
8
+ import { r as ForbiddenError } from "./errors-DBANPbGr.mjs";
9
+ import { t as hasEvents } from "./typeGuards-DwxA1t_L.mjs";
10
+ import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-Dtg0Kt9T.mjs";
11
+ import { t as requestContext } from "./requestContext-xi6OKBL-.mjs";
12
+ import { applyPresets, getAvailablePresets } from "./presets/index.mjs";
13
+
14
+ //#region src/core/AccessControl.ts
15
+ var AccessControl = class AccessControl {
16
+ tenantField;
17
+ idField;
18
+ _adapterMatchesFilter;
19
+ /** Patterns that indicate dangerous regex (nested quantifiers, excessive backtracking).
20
+ * Uses [^...] character classes instead of .+ to avoid backtracking in the detector itself. */
21
+ static DANGEROUS_REGEX = /(\{[0-9]+,\}[^{]*\{[0-9]+,\})|(\+[^+]*\+)|(\*[^*]*\*)|(\.\*){3,}|\\1/;
22
+ /** Forbidden paths that could lead to prototype pollution */
23
+ static FORBIDDEN_PATHS = [
24
+ "__proto__",
25
+ "constructor",
26
+ "prototype"
27
+ ];
28
+ constructor(config) {
29
+ this.tenantField = config.tenantField;
30
+ this.idField = config.idField;
31
+ this._adapterMatchesFilter = config.matchesFilter;
32
+ }
33
+ /**
34
+ * Build filter for single-item operations (get/update/delete)
35
+ * Combines ID filter with policy/org filters for proper security enforcement
36
+ */
37
+ buildIdFilter(id, req) {
38
+ const filter = { [this.idField]: id };
39
+ const arcContext = this._meta(req);
40
+ const policyFilters = arcContext?._policyFilters;
41
+ if (policyFilters) Object.assign(filter, policyFilters);
42
+ const scope = arcContext?._scope;
43
+ const orgId = scope ? getOrgId(scope) : void 0;
44
+ if (orgId && !policyFilters?.[this.tenantField]) filter[this.tenantField] = orgId;
45
+ return filter;
46
+ }
47
+ /**
48
+ * Check if item matches policy filters (for get/update/delete operations)
49
+ * Validates that fetched item satisfies all policy constraints
50
+ *
51
+ * Delegates to adapter-provided matchesFilter if available (for SQL, etc.),
52
+ * otherwise falls back to built-in MongoDB-style matching.
53
+ */
54
+ checkPolicyFilters(item, req) {
55
+ const policyFilters = this._meta(req)?._policyFilters;
56
+ if (!policyFilters) return true;
57
+ if (this._adapterMatchesFilter) return this._adapterMatchesFilter(item, policyFilters);
58
+ return this.defaultMatchesPolicyFilters(item, policyFilters);
59
+ }
60
+ /**
61
+ * Check org/tenant scope for a document — uses configurable tenantField.
62
+ *
63
+ * SECURITY: When org scope is active (orgId present), documents that are
64
+ * missing the tenant field are DENIED by default. This prevents legacy or
65
+ * unscoped records from leaking across tenants.
66
+ */
67
+ checkOrgScope(item, arcContext) {
68
+ const scope = arcContext?._scope;
69
+ const orgId = scope ? getOrgId(scope) : void 0;
70
+ if (!item || !orgId) return true;
71
+ if (scope && isElevated(scope) && !orgId) return true;
72
+ const itemOrgId = item[this.tenantField];
73
+ if (!itemOrgId) return false;
74
+ return String(itemOrgId) === String(orgId);
75
+ }
76
+ /** Check ownership for update/delete (ownedByUser preset) */
77
+ checkOwnership(item, req) {
78
+ const ownershipCheck = this._meta(req)?._ownershipCheck;
79
+ if (!item || !ownershipCheck) return true;
80
+ const { field, userId } = ownershipCheck;
81
+ const itemOwnerId = item[field];
82
+ if (!itemOwnerId) return true;
83
+ return String(itemOwnerId) === String(userId);
84
+ }
85
+ /**
86
+ * Fetch a single document with full access control enforcement.
87
+ * Combines compound DB filter (ID + org + policy) with post-hoc fallback.
88
+ *
89
+ * Takes repository as a parameter to avoid coupling.
90
+ *
91
+ * Replaces the duplicated pattern in get/update/delete:
92
+ * buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
93
+ */
94
+ async fetchWithAccessControl(id, req, repository, queryOptions) {
95
+ const compoundFilter = this.buildIdFilter(id, req);
96
+ const hasCompoundFilters = Object.keys(compoundFilter).length > 1;
97
+ try {
98
+ if (hasCompoundFilters && typeof repository.getOne === "function") return await repository.getOne(compoundFilter, queryOptions);
99
+ const item = await repository.getById(id, queryOptions);
100
+ if (!item) return null;
101
+ const arcContext = this._meta(req);
102
+ if (!this.checkOrgScope(item, arcContext) || !this.checkPolicyFilters(item, req)) return null;
103
+ return item;
104
+ } catch (error) {
105
+ if (error instanceof Error && error.message?.includes("not found")) return null;
106
+ throw error;
107
+ }
108
+ }
109
+ /** Extract typed Arc internal metadata from request */
110
+ _meta(req) {
111
+ return req.metadata;
112
+ }
113
+ /**
114
+ * Check if a value matches a MongoDB query operator
115
+ */
116
+ matchesOperator(itemValue, operator, filterValue) {
117
+ const equalsByValue = (a, b) => String(a) === String(b);
118
+ switch (operator) {
119
+ case "$eq": return equalsByValue(itemValue, filterValue);
120
+ case "$ne": return !equalsByValue(itemValue, filterValue);
121
+ case "$gt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue > filterValue;
122
+ case "$gte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue >= filterValue;
123
+ case "$lt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue < filterValue;
124
+ case "$lte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue <= filterValue;
125
+ case "$in":
126
+ if (!Array.isArray(filterValue)) return false;
127
+ if (Array.isArray(itemValue)) return itemValue.some((v) => filterValue.some((fv) => equalsByValue(v, fv)));
128
+ return filterValue.some((fv) => equalsByValue(itemValue, fv));
129
+ case "$nin":
130
+ if (!Array.isArray(filterValue)) return false;
131
+ if (Array.isArray(itemValue)) return itemValue.every((v) => filterValue.every((fv) => !equalsByValue(v, fv)));
132
+ return filterValue.every((fv) => !equalsByValue(itemValue, fv));
133
+ case "$exists": return filterValue ? itemValue !== void 0 : itemValue === void 0;
134
+ case "$regex":
135
+ if (typeof itemValue === "string" && (typeof filterValue === "string" || filterValue instanceof RegExp)) {
136
+ const regex = typeof filterValue === "string" ? AccessControl.safeRegex(filterValue) : filterValue;
137
+ return regex !== null && regex.test(itemValue);
138
+ }
139
+ return false;
140
+ default: return false;
141
+ }
142
+ }
143
+ /**
144
+ * Check if item matches a single filter condition
145
+ * Supports nested paths (e.g., "owner.id", "metadata.status")
146
+ */
147
+ matchesFilter(item, key, filterValue) {
148
+ const itemValue = key.includes(".") ? this.getNestedValue(item, key) : item[key];
149
+ if (filterValue && typeof filterValue === "object" && !Array.isArray(filterValue)) {
150
+ if (Object.keys(filterValue).some((op) => op.startsWith("$"))) {
151
+ for (const [operator, opValue] of Object.entries(filterValue)) if (!this.matchesOperator(itemValue, operator, opValue)) return false;
152
+ return true;
153
+ }
154
+ }
155
+ if (Array.isArray(itemValue)) return itemValue.some((v) => String(v) === String(filterValue));
156
+ return String(itemValue) === String(filterValue);
157
+ }
158
+ /**
159
+ * Built-in MongoDB-style policy filter matching.
160
+ * Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
161
+ */
162
+ defaultMatchesPolicyFilters(item, policyFilters) {
163
+ if (policyFilters.$and && Array.isArray(policyFilters.$and)) return policyFilters.$and.every((condition) => {
164
+ return Object.entries(condition).every(([key, value]) => {
165
+ return this.matchesFilter(item, key, value);
166
+ });
167
+ });
168
+ if (policyFilters.$or && Array.isArray(policyFilters.$or)) return policyFilters.$or.some((condition) => {
169
+ return Object.entries(condition).every(([key, value]) => {
170
+ return this.matchesFilter(item, key, value);
171
+ });
172
+ });
173
+ for (const [key, value] of Object.entries(policyFilters)) {
174
+ if (key.startsWith("$")) continue;
175
+ if (!this.matchesFilter(item, key, value)) return false;
176
+ }
177
+ return true;
178
+ }
179
+ /**
180
+ * Get nested value from object using dot notation (e.g., "owner.id")
181
+ * Security: Validates path against forbidden patterns to prevent prototype pollution
182
+ */
183
+ getNestedValue(obj, path) {
184
+ if (AccessControl.FORBIDDEN_PATHS.some((p) => path.toLowerCase().includes(p))) return;
185
+ const keys = path.split(".");
186
+ let value = obj;
187
+ for (const key of keys) {
188
+ if (value == null) return void 0;
189
+ if (AccessControl.FORBIDDEN_PATHS.includes(key.toLowerCase())) return;
190
+ value = value[key];
191
+ }
192
+ return value;
193
+ }
194
+ /**
195
+ * Create a safe RegExp from a string, guarding against ReDoS.
196
+ * Returns null if the pattern is invalid or dangerous.
197
+ */
198
+ static safeRegex(pattern) {
199
+ if (pattern.length > MAX_REGEX_LENGTH) return null;
200
+ if (AccessControl.DANGEROUS_REGEX.test(pattern)) return null;
201
+ try {
202
+ return new RegExp(pattern);
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+ };
208
+
209
+ //#endregion
210
+ //#region src/core/BodySanitizer.ts
211
+ var BodySanitizer = class {
212
+ schemaOptions;
213
+ constructor(config) {
214
+ this.schemaOptions = config.schemaOptions;
215
+ }
216
+ /**
217
+ * Strip readonly and system-managed fields from request body.
218
+ * Prevents clients from overwriting _id, timestamps, __v, etc.
219
+ *
220
+ * Also applies field-level write permissions when the request has
221
+ * field permission metadata.
222
+ */
223
+ sanitize(body, _operation, req, meta) {
224
+ let sanitized = { ...body };
225
+ for (const field of SYSTEM_FIELDS) delete sanitized[field];
226
+ const fieldRules = this.schemaOptions.fieldRules ?? {};
227
+ for (const [field, rules] of Object.entries(fieldRules)) if (rules.systemManaged || rules.readonly) delete sanitized[field];
228
+ if (req) {
229
+ const arcContext = meta ?? req.metadata;
230
+ const scope = arcContext?._scope ?? PUBLIC_SCOPE;
231
+ if (!isElevated(scope)) {
232
+ const fieldPerms = arcContext?.arc?.fields;
233
+ if (fieldPerms) {
234
+ const effectiveRoles = resolveEffectiveRoles(getUserRoles(req.user), isMember(scope) ? scope.orgRoles : []);
235
+ sanitized = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
236
+ }
237
+ }
238
+ }
239
+ return sanitized;
240
+ }
241
+ };
242
+
243
+ //#endregion
244
+ //#region src/core/QueryResolver.ts
245
+ const defaultParser = new ArcQueryParser();
246
+ function getDefaultQueryParser() {
247
+ return defaultParser;
248
+ }
249
+ var QueryResolver = class {
250
+ queryParser;
251
+ maxLimit;
252
+ defaultLimit;
253
+ defaultSort;
254
+ schemaOptions;
255
+ tenantField;
256
+ constructor(config = {}) {
257
+ this.queryParser = config.queryParser ?? getDefaultQueryParser();
258
+ this.maxLimit = config.maxLimit ?? 100;
259
+ this.defaultLimit = config.defaultLimit ?? DEFAULT_LIMIT;
260
+ this.defaultSort = config.defaultSort ?? DEFAULT_SORT;
261
+ this.schemaOptions = config.schemaOptions ?? {};
262
+ this.tenantField = config.tenantField ?? DEFAULT_TENANT_FIELD;
263
+ }
264
+ /**
265
+ * Resolve a request into parsed query options -- ONE parse per request.
266
+ * Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
267
+ */
268
+ resolve(req, meta) {
269
+ const parsed = this.queryParser.parse(req.query);
270
+ const arcContext = meta ?? req.metadata;
271
+ delete parsed.filters?._policyFilters;
272
+ const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
273
+ const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
274
+ const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.defaultSort;
275
+ const selectString = this.selectToString(parsed.select) ?? req.query?.select;
276
+ const filters = { ...parsed.filters };
277
+ const policyFilters = arcContext?._policyFilters;
278
+ if (policyFilters) Object.assign(filters, policyFilters);
279
+ const scope = arcContext?._scope;
280
+ const orgId = scope ? getOrgId(scope) : void 0;
281
+ if (orgId && !policyFilters?.[this.tenantField]) filters[this.tenantField] = orgId;
282
+ return {
283
+ page,
284
+ limit,
285
+ sort: sortString,
286
+ select: this.sanitizeSelect(selectString, this.schemaOptions),
287
+ populate: this.sanitizePopulate(parsed.populate, this.schemaOptions),
288
+ populateOptions: parsed.populateOptions,
289
+ filters,
290
+ search: parsed.search,
291
+ after: parsed.after,
292
+ user: req.user,
293
+ context: arcContext
294
+ };
295
+ }
296
+ /**
297
+ * Convert parsed select object to string format
298
+ * Converts { name: 1, email: 1, password: 0 } -> 'name email -password'
299
+ */
300
+ selectToString(select) {
301
+ if (!select) return void 0;
302
+ if (typeof select === "string") return select;
303
+ if (Array.isArray(select)) return select.join(" ");
304
+ if (Object.keys(select).length === 0) return void 0;
305
+ return Object.entries(select).map(([field, include]) => include === 0 ? `-${field}` : field).join(" ");
306
+ }
307
+ /** Sanitize select fields */
308
+ sanitizeSelect(select, schemaOptions) {
309
+ if (!select) return void 0;
310
+ const blockedFields = this.getBlockedFields(schemaOptions);
311
+ if (blockedFields.length === 0) return select;
312
+ const sanitized = select.split(/[\s,]+/).filter(Boolean).filter((f) => {
313
+ const fieldName = f.replace(/^-/, "");
314
+ return !blockedFields.includes(fieldName);
315
+ });
316
+ return sanitized.length > 0 ? sanitized.join(" ") : void 0;
317
+ }
318
+ /** Sanitize populate fields */
319
+ sanitizePopulate(populate, schemaOptions) {
320
+ if (!populate) return void 0;
321
+ const allowedPopulate = schemaOptions.query?.allowedPopulate;
322
+ const requested = typeof populate === "string" ? populate.split(",").map((p) => p.trim()) : Array.isArray(populate) ? populate.map(String) : [];
323
+ if (requested.length === 0) return void 0;
324
+ if (!allowedPopulate) return requested;
325
+ const sanitized = requested.filter((p) => allowedPopulate.includes(p));
326
+ return sanitized.length > 0 ? sanitized : void 0;
327
+ }
328
+ /** Get blocked fields from schema options */
329
+ getBlockedFields(schemaOptions) {
330
+ const fieldRules = schemaOptions.fieldRules ?? {};
331
+ return Object.entries(fieldRules).filter(([, rules]) => rules.systemManaged || rules.hidden).map(([field]) => field);
332
+ }
333
+ };
334
+
335
+ //#endregion
336
+ //#region src/core/BaseController.ts
337
+ /**
338
+ * Framework-agnostic base controller implementing IController.
339
+ *
340
+ * Composes AccessControl, BodySanitizer, and QueryResolver for clean
341
+ * separation of concerns. CRUD methods delegate directly to these
342
+ * composed classes — no intermediate wrapper methods.
343
+ *
344
+ * @template TDoc - The document type
345
+ * @template TRepository - The repository type (defaults to RepositoryLike)
346
+ */
347
+ var BaseController = class {
348
+ repository;
349
+ schemaOptions;
350
+ queryParser;
351
+ maxLimit;
352
+ defaultLimit;
353
+ defaultSort;
354
+ resourceName;
355
+ tenantField;
356
+ idField = DEFAULT_ID_FIELD;
357
+ /** Composable access control (ID filtering, policy checks, org scope, ownership) */
358
+ accessControl;
359
+ /** Composable body sanitization (field permissions, system fields) */
360
+ bodySanitizer;
361
+ /** Composable query resolution (parsing, pagination, sort, select/populate) */
362
+ queryResolver;
363
+ _matchesFilter;
364
+ _presetFields = {};
365
+ _cacheConfig;
366
+ constructor(repository, options = {}) {
367
+ this.repository = repository;
368
+ this.schemaOptions = options.schemaOptions ?? {};
369
+ this.queryParser = options.queryParser ?? getDefaultQueryParser();
370
+ this.maxLimit = options.maxLimit ?? 100;
371
+ this.defaultLimit = options.defaultLimit ?? DEFAULT_LIMIT;
372
+ this.defaultSort = options.defaultSort ?? DEFAULT_SORT;
373
+ this.resourceName = options.resourceName;
374
+ this.tenantField = options.tenantField ?? DEFAULT_TENANT_FIELD;
375
+ this.idField = options.idField ?? DEFAULT_ID_FIELD;
376
+ this._matchesFilter = options.matchesFilter;
377
+ if (options.cache) this._cacheConfig = options.cache;
378
+ if (options.presetFields) this._presetFields = options.presetFields;
379
+ this.accessControl = new AccessControl({
380
+ tenantField: this.tenantField,
381
+ idField: this.idField,
382
+ matchesFilter: this._matchesFilter
383
+ });
384
+ this.bodySanitizer = new BodySanitizer({ schemaOptions: this.schemaOptions });
385
+ this.queryResolver = new QueryResolver({
386
+ queryParser: this.queryParser,
387
+ maxLimit: this.maxLimit,
388
+ defaultLimit: this.defaultLimit,
389
+ defaultSort: this.defaultSort,
390
+ schemaOptions: this.schemaOptions,
391
+ tenantField: this.tenantField
392
+ });
393
+ this.list = this.list.bind(this);
394
+ this.get = this.get.bind(this);
395
+ this.create = this.create.bind(this);
396
+ this.update = this.update.bind(this);
397
+ this.delete = this.delete.bind(this);
398
+ }
399
+ /** Extract typed Arc internal metadata from request */
400
+ meta(req) {
401
+ return req.metadata;
402
+ }
403
+ /** Get hook system from request context (instance-scoped) */
404
+ getHooks(req) {
405
+ return this.meta(req)?.arc?.hooks ?? null;
406
+ }
407
+ /** Resolve cache config for a specific operation, merging per-op overrides */
408
+ resolveCacheConfig(operation) {
409
+ const cfg = this._cacheConfig;
410
+ if (!cfg || cfg.disabled) return null;
411
+ const opOverride = cfg[operation];
412
+ return {
413
+ staleTime: opOverride?.staleTime ?? cfg.staleTime ?? 0,
414
+ gcTime: opOverride?.gcTime ?? cfg.gcTime ?? 60,
415
+ tags: cfg.tags
416
+ };
417
+ }
418
+ /** Extract user/org IDs from request for cache key scoping */
419
+ cacheScope(req) {
420
+ const userId = getUserId(req.user);
421
+ const scope = this.meta(req)?._scope;
422
+ return {
423
+ userId,
424
+ orgId: scope ? getOrgId(scope) : void 0
425
+ };
426
+ }
427
+ async list(req) {
428
+ const options = this.queryResolver.resolve(req, this.meta(req));
429
+ const cacheConfig = this.resolveCacheConfig("list");
430
+ const qc = req.server?.queryCache;
431
+ if (cacheConfig && qc) {
432
+ const version = await qc.getResourceVersion(this.resourceName);
433
+ const { userId, orgId } = this.cacheScope(req);
434
+ const key = buildQueryKey(this.resourceName, "list", version, options, userId, orgId);
435
+ const { data, status } = await qc.get(key);
436
+ if (status === "fresh") return {
437
+ success: true,
438
+ data,
439
+ status: 200,
440
+ headers: { "x-cache": "HIT" }
441
+ };
442
+ if (status === "stale") {
443
+ setImmediate(() => {
444
+ this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
445
+ });
446
+ return {
447
+ success: true,
448
+ data,
449
+ status: 200,
450
+ headers: { "x-cache": "STALE" }
451
+ };
452
+ }
453
+ const result = await this.executeListQuery(options, req);
454
+ await qc.set(key, result, cacheConfig);
455
+ return {
456
+ success: true,
457
+ data: result,
458
+ status: 200,
459
+ headers: { "x-cache": "MISS" }
460
+ };
461
+ }
462
+ return {
463
+ success: true,
464
+ data: await this.executeListQuery(options, req),
465
+ status: 200
466
+ };
467
+ }
468
+ /** Execute list query through hooks (extracted for cache revalidation) */
469
+ async executeListQuery(options, req) {
470
+ const hooks = this.getHooks(req);
471
+ const repoGetAll = async () => this.repository.getAll(options);
472
+ const result = hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "list", options, repoGetAll, {
473
+ user: req.user,
474
+ context: this.meta(req)
475
+ }) : await repoGetAll();
476
+ if (Array.isArray(result)) return {
477
+ docs: result,
478
+ page: 1,
479
+ limit: result.length,
480
+ total: result.length,
481
+ pages: 1,
482
+ hasNext: false,
483
+ hasPrev: false
484
+ };
485
+ return result;
486
+ }
487
+ async get(req) {
488
+ const id = req.params.id;
489
+ if (!id) return {
490
+ success: false,
491
+ error: "ID parameter is required",
492
+ status: 400
493
+ };
494
+ const options = this.queryResolver.resolve(req, this.meta(req));
495
+ const cacheConfig = this.resolveCacheConfig("byId");
496
+ const qc = req.server?.queryCache;
497
+ if (cacheConfig && qc) {
498
+ const version = await qc.getResourceVersion(this.resourceName);
499
+ const { userId, orgId } = this.cacheScope(req);
500
+ const key = buildQueryKey(this.resourceName, "get", version, {
501
+ id,
502
+ ...options
503
+ }, userId, orgId);
504
+ const { data, status } = await qc.get(key);
505
+ if (status === "fresh") return {
506
+ success: true,
507
+ data,
508
+ status: 200,
509
+ headers: { "x-cache": "HIT" }
510
+ };
511
+ if (status === "stale") {
512
+ setImmediate(() => {
513
+ this.executeGetQuery(id, options, req).then((fresh) => {
514
+ if (fresh) qc.set(key, fresh, cacheConfig);
515
+ }).catch(() => {});
516
+ });
517
+ return {
518
+ success: true,
519
+ data,
520
+ status: 200,
521
+ headers: { "x-cache": "STALE" }
522
+ };
523
+ }
524
+ const item = await this.executeGetQuery(id, options, req);
525
+ if (!item) return {
526
+ success: false,
527
+ error: "Resource not found",
528
+ status: 404
529
+ };
530
+ await qc.set(key, item, cacheConfig);
531
+ return {
532
+ success: true,
533
+ data: item,
534
+ status: 200,
535
+ headers: { "x-cache": "MISS" }
536
+ };
537
+ }
538
+ try {
539
+ const item = await this.executeGetQuery(id, options, req);
540
+ if (!item) return {
541
+ success: false,
542
+ error: "Resource not found",
543
+ status: 404
544
+ };
545
+ return {
546
+ success: true,
547
+ data: item,
548
+ status: 200
549
+ };
550
+ } catch (error) {
551
+ if (error instanceof Error && error.message?.includes("not found")) return {
552
+ success: false,
553
+ error: "Resource not found",
554
+ status: 404
555
+ };
556
+ throw error;
557
+ }
558
+ }
559
+ /** Execute get query through hooks (extracted for cache revalidation) */
560
+ async executeGetQuery(id, options, req) {
561
+ const hooks = this.getHooks(req);
562
+ const fetchItem = async () => this.accessControl.fetchWithAccessControl(id, req, this.repository, options);
563
+ return (hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "read", null, fetchItem, {
564
+ user: req.user,
565
+ context: this.meta(req)
566
+ }) : await fetchItem()) ?? null;
567
+ }
568
+ async create(req) {
569
+ const arcContext = this.meta(req);
570
+ const data = this.bodySanitizer.sanitize(req.body ?? {}, "create", req, arcContext);
571
+ const scope = arcContext?._scope;
572
+ const createOrgId = scope ? getOrgId(scope) : void 0;
573
+ if (createOrgId) data[this.tenantField] = createOrgId;
574
+ const userId = getUserId(req.user);
575
+ if (userId) data.createdBy = userId;
576
+ const hooks = this.getHooks(req);
577
+ const user = req.user;
578
+ let processedData = data;
579
+ if (hooks && this.resourceName) try {
580
+ processedData = await hooks.executeBefore(this.resourceName, "create", data, {
581
+ user,
582
+ context: arcContext
583
+ });
584
+ } catch (err) {
585
+ return {
586
+ success: false,
587
+ error: "Hook execution failed",
588
+ details: {
589
+ code: "BEFORE_CREATE_HOOK_ERROR",
590
+ message: err.message
591
+ },
592
+ status: 400
593
+ };
594
+ }
595
+ const repoCreate = async () => this.repository.create(processedData, {
596
+ user,
597
+ context: arcContext
598
+ });
599
+ let item;
600
+ if (hooks && this.resourceName) {
601
+ item = await hooks.executeAround(this.resourceName, "create", processedData, repoCreate, {
602
+ user,
603
+ context: arcContext
604
+ });
605
+ await hooks.executeAfter(this.resourceName, "create", item, {
606
+ user,
607
+ context: arcContext
608
+ });
609
+ } else item = await repoCreate();
610
+ return {
611
+ success: true,
612
+ data: item,
613
+ status: 201,
614
+ meta: { message: "Created successfully" }
615
+ };
616
+ }
617
+ async update(req) {
618
+ const id = req.params.id;
619
+ if (!id) return {
620
+ success: false,
621
+ error: "ID parameter is required",
622
+ status: 400
623
+ };
624
+ const arcContext = this.meta(req);
625
+ const data = this.bodySanitizer.sanitize(req.body ?? {}, "update", req, arcContext);
626
+ const user = req.user;
627
+ const userId = getUserId(user);
628
+ if (userId) data.updatedBy = userId;
629
+ const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
630
+ if (!existing) return {
631
+ success: false,
632
+ error: "Resource not found",
633
+ status: 404
634
+ };
635
+ if (!this.accessControl.checkOwnership(existing, req)) return {
636
+ success: false,
637
+ error: "You do not have permission to modify this resource",
638
+ details: { code: "OWNERSHIP_DENIED" },
639
+ status: 403
640
+ };
641
+ const hooks = this.getHooks(req);
642
+ let processedData = data;
643
+ if (hooks && this.resourceName) try {
644
+ processedData = await hooks.executeBefore(this.resourceName, "update", data, {
645
+ user,
646
+ context: arcContext,
647
+ meta: {
648
+ id,
649
+ existing
650
+ }
651
+ });
652
+ } catch (err) {
653
+ return {
654
+ success: false,
655
+ error: "Hook execution failed",
656
+ details: {
657
+ code: "BEFORE_UPDATE_HOOK_ERROR",
658
+ message: err.message
659
+ },
660
+ status: 400
661
+ };
662
+ }
663
+ const repoUpdate = async () => this.repository.update(id, processedData, {
664
+ user,
665
+ context: arcContext
666
+ });
667
+ let item;
668
+ if (hooks && this.resourceName) {
669
+ item = await hooks.executeAround(this.resourceName, "update", processedData, repoUpdate, {
670
+ user,
671
+ context: arcContext,
672
+ meta: {
673
+ id,
674
+ existing
675
+ }
676
+ });
677
+ if (item) await hooks.executeAfter(this.resourceName, "update", item, {
678
+ user,
679
+ context: arcContext,
680
+ meta: {
681
+ id,
682
+ existing
683
+ }
684
+ });
685
+ } else item = await repoUpdate();
686
+ if (!item) return {
687
+ success: false,
688
+ error: "Resource not found",
689
+ status: 404
690
+ };
691
+ return {
692
+ success: true,
693
+ data: item,
694
+ status: 200,
695
+ meta: { message: "Updated successfully" }
696
+ };
697
+ }
698
+ async delete(req) {
699
+ const id = req.params.id;
700
+ if (!id) return {
701
+ success: false,
702
+ error: "ID parameter is required",
703
+ status: 400
704
+ };
705
+ const arcContext = this.meta(req);
706
+ const user = req.user;
707
+ const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
708
+ if (!existing) return {
709
+ success: false,
710
+ error: "Resource not found",
711
+ status: 404
712
+ };
713
+ if (!this.accessControl.checkOwnership(existing, req)) return {
714
+ success: false,
715
+ error: "You do not have permission to delete this resource",
716
+ details: { code: "OWNERSHIP_DENIED" },
717
+ status: 403
718
+ };
719
+ const hooks = this.getHooks(req);
720
+ if (hooks && this.resourceName) try {
721
+ await hooks.executeBefore(this.resourceName, "delete", existing, {
722
+ user,
723
+ context: arcContext,
724
+ meta: { id }
725
+ });
726
+ } catch (err) {
727
+ return {
728
+ success: false,
729
+ error: "Hook execution failed",
730
+ details: {
731
+ code: "BEFORE_DELETE_HOOK_ERROR",
732
+ message: err.message
733
+ },
734
+ status: 400
735
+ };
736
+ }
737
+ const repoDelete = async () => this.repository.delete(id, {
738
+ user,
739
+ context: arcContext
740
+ });
741
+ let result;
742
+ if (hooks && this.resourceName) result = await hooks.executeAround(this.resourceName, "delete", existing, repoDelete, {
743
+ user,
744
+ context: arcContext,
745
+ meta: { id }
746
+ });
747
+ else result = await repoDelete();
748
+ if (!(typeof result === "object" && result !== null ? result.success : result)) return {
749
+ success: false,
750
+ error: "Resource not found",
751
+ status: 404
752
+ };
753
+ if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "delete", existing, {
754
+ user,
755
+ context: arcContext,
756
+ meta: { id }
757
+ });
758
+ return {
759
+ success: true,
760
+ data: { message: "Deleted successfully" },
761
+ status: 200
762
+ };
763
+ }
764
+ async getBySlug(req) {
765
+ const repo = this.repository;
766
+ if (!repo.getBySlug) return {
767
+ success: false,
768
+ error: "Slug lookup not implemented",
769
+ status: 501
770
+ };
771
+ const slugField = this._presetFields.slugField ?? "slug";
772
+ const slug = req.params[slugField] ?? req.params.slug;
773
+ const options = this.queryResolver.resolve(req, this.meta(req));
774
+ const arcContext = this.meta(req);
775
+ const item = await repo.getBySlug(slug, options);
776
+ if (!item || !this.accessControl.checkOrgScope(item, arcContext)) return {
777
+ success: false,
778
+ error: "Resource not found",
779
+ status: 404
780
+ };
781
+ return {
782
+ success: true,
783
+ data: item,
784
+ status: 200
785
+ };
786
+ }
787
+ async getDeleted(req) {
788
+ const repo = this.repository;
789
+ if (!repo.getDeleted) return {
790
+ success: false,
791
+ error: "Soft delete not implemented",
792
+ status: 501
793
+ };
794
+ const options = this.queryResolver.resolve(req, this.meta(req));
795
+ const result = await repo.getDeleted(options);
796
+ if (Array.isArray(result)) return {
797
+ success: true,
798
+ data: {
799
+ docs: result,
800
+ page: 1,
801
+ limit: result.length,
802
+ total: result.length,
803
+ pages: 1,
804
+ hasNext: false,
805
+ hasPrev: false
806
+ },
807
+ status: 200
808
+ };
809
+ return {
810
+ success: true,
811
+ data: result,
812
+ status: 200
813
+ };
814
+ }
815
+ async restore(req) {
816
+ const repo = this.repository;
817
+ if (!repo.restore) return {
818
+ success: false,
819
+ error: "Restore not implemented",
820
+ status: 501
821
+ };
822
+ const id = req.params.id;
823
+ if (!id) return {
824
+ success: false,
825
+ error: "ID parameter is required",
826
+ status: 400
827
+ };
828
+ const item = await repo.restore(id);
829
+ if (!item) return {
830
+ success: false,
831
+ error: "Resource not found",
832
+ status: 404
833
+ };
834
+ return {
835
+ success: true,
836
+ data: item,
837
+ status: 200,
838
+ meta: { message: "Restored successfully" }
839
+ };
840
+ }
841
+ async getTree(req) {
842
+ const repo = this.repository;
843
+ if (!repo.getTree) return {
844
+ success: false,
845
+ error: "Tree structure not implemented",
846
+ status: 501
847
+ };
848
+ const options = this.queryResolver.resolve(req, this.meta(req));
849
+ return {
850
+ success: true,
851
+ data: await repo.getTree(options),
852
+ status: 200
853
+ };
854
+ }
855
+ async getChildren(req) {
856
+ const repo = this.repository;
857
+ if (!repo.getChildren) return {
858
+ success: false,
859
+ error: "Tree structure not implemented",
860
+ status: 501
861
+ };
862
+ const parentField = this._presetFields.parentField ?? "parent";
863
+ const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
864
+ const options = this.queryResolver.resolve(req, this.meta(req));
865
+ return {
866
+ success: true,
867
+ data: await repo.getChildren(parentId, options),
868
+ status: 200
869
+ };
870
+ }
871
+ };
872
+
873
+ //#endregion
874
+ //#region src/core/fastifyAdapter.ts
875
+ /** Type guard for Mongoose-like documents with toObject() */
876
+ function isMongooseDoc(obj) {
877
+ return !!obj && typeof obj === "object" && "toObject" in obj && typeof obj.toObject === "function";
878
+ }
879
+ /**
880
+ * Apply field mask to a single object
881
+ * Filters fields based on include/exclude rules
882
+ */
883
+ function applyFieldMaskToObject(obj, fieldMask) {
884
+ if (!obj || typeof obj !== "object") return obj;
885
+ const plain = isMongooseDoc(obj) ? obj.toObject() : obj;
886
+ const { include, exclude } = fieldMask;
887
+ if (include && include.length > 0) {
888
+ const filtered = {};
889
+ for (const field of include) if (field in plain) filtered[field] = plain[field];
890
+ return filtered;
891
+ }
892
+ if (exclude && exclude.length > 0) {
893
+ const filtered = { ...plain };
894
+ for (const field of exclude) delete filtered[field];
895
+ return filtered;
896
+ }
897
+ return plain;
898
+ }
899
+ /**
900
+ * Apply field mask to response data (handles both objects and arrays)
901
+ */
902
+ function applyFieldMask(data, fieldMask) {
903
+ if (!fieldMask) return data;
904
+ if (Array.isArray(data)) return data.map((item) => applyFieldMaskToObject(item, fieldMask));
905
+ if (data && typeof data === "object") return applyFieldMaskToObject(data, fieldMask);
906
+ return data;
907
+ }
908
+ /**
909
+ * Create IRequestContext from Fastify request
910
+ *
911
+ * Extracts framework-agnostic context from Fastify-specific request object
912
+ */
913
+ function createRequestContext(req) {
914
+ const reqWithExtras = req;
915
+ const requestContext = reqWithExtras.context ?? {};
916
+ const srv = req.server;
917
+ const serverAccessor = {
918
+ events: srv && "events" in srv ? srv.events : void 0,
919
+ audit: srv && "audit" in srv ? srv.audit : void 0,
920
+ queryCache: srv && "queryCache" in srv ? srv.queryCache : void 0,
921
+ log: req.log
922
+ };
923
+ return {
924
+ query: reqWithExtras.query ?? {},
925
+ body: reqWithExtras.body ?? {},
926
+ params: reqWithExtras.params ?? {},
927
+ headers: reqWithExtras.headers,
928
+ user: reqWithExtras.user ? (() => {
929
+ const user = reqWithExtras.user;
930
+ const rawId = user._id ?? user.id;
931
+ const normalizedId = rawId ? String(rawId) : void 0;
932
+ return {
933
+ ...user,
934
+ id: normalizedId,
935
+ _id: normalizedId
936
+ };
937
+ })() : null,
938
+ context: requestContext,
939
+ metadata: {
940
+ ...reqWithExtras.context,
941
+ arc: reqWithExtras.arc,
942
+ _scope: reqWithExtras.scope,
943
+ _ownershipCheck: reqWithExtras._ownershipCheck,
944
+ _policyFilters: reqWithExtras._policyFilters ?? {},
945
+ log: reqWithExtras.log
946
+ },
947
+ server: serverAccessor
948
+ };
949
+ }
950
+ /**
951
+ * Get typed auth context from an IRequestContext.
952
+ * Use this in controller overrides to access request context.
953
+ *
954
+ * For org scope, use `getControllerScope(req)` instead.
955
+ */
956
+ function getControllerContext(req) {
957
+ return req.context ?? req.metadata ?? {};
958
+ }
959
+ /**
960
+ * Get request scope from an IRequestContext.
961
+ * Returns the RequestScope set by auth adapters.
962
+ */
963
+ function getControllerScope(req) {
964
+ return req.metadata?._scope ?? PUBLIC_SCOPE;
965
+ }
966
+ /**
967
+ * Compute per-field capability metadata for the current user.
968
+ * Only includes fields that have restrictions — unrestricted fields
969
+ * are omitted (frontend defaults to { readable: true, writable: true }).
970
+ */
971
+ function computeFieldCapabilities(fieldPerms, effectiveRoles) {
972
+ const caps = {};
973
+ for (const [field, perm] of Object.entries(fieldPerms)) {
974
+ let readable = true;
975
+ let writable = true;
976
+ switch (perm._type) {
977
+ case "hidden":
978
+ readable = false;
979
+ writable = false;
980
+ break;
981
+ case "visibleTo":
982
+ readable = perm.roles?.some((r) => effectiveRoles.includes(r)) ?? false;
983
+ break;
984
+ case "writableBy":
985
+ writable = perm.roles?.some((r) => effectiveRoles.includes(r)) ?? false;
986
+ break;
987
+ }
988
+ caps[field] = {
989
+ readable,
990
+ writable
991
+ };
992
+ }
993
+ return caps;
994
+ }
995
+ /**
996
+ * Send IControllerResponse via Fastify reply
997
+ *
998
+ * Converts framework-agnostic response to Fastify response
999
+ * Applies field masking if specified in request
1000
+ */
1001
+ function sendControllerResponse(reply, response, request) {
1002
+ const reqWithExtras = request;
1003
+ const fieldMaskConfig = reqWithExtras?.fieldMask;
1004
+ const arcMeta = reqWithExtras?.arc;
1005
+ const scope = reqWithExtras?.scope ?? PUBLIC_SCOPE;
1006
+ const fieldPerms = isElevated(scope) ? void 0 : arcMeta?.fields;
1007
+ const effectiveRoles = fieldPerms ? resolveEffectiveRoles(getUserRoles(reqWithExtras?.user), isMember(scope) ? scope.orgRoles : []) : [];
1008
+ const fieldCaps = fieldPerms ? computeFieldCapabilities(fieldPerms, effectiveRoles) : void 0;
1009
+ const hasFieldRestrictions = !!(fieldMaskConfig || fieldPerms);
1010
+ /** Apply both field mask and field-level permissions to a data item */
1011
+ const applyPermissions = (data) => {
1012
+ let result = fieldMaskConfig ? applyFieldMask(data, fieldMaskConfig) : data;
1013
+ if (fieldPerms && result && typeof result === "object") if (Array.isArray(result)) result = result.map((item) => applyFieldReadPermissions(item, fieldPerms, effectiveRoles));
1014
+ else result = applyFieldReadPermissions(result, fieldPerms, effectiveRoles);
1015
+ return result;
1016
+ };
1017
+ if (response.headers) for (const [key, value] of Object.entries(response.headers)) reply.header(key, value);
1018
+ if (response.success && response.data && typeof response.data === "object" && "docs" in response.data) {
1019
+ const paginatedData = response.data;
1020
+ const filteredDocs = hasFieldRestrictions ? applyPermissions(paginatedData.docs) : paginatedData.docs;
1021
+ reply.code(response.status ?? 200).send({
1022
+ success: true,
1023
+ docs: filteredDocs,
1024
+ page: paginatedData.page,
1025
+ limit: paginatedData.limit,
1026
+ total: paginatedData.total,
1027
+ pages: paginatedData.pages,
1028
+ hasNext: paginatedData.hasNext,
1029
+ hasPrev: paginatedData.hasPrev,
1030
+ ...response.meta ?? {},
1031
+ ...fieldCaps ? { fieldPermissions: fieldCaps } : {}
1032
+ });
1033
+ return;
1034
+ }
1035
+ const filteredData = hasFieldRestrictions ? applyPermissions(response.data) : response.data;
1036
+ reply.code(response.status ?? (response.success ? 200 : 400)).send({
1037
+ success: response.success,
1038
+ data: filteredData,
1039
+ error: response.error,
1040
+ details: response.details,
1041
+ ...response.meta ?? {},
1042
+ ...fieldCaps ? { fieldPermissions: fieldCaps } : {}
1043
+ });
1044
+ }
1045
+ /**
1046
+ * Create Fastify route handler from IController method
1047
+ *
1048
+ * Wraps framework-agnostic controller method in Fastify-specific handler
1049
+ *
1050
+ * @example
1051
+ * ```typescript
1052
+ * const controller = new BaseController(repository);
1053
+ *
1054
+ * // Create Fastify handler
1055
+ * const listHandler = createFastifyHandler(controller.list.bind(controller));
1056
+ *
1057
+ * // Register route
1058
+ * fastify.get('/products', listHandler);
1059
+ * ```
1060
+ */
1061
+ function createFastifyHandler(controllerMethod) {
1062
+ return async (req, reply) => {
1063
+ sendControllerResponse(reply, await controllerMethod(createRequestContext(req)), req);
1064
+ };
1065
+ }
1066
+ /**
1067
+ * Create Fastify adapters for all CRUD methods of an IController
1068
+ *
1069
+ * Returns Fastify-compatible handlers for each CRUD operation
1070
+ *
1071
+ * @example
1072
+ * ```typescript
1073
+ * const controller = new BaseController(repository);
1074
+ * const handlers = createCrudHandlers(controller);
1075
+ *
1076
+ * fastify.get('/', handlers.list);
1077
+ * fastify.get('/:id', handlers.get);
1078
+ * fastify.post('/', handlers.create);
1079
+ * fastify.patch('/:id', handlers.update);
1080
+ * fastify.delete('/:id', handlers.delete);
1081
+ * ```
1082
+ */
1083
+ function createCrudHandlers(controller) {
1084
+ return {
1085
+ list: createFastifyHandler(controller.list.bind(controller)),
1086
+ get: createFastifyHandler(controller.get.bind(controller)),
1087
+ create: createFastifyHandler(controller.create.bind(controller)),
1088
+ update: createFastifyHandler(controller.update.bind(controller)),
1089
+ delete: createFastifyHandler(controller.delete.bind(controller))
1090
+ };
1091
+ }
1092
+
1093
+ //#endregion
1094
+ //#region src/pipeline/pipe.ts
1095
+ /**
1096
+ * Compose pipeline steps into an ordered array.
1097
+ * Accepts guards, transforms, and interceptors in any order.
1098
+ */
1099
+ function pipe(...steps) {
1100
+ return steps;
1101
+ }
1102
+ /**
1103
+ * Check if a step applies to the given operation.
1104
+ */
1105
+ function appliesTo(step, operation) {
1106
+ if (!step.operations || step.operations.length === 0) return true;
1107
+ return step.operations.includes(operation);
1108
+ }
1109
+ /**
1110
+ * Execute a pipeline against a request context.
1111
+ *
1112
+ * This is the core runtime that createCrudRouter uses to execute pipelines.
1113
+ * External usage is not needed — this is wired automatically when `pipe` is set.
1114
+ *
1115
+ * @param steps - Pipeline steps to execute
1116
+ * @param ctx - The pipeline context (extends IRequestContext)
1117
+ * @param handler - The actual controller method to call
1118
+ * @param operation - The CRUD operation name
1119
+ * @returns The controller response (possibly modified by interceptors)
1120
+ */
1121
+ async function executePipeline(steps, ctx, handler, operation) {
1122
+ const guards = [];
1123
+ const transforms = [];
1124
+ const interceptors = [];
1125
+ for (const step of steps) {
1126
+ if (!appliesTo(step, operation)) continue;
1127
+ switch (step._type) {
1128
+ case "guard":
1129
+ guards.push(step);
1130
+ break;
1131
+ case "transform":
1132
+ transforms.push(step);
1133
+ break;
1134
+ case "interceptor":
1135
+ interceptors.push(step);
1136
+ break;
1137
+ }
1138
+ }
1139
+ for (const g of guards) if (!await g.handler(ctx)) throw new ForbiddenError(`Guard '${g.name}' denied access`);
1140
+ let currentCtx = ctx;
1141
+ for (const t of transforms) {
1142
+ const result = await t.handler(currentCtx);
1143
+ if (result) currentCtx = result;
1144
+ }
1145
+ let chain = () => handler(currentCtx);
1146
+ for (let i = interceptors.length - 1; i >= 0; i--) {
1147
+ const interceptor = interceptors[i];
1148
+ const next = chain;
1149
+ chain = () => interceptor.handler(currentCtx, next);
1150
+ }
1151
+ return chain();
1152
+ }
1153
+
1154
+ //#endregion
1155
+ //#region src/core/createCrudRouter.ts
1156
+ /**
1157
+ * Build per-route rate limit config object.
1158
+ *
1159
+ * Returns a `config` object suitable for Fastify's `route()` options,
1160
+ * or `undefined` if no rate limit is configured for this resource.
1161
+ *
1162
+ * - `RateLimitConfig` object -> apply that limit to the route
1163
+ * - `false` -> explicitly disable rate limiting for the route
1164
+ * - `undefined` -> no override (inherits instance-level config)
1165
+ */
1166
+ function buildRateLimitConfig(rateLimit) {
1167
+ if (rateLimit === void 0) return void 0;
1168
+ if (rateLimit === false) return { rateLimit: false };
1169
+ return { rateLimit: {
1170
+ max: rateLimit.max,
1171
+ timeWindow: rateLimit.timeWindow
1172
+ } };
1173
+ }
1174
+ /**
1175
+ * Check if a permission requires authentication
1176
+ *
1177
+ * A permission requires auth if:
1178
+ * - It exists AND
1179
+ * - It doesn't have _isPublic flag set to true
1180
+ *
1181
+ * This is used to automatically add fastify.authenticate
1182
+ * to the preHandler chain for non-public routes.
1183
+ */
1184
+ function requiresAuthentication(permission) {
1185
+ if (!permission) return false;
1186
+ return !permission._isPublic;
1187
+ }
1188
+ /**
1189
+ * Build authentication middleware
1190
+ *
1191
+ * - Protected routes (requireAuth, requireRoles, etc.): uses fastify.authenticate (fails without token)
1192
+ * - Public routes (allowPublic): uses fastify.optionalAuthenticate (parses token if present, doesn't fail)
1193
+ *
1194
+ * This ensures request.user is populated on public routes when a Bearer token is sent,
1195
+ * enabling downstream middleware (e.g. multiTenant flexible filter) to apply org-scoped queries.
1196
+ */
1197
+ function buildAuthMiddleware(fastify, permission) {
1198
+ if (requiresAuthentication(permission)) return fastify.authenticate ?? null;
1199
+ return fastify.optionalAuthenticate ?? null;
1200
+ }
1201
+ /**
1202
+ * Build permission middleware from PermissionCheck function
1203
+ *
1204
+ * Creates a Fastify preHandler that:
1205
+ * 1. Executes the permission check
1206
+ * 2. Returns 401 if authentication required but user absent
1207
+ * 3. Returns 403 if permission denied
1208
+ * 4. Applies query filters from PermissionResult if present
1209
+ */
1210
+ function buildPermissionMiddleware(permissionCheck, resourceName, action) {
1211
+ if (!permissionCheck) return null;
1212
+ return async (request, reply) => {
1213
+ const reqWithExtras = request;
1214
+ const params = request.params;
1215
+ const context = {
1216
+ user: reqWithExtras.user ?? null,
1217
+ request,
1218
+ resource: resourceName,
1219
+ action,
1220
+ resourceId: params?.id,
1221
+ params,
1222
+ data: request.body
1223
+ };
1224
+ let result;
1225
+ try {
1226
+ result = await permissionCheck(context);
1227
+ } catch (err) {
1228
+ request.log?.warn?.({
1229
+ err,
1230
+ resource: resourceName,
1231
+ action
1232
+ }, "Permission check threw");
1233
+ reply.code(403).send({
1234
+ success: false,
1235
+ error: "Permission denied"
1236
+ });
1237
+ return;
1238
+ }
1239
+ if (typeof result === "boolean") {
1240
+ if (!result) {
1241
+ reply.code(context.user ? 403 : 401).send({
1242
+ success: false,
1243
+ error: context.user ? "Permission denied" : "Authentication required"
1244
+ });
1245
+ return;
1246
+ }
1247
+ return;
1248
+ }
1249
+ const permResult = result;
1250
+ if (!permResult.granted) {
1251
+ reply.code(context.user ? 403 : 401).send({
1252
+ success: false,
1253
+ error: permResult.reason ?? (context.user ? "Permission denied" : "Authentication required")
1254
+ });
1255
+ return;
1256
+ }
1257
+ if (permResult.filters) reqWithExtras._policyFilters = {
1258
+ ...reqWithExtras._policyFilters ?? {},
1259
+ ...permResult.filters
1260
+ };
1261
+ };
1262
+ }
1263
+ /**
1264
+ * Create additional routes from preset/custom definitions
1265
+ */
1266
+ function createAdditionalRoutes(fastify, routes, controller, options) {
1267
+ const { tag, resourceName, arcDecorator, rateLimitConfig, cacheMw, idempotencyMw, pipeline } = options;
1268
+ for (const route of routes) {
1269
+ const opName = route.operation ?? (typeof route.handler === "string" ? route.handler : `${route.method.toLowerCase()}${route.path.replace(/[/:]/g, "_")}`);
1270
+ let handler;
1271
+ if (typeof route.handler === "string") {
1272
+ if (!controller) throw new Error(`Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller. Either provide a controller or use a function handler instead.`);
1273
+ const method = controller[route.handler];
1274
+ if (typeof method !== "function") throw new Error(`Handler '${route.handler}' not found on controller`);
1275
+ const boundMethod = method.bind(controller);
1276
+ if (route.wrapHandler) {
1277
+ const steps = pipeline ? resolvePipelineSteps(pipeline, opName) : [];
1278
+ if (steps.length > 0) handler = createPipelineHandler(boundMethod, steps, opName, resourceName);
1279
+ else handler = createFastifyHandler(boundMethod);
1280
+ } else handler = boundMethod;
1281
+ } else if (route.wrapHandler) {
1282
+ const steps = pipeline ? resolvePipelineSteps(pipeline, opName) : [];
1283
+ if (steps.length > 0) handler = createPipelineHandler(route.handler, steps, opName, resourceName);
1284
+ else handler = createFastifyHandler(route.handler);
1285
+ } else handler = route.handler;
1286
+ const routeTags = route.tags ?? (tag ? [tag] : void 0);
1287
+ const convertedSchema = route.schema ? convertRouteSchema(route.schema) : void 0;
1288
+ const schema = {
1289
+ ...routeTags ? { tags: routeTags } : {},
1290
+ ...route.summary ? { summary: route.summary } : {},
1291
+ ...route.description ? { description: route.description } : {},
1292
+ ...convertedSchema ?? {}
1293
+ };
1294
+ const authMw = buildAuthMiddleware(fastify, route.permissions);
1295
+ const permissionMw = buildPermissionMiddleware(route.permissions, resourceName, opName);
1296
+ const customPreHandlers = typeof route.preHandler === "function" ? route.preHandler(fastify) : route.preHandler ?? [];
1297
+ const preHandler = [
1298
+ arcDecorator,
1299
+ authMw,
1300
+ permissionMw,
1301
+ route.method === "GET" ? cacheMw : [
1302
+ "POST",
1303
+ "PUT",
1304
+ "PATCH"
1305
+ ].includes(route.method) ? idempotencyMw : null,
1306
+ ...customPreHandlers
1307
+ ].filter(Boolean);
1308
+ fastify.route({
1309
+ method: route.method,
1310
+ url: route.path,
1311
+ schema,
1312
+ preHandler: preHandler.length > 0 ? preHandler : void 0,
1313
+ handler,
1314
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
1315
+ });
1316
+ }
1317
+ }
1318
+ /**
1319
+ * Resolve pipeline steps for a specific operation.
1320
+ * If pipeline is a flat array, all steps are returned.
1321
+ * If it's a per-operation map, only matching steps are returned.
1322
+ */
1323
+ function resolvePipelineSteps(pipeline, operation) {
1324
+ if (!pipeline) return [];
1325
+ if (Array.isArray(pipeline)) return pipeline;
1326
+ return pipeline[operation] ?? [];
1327
+ }
1328
+ /**
1329
+ * Create a Fastify handler that wraps a controller method with pipeline execution.
1330
+ */
1331
+ function createPipelineHandler(controllerMethod, steps, operation, resourceName) {
1332
+ return async (req, reply) => {
1333
+ sendControllerResponse(reply, await executePipeline(steps, {
1334
+ ...createRequestContext(req),
1335
+ resource: resourceName,
1336
+ operation
1337
+ }, (ctx) => controllerMethod(ctx), operation), req);
1338
+ };
1339
+ }
1340
+ /**
1341
+ * Create CRUD routes for a controller
1342
+ *
1343
+ * @param fastify - Fastify instance with Arc decorators
1344
+ * @param controller - CRUD controller with handler methods
1345
+ * @param options - Router configuration
1346
+ */
1347
+ function createCrudRouter(fastify, controller, options = {}) {
1348
+ const { tag = "Resource", schemas = {}, permissions = {}, middlewares = {}, additionalRoutes = [], disableDefaultRoutes = false, disabledRoutes = [], resourceName = "unknown", schemaOptions, rateLimit, pipe: pipeline, fields: fieldPermissions, updateMethod = DEFAULT_UPDATE_METHOD } = options;
1349
+ const rateLimitConfig = buildRateLimitConfig(rateLimit);
1350
+ const cacheMw = !(fastify.hasDecorator("queryCache") && controller && typeof controller._cacheConfig !== "undefined" && controller._cacheConfig !== void 0) && fastify.hasDecorator("responseCache") ? fastify.responseCache.middleware : null;
1351
+ const idempotencyMw = fastify.hasDecorator("idempotency") ? fastify.idempotency.middleware : null;
1352
+ const arcMeta = Object.freeze({
1353
+ resourceName,
1354
+ schemaOptions,
1355
+ permissions,
1356
+ hooks: fastify.arc?.hooks,
1357
+ events: fastify.events,
1358
+ fields: fieldPermissions
1359
+ });
1360
+ const arcDecorator = async (req, _reply) => {
1361
+ req.arc = arcMeta;
1362
+ const store = requestContext.get();
1363
+ if (store) store.resourceName = resourceName;
1364
+ };
1365
+ const mw = {
1366
+ list: middlewares.list ?? [],
1367
+ get: middlewares.get ?? [],
1368
+ create: middlewares.create ?? [],
1369
+ update: middlewares.update ?? [],
1370
+ delete: middlewares.delete ?? []
1371
+ };
1372
+ const idParamsSchema = {
1373
+ type: "object",
1374
+ properties: { id: { type: "string" } },
1375
+ required: ["id"]
1376
+ };
1377
+ const defaultSchemas = getDefaultCrudSchemas();
1378
+ /**
1379
+ * Build route schema by merging: base (tags/summary) → defaults (response/querystring) → user overrides.
1380
+ * User-provided schemas always take precedence. Defaults enable fast-json-stringify when no user schema is set.
1381
+ */
1382
+ const buildSchema = (base, defaults, userSchema) => ({
1383
+ ...defaults,
1384
+ ...base,
1385
+ ...userSchema ?? {}
1386
+ });
1387
+ let handlers;
1388
+ if (!disableDefaultRoutes) {
1389
+ if (!controller) throw new Error("Controller is required when disableDefaultRoutes is not true. Provide a controller or use defineResource which auto-creates BaseController.");
1390
+ const ctrl = controller;
1391
+ if (pipeline) {
1392
+ const ops = CRUD_OPERATIONS;
1393
+ const wrapped = {};
1394
+ for (const op of ops) {
1395
+ const steps = resolvePipelineSteps(pipeline, op);
1396
+ if (steps.length > 0) wrapped[op] = createPipelineHandler(ctrl[op].bind(ctrl), steps, op, resourceName);
1397
+ }
1398
+ handlers = {
1399
+ ...createCrudHandlers(ctrl),
1400
+ ...wrapped
1401
+ };
1402
+ } else handlers = createCrudHandlers(ctrl);
1403
+ }
1404
+ if (!disableDefaultRoutes && handlers) {
1405
+ if (!disabledRoutes.includes("list")) {
1406
+ const listPreHandler = [
1407
+ arcDecorator,
1408
+ buildAuthMiddleware(fastify, permissions.list),
1409
+ buildPermissionMiddleware(permissions.list, resourceName, "list"),
1410
+ cacheMw,
1411
+ ...mw.list
1412
+ ].filter(Boolean);
1413
+ fastify.route({
1414
+ method: "GET",
1415
+ url: "/",
1416
+ schema: buildSchema({
1417
+ tags: [tag],
1418
+ summary: `List ${tag}`
1419
+ }, defaultSchemas.list, schemas.list),
1420
+ preHandler: listPreHandler.length > 0 ? listPreHandler : void 0,
1421
+ handler: handlers.list,
1422
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
1423
+ });
1424
+ }
1425
+ if (!disabledRoutes.includes("get")) {
1426
+ const getPreHandler = [
1427
+ arcDecorator,
1428
+ buildAuthMiddleware(fastify, permissions.get),
1429
+ buildPermissionMiddleware(permissions.get, resourceName, "get"),
1430
+ cacheMw,
1431
+ ...mw.get
1432
+ ].filter(Boolean);
1433
+ fastify.route({
1434
+ method: "GET",
1435
+ url: "/:id",
1436
+ schema: buildSchema({
1437
+ tags: [tag],
1438
+ summary: `Get ${tag} by ID`,
1439
+ params: idParamsSchema
1440
+ }, defaultSchemas.get, schemas.get),
1441
+ preHandler: getPreHandler.length > 0 ? getPreHandler : void 0,
1442
+ handler: handlers.get,
1443
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
1444
+ });
1445
+ }
1446
+ if (!disabledRoutes.includes("create")) {
1447
+ const createPreHandler = [
1448
+ arcDecorator,
1449
+ buildAuthMiddleware(fastify, permissions.create),
1450
+ buildPermissionMiddleware(permissions.create, resourceName, "create"),
1451
+ idempotencyMw,
1452
+ ...mw.create
1453
+ ].filter(Boolean);
1454
+ fastify.route({
1455
+ method: "POST",
1456
+ url: "/",
1457
+ schema: buildSchema({
1458
+ tags: [tag],
1459
+ summary: `Create ${tag}`
1460
+ }, defaultSchemas.create, schemas.create),
1461
+ preHandler: createPreHandler.length > 0 ? createPreHandler : void 0,
1462
+ handler: handlers.create,
1463
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
1464
+ });
1465
+ }
1466
+ if (!disabledRoutes.includes("update")) {
1467
+ const updateMethods = updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod];
1468
+ const updatePreHandler = [
1469
+ arcDecorator,
1470
+ buildAuthMiddleware(fastify, permissions.update),
1471
+ buildPermissionMiddleware(permissions.update, resourceName, "update"),
1472
+ idempotencyMw,
1473
+ ...mw.update
1474
+ ].filter(Boolean);
1475
+ for (const method of updateMethods) fastify.route({
1476
+ method,
1477
+ url: "/:id",
1478
+ schema: buildSchema({
1479
+ tags: [tag],
1480
+ summary: `${method === "PUT" ? "Replace" : "Update"} ${tag}`,
1481
+ params: idParamsSchema
1482
+ }, defaultSchemas.update, schemas.update),
1483
+ preHandler: updatePreHandler.length > 0 ? updatePreHandler : void 0,
1484
+ handler: handlers.update,
1485
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
1486
+ });
1487
+ }
1488
+ if (!disabledRoutes.includes("delete")) {
1489
+ const deletePreHandler = [
1490
+ arcDecorator,
1491
+ buildAuthMiddleware(fastify, permissions.delete),
1492
+ buildPermissionMiddleware(permissions.delete, resourceName, "delete"),
1493
+ ...mw.delete
1494
+ ].filter(Boolean);
1495
+ fastify.route({
1496
+ method: "DELETE",
1497
+ url: "/:id",
1498
+ schema: buildSchema({
1499
+ tags: [tag],
1500
+ summary: `Delete ${tag}`,
1501
+ params: idParamsSchema
1502
+ }, defaultSchemas.delete, schemas.delete),
1503
+ preHandler: deletePreHandler.length > 0 ? deletePreHandler : void 0,
1504
+ handler: handlers.delete,
1505
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
1506
+ });
1507
+ }
1508
+ }
1509
+ if (additionalRoutes.length > 0) createAdditionalRoutes(fastify, additionalRoutes, controller, {
1510
+ tag,
1511
+ resourceName,
1512
+ arcDecorator,
1513
+ rateLimitConfig,
1514
+ cacheMw,
1515
+ idempotencyMw,
1516
+ pipeline
1517
+ });
1518
+ }
1519
+ /**
1520
+ * Create permission middleware from PermissionCheck
1521
+ * Useful for custom route registration
1522
+ */
1523
+ function createPermissionMiddleware(permission, resourceName, action) {
1524
+ return buildPermissionMiddleware(permission, resourceName, action);
1525
+ }
1526
+
1527
+ //#endregion
1528
+ //#region src/core/createActionRouter.ts
1529
+ /**
1530
+ * Create action-based state transition endpoint
1531
+ *
1532
+ * Registers: POST /:id/action
1533
+ * Body: { action: string, ...actionData }
1534
+ *
1535
+ * @param fastify - Fastify instance
1536
+ * @param config - Action router configuration
1537
+ */
1538
+ function createActionRouter(fastify, config) {
1539
+ const { tag, actions, actionPermissions = {}, actionSchemas = {}, globalAuth, idempotencyService, onError } = config;
1540
+ const actionEnum = Object.keys(actions);
1541
+ if (actionEnum.length === 0) {
1542
+ fastify.log.warn("[createActionRouter] No actions defined, skipping route creation");
1543
+ return;
1544
+ }
1545
+ const bodyProperties = { action: {
1546
+ type: "string",
1547
+ enum: actionEnum,
1548
+ description: `Action to perform: ${actionEnum.join(" | ")}`
1549
+ } };
1550
+ Object.entries(actionSchemas).forEach(([actionName, schema]) => {
1551
+ if (schema && typeof schema === "object") Object.entries(schema).forEach(([propName, propSchema]) => {
1552
+ bodyProperties[propName] = {
1553
+ ...propSchema,
1554
+ description: `${propSchema.description || ""} (for ${actionName} action)`.trim()
1555
+ };
1556
+ });
1557
+ });
1558
+ const routeSchema = {
1559
+ tags: tag ? [tag] : void 0,
1560
+ summary: `Perform action (${actionEnum.join("/")})`,
1561
+ description: buildActionDescription(actions, actionPermissions),
1562
+ params: {
1563
+ type: "object",
1564
+ properties: { id: {
1565
+ type: "string",
1566
+ description: "Resource ID"
1567
+ } },
1568
+ required: ["id"]
1569
+ },
1570
+ body: {
1571
+ type: "object",
1572
+ properties: bodyProperties,
1573
+ required: ["action"]
1574
+ },
1575
+ response: {
1576
+ 200: {
1577
+ type: "object",
1578
+ properties: {
1579
+ success: { type: "boolean" },
1580
+ data: { type: "object" }
1581
+ }
1582
+ },
1583
+ 400: {
1584
+ type: "object",
1585
+ properties: {
1586
+ success: { type: "boolean" },
1587
+ error: { type: "string" }
1588
+ }
1589
+ },
1590
+ 403: {
1591
+ type: "object",
1592
+ properties: {
1593
+ success: { type: "boolean" },
1594
+ error: { type: "string" }
1595
+ }
1596
+ }
1597
+ }
1598
+ };
1599
+ const preHandler = [];
1600
+ const hasPublicActions = Object.entries(actionPermissions).some(([, p]) => p?._isPublic) || globalAuth && globalAuth?._isPublic;
1601
+ const hasProtectedActions = Object.entries(actionPermissions).some(([, p]) => !p?._isPublic) || globalAuth && !globalAuth?._isPublic;
1602
+ if (hasProtectedActions && !hasPublicActions && fastify.authenticate) preHandler.push(fastify.authenticate);
1603
+ fastify.post("/:id/action", {
1604
+ schema: routeSchema,
1605
+ preHandler: preHandler.length ? preHandler : void 0
1606
+ }, async (req, reply) => {
1607
+ const { action, ...data } = req.body;
1608
+ const { id } = req.params;
1609
+ const rawIdempotencyKey = req.headers["idempotency-key"];
1610
+ const idempotencyKey = Array.isArray(rawIdempotencyKey) ? rawIdempotencyKey[0] : rawIdempotencyKey;
1611
+ const handler = actions[action];
1612
+ if (!handler) return reply.code(400).send({
1613
+ success: false,
1614
+ error: `Invalid action '${action}'. Valid actions: ${actionEnum.join(", ")}`,
1615
+ validActions: actionEnum
1616
+ });
1617
+ const permissionCheck = actionPermissions[action] ?? globalAuth;
1618
+ if (hasPublicActions && hasProtectedActions && permissionCheck) {
1619
+ if (!permissionCheck?._isPublic && fastify.authenticate) {
1620
+ try {
1621
+ await fastify.authenticate(req, reply);
1622
+ } catch {
1623
+ if (!reply.sent) return reply.code(401).send({
1624
+ success: false,
1625
+ error: "Authentication required"
1626
+ });
1627
+ return;
1628
+ }
1629
+ if (reply.sent) return;
1630
+ }
1631
+ }
1632
+ if (permissionCheck) {
1633
+ const context = {
1634
+ user: req.user ?? null,
1635
+ request: req,
1636
+ resource: tag ?? "action",
1637
+ action,
1638
+ resourceId: id,
1639
+ params: req.params,
1640
+ data
1641
+ };
1642
+ let result;
1643
+ try {
1644
+ result = await permissionCheck(context);
1645
+ } catch (err) {
1646
+ req.log?.warn?.({
1647
+ err,
1648
+ resource: tag ?? "action",
1649
+ action
1650
+ }, "Permission check threw");
1651
+ return reply.code(403).send({
1652
+ success: false,
1653
+ error: "Permission denied"
1654
+ });
1655
+ }
1656
+ if (typeof result === "boolean") {
1657
+ if (!result) return reply.code(context.user ? 403 : 401).send({
1658
+ success: false,
1659
+ error: context.user ? `Permission denied for '${action}'` : "Authentication required"
1660
+ });
1661
+ } else {
1662
+ const permResult = result;
1663
+ if (!permResult.granted) return reply.code(context.user ? 403 : 401).send({
1664
+ success: false,
1665
+ error: permResult.reason ?? (context.user ? `Permission denied for '${action}'` : "Authentication required")
1666
+ });
1667
+ }
1668
+ }
1669
+ try {
1670
+ if (idempotencyKey && idempotencyService) {
1671
+ const user = req.user;
1672
+ const payloadForHash = {
1673
+ action,
1674
+ id,
1675
+ data,
1676
+ userId: (user?._id)?.toString?.() || user?.id || null
1677
+ };
1678
+ const idempotencyResult = await idempotencyService.check(idempotencyKey, payloadForHash);
1679
+ if (!idempotencyResult.isNew && "existingResult" in idempotencyResult) return reply.send({
1680
+ success: true,
1681
+ data: idempotencyResult.existingResult,
1682
+ cached: true
1683
+ });
1684
+ }
1685
+ const result = await handler(id, data, req);
1686
+ if (idempotencyService) await idempotencyService.complete(idempotencyKey, result);
1687
+ return reply.send({
1688
+ success: true,
1689
+ data: result
1690
+ });
1691
+ } catch (error) {
1692
+ if (idempotencyService) await idempotencyService.fail(idempotencyKey, error);
1693
+ if (onError) {
1694
+ const { statusCode, error: errorMsg, code } = onError(error, action, id);
1695
+ return reply.code(statusCode).send({
1696
+ success: false,
1697
+ error: errorMsg,
1698
+ code
1699
+ });
1700
+ }
1701
+ const err = error;
1702
+ const statusCode = err.statusCode || err.status || 500;
1703
+ const errorCode = err.code || "ACTION_FAILED";
1704
+ if (statusCode >= 500) req.log.error({
1705
+ err: error,
1706
+ action,
1707
+ id
1708
+ }, "Action handler error");
1709
+ return reply.code(statusCode).send({
1710
+ success: false,
1711
+ error: err.message || `Failed to execute '${action}' action`,
1712
+ code: errorCode
1713
+ });
1714
+ }
1715
+ });
1716
+ fastify.log.debug({
1717
+ actions: actionEnum,
1718
+ tag
1719
+ }, "[createActionRouter] Registered action endpoint: POST /:id/action");
1720
+ }
1721
+ /**
1722
+ * Build description with action details
1723
+ * Uses _roles metadata from PermissionCheck functions for OpenAPI docs
1724
+ */
1725
+ function buildActionDescription(actions, actionPermissions) {
1726
+ const lines = ["Unified action endpoint for state transitions.\n\n**Available actions:**"];
1727
+ Object.keys(actions).forEach((action) => {
1728
+ const roles = actionPermissions[action]?._roles;
1729
+ const roleStr = roles?.length ? ` (requires: ${roles.join(" or ")})` : "";
1730
+ lines.push(`- \`${action}\`${roleStr}`);
1731
+ });
1732
+ return lines.join("\n");
1733
+ }
1734
+
1735
+ //#endregion
1736
+ //#region src/core/validateResourceConfig.ts
1737
+ /**
1738
+ * Validate a resource configuration
1739
+ */
1740
+ function validateResourceConfig(config, options = {}) {
1741
+ const errors = [];
1742
+ const warnings = [];
1743
+ if (!config.name) errors.push({
1744
+ field: "name",
1745
+ message: "Resource name is required",
1746
+ suggestion: "Add a unique resource name (e.g., \"product\", \"user\")"
1747
+ });
1748
+ else if (!/^[a-z][a-z0-9-]*$/i.test(config.name)) errors.push({
1749
+ field: "name",
1750
+ message: `Invalid resource name "${config.name}"`,
1751
+ suggestion: "Use alphanumeric characters and hyphens, starting with a letter"
1752
+ });
1753
+ const crudRoutes = CRUD_OPERATIONS;
1754
+ const disabledRoutes = new Set(config.disabledRoutes ?? []);
1755
+ const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
1756
+ if (!config.disableDefaultRoutes && enabledCrudRoutes.length > 0) {
1757
+ if (!config.adapter) errors.push({
1758
+ field: "adapter",
1759
+ message: "Data adapter is required when CRUD routes are enabled",
1760
+ suggestion: "Provide an adapter: createMongooseAdapter({ model, repository })"
1761
+ });
1762
+ else if (!config.adapter.repository) errors.push({
1763
+ field: "adapter.repository",
1764
+ message: "Adapter must provide a repository",
1765
+ suggestion: "Ensure your adapter returns a valid CrudRepository"
1766
+ });
1767
+ } else if (!config.adapter && !config.additionalRoutes?.length) warnings.push({
1768
+ field: "config",
1769
+ message: "Resource has no adapter and no additionalRoutes",
1770
+ suggestion: "Provide either adapter for CRUD or additionalRoutes for custom logic"
1771
+ });
1772
+ if (config.controller && !options.skipControllerCheck && !config.disableDefaultRoutes) {
1773
+ const ctrl = config.controller;
1774
+ const requiredMethods = CRUD_OPERATIONS;
1775
+ for (const method of requiredMethods) if (typeof ctrl[method] !== "function") errors.push({
1776
+ field: `controller.${method}`,
1777
+ message: `Missing required CRUD method "${method}"`,
1778
+ suggestion: "Extend BaseController which implements IController interface"
1779
+ });
1780
+ }
1781
+ if (config.controller && config.additionalRoutes) validateAdditionalRouteHandlers(config.controller, config.additionalRoutes, errors);
1782
+ if (config.permissions) validatePermissionKeys(config, options, errors, warnings);
1783
+ if (config.presets && !options.allowUnknownPresets) validatePresets(config.presets, errors, warnings);
1784
+ if (config.prefix) {
1785
+ if (!config.prefix.startsWith("/")) errors.push({
1786
+ field: "prefix",
1787
+ message: `Prefix must start with "/" (got "${config.prefix}")`,
1788
+ suggestion: `Change to "/${config.prefix}"`
1789
+ });
1790
+ if (config.prefix.endsWith("/") && config.prefix !== "/") warnings.push({
1791
+ field: "prefix",
1792
+ message: `Prefix should not end with "/" (got "${config.prefix}")`,
1793
+ suggestion: `Change to "${config.prefix.slice(0, -1)}"`
1794
+ });
1795
+ }
1796
+ if (config.additionalRoutes) validateAdditionalRoutes(config.additionalRoutes, errors);
1797
+ return {
1798
+ valid: errors.length === 0,
1799
+ errors,
1800
+ warnings
1801
+ };
1802
+ }
1803
+ function validateAdditionalRouteHandlers(controller, routes, errors) {
1804
+ const ctrl = controller;
1805
+ for (const route of routes) if (typeof route.handler === "string") {
1806
+ if (typeof ctrl[route.handler] !== "function") errors.push({
1807
+ field: `additionalRoutes[${route.method} ${route.path}]`,
1808
+ message: `Handler "${route.handler}" not found on controller`,
1809
+ suggestion: `Add method "${route.handler}" to controller or use a function handler`
1810
+ });
1811
+ }
1812
+ }
1813
+ function validatePermissionKeys(config, options, errors, warnings) {
1814
+ const validKeys = new Set([...CRUD_OPERATIONS, ...options.additionalPermissionKeys ?? []]);
1815
+ for (const route of config.additionalRoutes ?? []) if (typeof route.handler === "string") validKeys.add(route.handler);
1816
+ for (const preset of config.presets ?? []) {
1817
+ const presetName = typeof preset === "string" ? preset : preset.name;
1818
+ if (presetName === "softDelete") {
1819
+ validKeys.add("deleted");
1820
+ validKeys.add("restore");
1821
+ }
1822
+ if (presetName === "slugLookup") validKeys.add("getBySlug");
1823
+ if (presetName === "tree") {
1824
+ validKeys.add("tree");
1825
+ validKeys.add("children");
1826
+ validKeys.add("getTree");
1827
+ validKeys.add("getChildren");
1828
+ }
1829
+ }
1830
+ for (const key of Object.keys(config.permissions ?? {})) if (!validKeys.has(key)) warnings.push({
1831
+ field: `permissions.${key}`,
1832
+ message: `Unknown permission key "${key}"`,
1833
+ suggestion: `Valid keys: ${Array.from(validKeys).join(", ")}`
1834
+ });
1835
+ }
1836
+ function validatePresets(presets, errors, warnings) {
1837
+ const availablePresets = getAvailablePresets();
1838
+ for (const preset of presets) {
1839
+ if (typeof preset === "object" && ("middlewares" in preset || "additionalRoutes" in preset)) continue;
1840
+ const presetName = typeof preset === "string" ? preset : preset.name;
1841
+ if (!availablePresets.includes(presetName)) errors.push({
1842
+ field: "presets",
1843
+ message: `Unknown preset "${presetName}"`,
1844
+ suggestion: `Available presets: ${availablePresets.join(", ")}`
1845
+ });
1846
+ if (typeof preset === "object") validatePresetOptions(preset, warnings);
1847
+ }
1848
+ }
1849
+ function validatePresetOptions(preset, warnings) {
1850
+ const validOptions = {
1851
+ slugLookup: ["slugField"],
1852
+ tree: ["parentField"],
1853
+ softDelete: ["deletedField"],
1854
+ ownedByUser: ["ownerField"],
1855
+ multiTenant: ["tenantField", "allowPublic"]
1856
+ }[preset.name] ?? [];
1857
+ const providedOptions = Object.keys(preset).filter((k) => k !== "name");
1858
+ for (const opt of providedOptions) if (!validOptions.includes(opt)) warnings.push({
1859
+ field: `presets[${preset.name}].${opt}`,
1860
+ message: `Unknown option "${opt}" for preset "${preset.name}"`,
1861
+ suggestion: validOptions.length > 0 ? `Valid options: ${validOptions.join(", ")}` : `Preset "${preset.name}" has no configurable options`
1862
+ });
1863
+ }
1864
+ function validateAdditionalRoutes(routes, errors) {
1865
+ const validMethods = [
1866
+ "GET",
1867
+ "POST",
1868
+ "PUT",
1869
+ "PATCH",
1870
+ "DELETE",
1871
+ "OPTIONS",
1872
+ "HEAD"
1873
+ ];
1874
+ const seenRoutes = /* @__PURE__ */ new Set();
1875
+ for (const [i, route] of routes.entries()) {
1876
+ if (!validMethods.includes(route.method)) errors.push({
1877
+ field: `additionalRoutes[${i}].method`,
1878
+ message: `Invalid HTTP method "${route.method}"`,
1879
+ suggestion: `Valid methods: ${validMethods.join(", ")}`
1880
+ });
1881
+ if (!route.path) errors.push({
1882
+ field: `additionalRoutes[${i}].path`,
1883
+ message: "Route path is required"
1884
+ });
1885
+ else if (!route.path.startsWith("/")) errors.push({
1886
+ field: `additionalRoutes[${i}].path`,
1887
+ message: `Route path must start with "/" (got "${route.path}")`,
1888
+ suggestion: `Change to "/${route.path}"`
1889
+ });
1890
+ if (!route.handler) errors.push({
1891
+ field: `additionalRoutes[${i}].handler`,
1892
+ message: "Route handler is required"
1893
+ });
1894
+ const routeKey = `${route.method} ${route.path}`;
1895
+ if (seenRoutes.has(routeKey)) errors.push({
1896
+ field: `additionalRoutes[${i}]`,
1897
+ message: `Duplicate route "${routeKey}"`
1898
+ });
1899
+ seenRoutes.add(routeKey);
1900
+ }
1901
+ }
1902
+ /**
1903
+ * Format validation errors for display
1904
+ */
1905
+ function formatValidationErrors(resourceName, result) {
1906
+ const lines = [];
1907
+ if (result.errors.length > 0) {
1908
+ lines.push(`Resource "${resourceName}" validation failed:`);
1909
+ lines.push("");
1910
+ lines.push("ERRORS:");
1911
+ for (const err of result.errors) {
1912
+ lines.push(` ✗ ${err.field}: ${err.message}`);
1913
+ if (err.suggestion) lines.push(` → ${err.suggestion}`);
1914
+ }
1915
+ }
1916
+ if (result.warnings.length > 0) {
1917
+ if (lines.length > 0) lines.push("");
1918
+ lines.push("WARNINGS:");
1919
+ for (const warn of result.warnings) {
1920
+ lines.push(` ⚠ ${warn.field}: ${warn.message}`);
1921
+ if (warn.suggestion) lines.push(` → ${warn.suggestion}`);
1922
+ }
1923
+ }
1924
+ return lines.join("\n");
1925
+ }
1926
+ /**
1927
+ * Validate and throw if invalid
1928
+ */
1929
+ function assertValidConfig(config, options) {
1930
+ const result = validateResourceConfig(config, options);
1931
+ if (!result.valid) {
1932
+ const errorMsg = formatValidationErrors(config.name ?? "unknown", result);
1933
+ throw new Error(errorMsg);
1934
+ }
1935
+ if (result.warnings.length > 0 && process.env.NODE_ENV !== "production") console.warn(formatValidationErrors(config.name ?? "unknown", {
1936
+ valid: true,
1937
+ errors: [],
1938
+ warnings: result.warnings
1939
+ }));
1940
+ }
1941
+
1942
+ //#endregion
1943
+ //#region src/core/defineResource.ts
1944
+ /**
1945
+ * Define a resource with database adapter
1946
+ *
1947
+ * This is the MAIN entry point for creating Arc resources.
1948
+ * The adapter provides both repository and schema metadata.
1949
+ */
1950
+ function defineResource(config) {
1951
+ if (!config.skipValidation) {
1952
+ assertValidConfig(config, { skipControllerCheck: true });
1953
+ if (config.permissions) {
1954
+ for (const [key, value] of Object.entries(config.permissions)) if (value !== void 0 && typeof value !== "function") throw new Error(`[Arc] Resource '${config.name}': permissions.${key} must be a PermissionCheck function.\nUse allowPublic(), requireAuth(), or requireRoles(['role']) from @classytic/arc/permissions.`);
1955
+ }
1956
+ for (const route of config.additionalRoutes ?? []) {
1957
+ if (typeof route.permissions !== "function") throw new Error(`[Arc] Resource '${config.name}' route ${route.method} ${route.path}: permissions is required and must be a PermissionCheck function.\nUse allowPublic() or requireAuth() from @classytic/arc/permissions.`);
1958
+ if (typeof route.wrapHandler !== "boolean") throw new Error(`[Arc] Resource '${config.name}' route ${route.method} ${route.path}: wrapHandler is required.\nSet true for ControllerHandler (context object) or false for FastifyHandler (req, reply).`);
1959
+ }
1960
+ }
1961
+ const repository = config.adapter?.repository;
1962
+ const crudRoutes = CRUD_OPERATIONS;
1963
+ const disabledRoutes = new Set(config.disabledRoutes ?? []);
1964
+ const hasCrudRoutes = !config.disableDefaultRoutes && crudRoutes.some((route) => !disabledRoutes.has(route));
1965
+ const originalPresets = (config.presets ?? []).map((p) => typeof p === "string" ? p : p.name);
1966
+ const resolvedConfig = config.presets?.length ? applyPresets(config, config.presets) : config;
1967
+ resolvedConfig._appliedPresets = originalPresets;
1968
+ let controller = resolvedConfig.controller;
1969
+ if (!controller && hasCrudRoutes && repository) controller = new BaseController(repository, {
1970
+ resourceName: resolvedConfig.name,
1971
+ schemaOptions: resolvedConfig.schemaOptions,
1972
+ queryParser: resolvedConfig.queryParser,
1973
+ tenantField: resolvedConfig.tenantField,
1974
+ idField: resolvedConfig.idField,
1975
+ matchesFilter: config.adapter?.matchesFilter,
1976
+ cache: resolvedConfig.cache,
1977
+ presetFields: resolvedConfig._controllerOptions ? {
1978
+ slugField: resolvedConfig._controllerOptions.slugField,
1979
+ parentField: resolvedConfig._controllerOptions.parentField
1980
+ } : void 0
1981
+ });
1982
+ const resource = new ResourceDefinition({
1983
+ ...resolvedConfig,
1984
+ adapter: config.adapter,
1985
+ controller
1986
+ });
1987
+ if (!config.skipValidation && controller) resource._validateControllerMethods();
1988
+ if (resolvedConfig._hooks?.length) resource._pendingHooks.push(...resolvedConfig._hooks.map((hook) => ({
1989
+ operation: hook.operation,
1990
+ phase: hook.phase,
1991
+ handler: hook.handler,
1992
+ priority: hook.priority ?? 10
1993
+ })));
1994
+ if (!config.skipRegistry) try {
1995
+ let openApiSchemas = config.openApiSchemas;
1996
+ if (!openApiSchemas && config.adapter?.generateSchemas) {
1997
+ const generated = config.adapter.generateSchemas(config.schemaOptions);
1998
+ if (generated) openApiSchemas = generated;
1999
+ }
2000
+ const queryParser = config.queryParser;
2001
+ if (!openApiSchemas?.listQuery && queryParser?.getQuerySchema) {
2002
+ const querySchema = queryParser.getQuerySchema();
2003
+ if (querySchema) openApiSchemas = {
2004
+ ...openApiSchemas,
2005
+ listQuery: querySchema
2006
+ };
2007
+ }
2008
+ if (openApiSchemas) openApiSchemas = convertOpenApiSchemas(openApiSchemas);
2009
+ resource._registryMeta = {
2010
+ module: config.module,
2011
+ openApiSchemas
2012
+ };
2013
+ } catch {}
2014
+ return resource;
2015
+ }
2016
+ var ResourceDefinition = class {
2017
+ name;
2018
+ displayName;
2019
+ tag;
2020
+ prefix;
2021
+ adapter;
2022
+ controller;
2023
+ schemaOptions;
2024
+ customSchemas;
2025
+ permissions;
2026
+ additionalRoutes;
2027
+ middlewares;
2028
+ disableDefaultRoutes;
2029
+ disabledRoutes;
2030
+ events;
2031
+ rateLimit;
2032
+ updateMethod;
2033
+ pipe;
2034
+ fields;
2035
+ cache;
2036
+ _appliedPresets;
2037
+ _pendingHooks;
2038
+ _registryMeta;
2039
+ constructor(config) {
2040
+ this.name = config.name;
2041
+ this.displayName = config.displayName ?? capitalize(config.name) + "s";
2042
+ this.tag = config.tag ?? this.displayName;
2043
+ this.prefix = config.prefix ?? `/${config.name}s`;
2044
+ this.adapter = config.adapter;
2045
+ this.controller = config.controller;
2046
+ this.schemaOptions = config.schemaOptions ?? {};
2047
+ this.customSchemas = config.customSchemas ?? {};
2048
+ this.permissions = config.permissions ?? {};
2049
+ this.additionalRoutes = config.additionalRoutes ?? [];
2050
+ this.middlewares = config.middlewares ?? {};
2051
+ this.disableDefaultRoutes = config.disableDefaultRoutes ?? false;
2052
+ this.disabledRoutes = config.disabledRoutes ?? [];
2053
+ this.events = config.events ?? {};
2054
+ this.rateLimit = config.rateLimit;
2055
+ this.updateMethod = config.updateMethod;
2056
+ this.pipe = config.pipe;
2057
+ this.fields = config.fields;
2058
+ this.cache = config.cache;
2059
+ this._appliedPresets = config._appliedPresets ?? [];
2060
+ this._pendingHooks = config._pendingHooks ?? [];
2061
+ }
2062
+ /** Get repository from adapter (if available) */
2063
+ get repository() {
2064
+ return this.adapter?.repository;
2065
+ }
2066
+ _validateControllerMethods() {
2067
+ const errors = [];
2068
+ const crudRoutes = CRUD_OPERATIONS;
2069
+ const disabledRoutes = new Set(this.disabledRoutes ?? []);
2070
+ const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
2071
+ if (!this.disableDefaultRoutes && enabledCrudRoutes.length > 0) if (!this.controller) errors.push("Controller is required when CRUD routes are enabled");
2072
+ else {
2073
+ const ctrl = this.controller;
2074
+ for (const method of enabledCrudRoutes) if (typeof ctrl[method] !== "function") errors.push(`CRUD method '${method}' not found on controller`);
2075
+ }
2076
+ for (const route of this.additionalRoutes) if (typeof route.handler === "string") {
2077
+ if (!this.controller) errors.push(`Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller`);
2078
+ else if (typeof this.controller[route.handler] !== "function") errors.push(`Route ${route.method} ${route.path}: handler '${route.handler}' not found`);
2079
+ }
2080
+ if (errors.length > 0) {
2081
+ const errorMsg = [
2082
+ `Resource '${this.name}' validation failed:`,
2083
+ ...errors.map((e) => ` - ${e}`),
2084
+ "",
2085
+ "Ensure controller implements IController<TDoc> interface.",
2086
+ "For preset routes (softDelete, tree), add corresponding methods to controller."
2087
+ ].join("\n");
2088
+ throw new Error(errorMsg);
2089
+ }
2090
+ }
2091
+ toPlugin() {
2092
+ const self = this;
2093
+ return async function resourcePlugin(fastify, _opts) {
2094
+ const arc = fastify.arc;
2095
+ if (arc?.registry && self._registryMeta) try {
2096
+ arc.registry.register(self, self._registryMeta);
2097
+ } catch (err) {
2098
+ fastify.log?.warn?.(`Failed to register resource '${self.name}' in registry: ${err instanceof Error ? err.message : err}`);
2099
+ }
2100
+ if (self._pendingHooks.length > 0) {
2101
+ const arc = fastify.arc;
2102
+ if (arc?.hooks) for (const hook of self._pendingHooks) arc.hooks.register({
2103
+ resource: self.name,
2104
+ operation: hook.operation,
2105
+ phase: hook.phase,
2106
+ handler: hook.handler,
2107
+ priority: hook.priority
2108
+ });
2109
+ }
2110
+ const registerRule = fastify.registerCacheInvalidationRule;
2111
+ if (self.cache?.invalidateOn && typeof registerRule === "function") for (const [pattern, tags] of Object.entries(self.cache.invalidateOn)) registerRule({
2112
+ pattern,
2113
+ tags
2114
+ });
2115
+ await fastify.register(async (instance) => {
2116
+ const typedInstance = instance;
2117
+ let schemas = null;
2118
+ if (self.customSchemas && Object.keys(self.customSchemas).length > 0) {
2119
+ schemas = schemas ?? {};
2120
+ for (const [op, customSchema] of Object.entries(self.customSchemas)) {
2121
+ const key = op;
2122
+ const converted = convertRouteSchema(customSchema);
2123
+ schemas[key] = schemas[key] ? deepMergeSchemas(schemas[key], converted) : converted;
2124
+ }
2125
+ }
2126
+ const resolvedRoutes = self.additionalRoutes;
2127
+ createCrudRouter(typedInstance, self.controller, {
2128
+ tag: self.tag,
2129
+ schemas: schemas ?? void 0,
2130
+ permissions: self.permissions,
2131
+ middlewares: self.middlewares,
2132
+ additionalRoutes: resolvedRoutes,
2133
+ disableDefaultRoutes: self.disableDefaultRoutes,
2134
+ disabledRoutes: self.disabledRoutes,
2135
+ resourceName: self.name,
2136
+ schemaOptions: self.schemaOptions,
2137
+ rateLimit: self.rateLimit,
2138
+ updateMethod: self.updateMethod,
2139
+ pipe: self.pipe,
2140
+ fields: self.fields
2141
+ });
2142
+ if (self.events && Object.keys(self.events).length > 0) typedInstance.log?.debug?.(`Resource '${self.name}' defined ${Object.keys(self.events).length} events`);
2143
+ }, { prefix: self.prefix });
2144
+ if (hasEvents(fastify)) try {
2145
+ await fastify.events.publish("arc.resource.registered", {
2146
+ resource: self.name,
2147
+ prefix: self.prefix,
2148
+ presets: self._appliedPresets,
2149
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2150
+ });
2151
+ } catch {}
2152
+ };
2153
+ }
2154
+ /**
2155
+ * Get event definitions for registry
2156
+ */
2157
+ getEvents() {
2158
+ return Object.entries(this.events).map(([action, meta]) => ({
2159
+ name: `${this.name}:${action}`,
2160
+ module: this.name,
2161
+ schema: meta.schema,
2162
+ description: meta.description
2163
+ }));
2164
+ }
2165
+ /**
2166
+ * Get resource metadata
2167
+ */
2168
+ getMetadata() {
2169
+ return {
2170
+ name: this.name,
2171
+ displayName: this.displayName,
2172
+ tag: this.tag,
2173
+ prefix: this.prefix,
2174
+ presets: this._appliedPresets,
2175
+ permissions: this.permissions,
2176
+ additionalRoutes: this.additionalRoutes,
2177
+ routes: [],
2178
+ events: Object.keys(this.events)
2179
+ };
2180
+ }
2181
+ };
2182
+ function deepMergeSchemas(base, override) {
2183
+ if (!override) return base;
2184
+ if (!base) return override;
2185
+ const result = { ...base };
2186
+ for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [...new Set([...result[key], ...value])];
2187
+ else if (value && typeof value === "object" && !Array.isArray(value)) result[key] = deepMergeSchemas(result[key], value);
2188
+ else result[key] = value;
2189
+ return result;
2190
+ }
2191
+ function capitalize(str) {
2192
+ if (!str) return "";
2193
+ return str.charAt(0).toUpperCase() + str.slice(1);
2194
+ }
2195
+
2196
+ //#endregion
2197
+ export { QueryResolver as _, validateResourceConfig as a, createPermissionMiddleware as c, createFastifyHandler as d, createRequestContext as f, BaseController as g, sendControllerResponse as h, formatValidationErrors as i, pipe as l, getControllerScope as m, defineResource as n, createActionRouter as o, getControllerContext as p, assertValidConfig as r, createCrudRouter as s, ResourceDefinition as t, createCrudHandlers as u, BodySanitizer as v, AccessControl as y };