@classytic/arc 2.10.3 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +1 -1
  2. package/dist/{BaseController-CbKKIflT.mjs → BaseController-JNV08qOT.mjs} +595 -537
  3. package/dist/{queryCachePlugin-BKbWjgDG.d.mts → QueryCache-DOBNHBE0.d.mts} +2 -32
  4. package/dist/actionPermissions-C8YYU92K.mjs +22 -0
  5. package/dist/adapters/index.d.mts +2 -2
  6. package/dist/adapters/index.mjs +1 -1
  7. package/dist/{adapters-BXY4i-hw.mjs → adapters-D0tT2Tyo.mjs} +54 -0
  8. package/dist/audit/index.d.mts +2 -2
  9. package/dist/audit/index.mjs +15 -17
  10. package/dist/auth/index.d.mts +4 -4
  11. package/dist/auth/index.mjs +3 -3
  12. package/dist/auth/redis-session.d.mts +1 -1
  13. package/dist/{betterAuthOpenApi-BBRVhjQN.mjs → betterAuthOpenApi-DwxtK3uG.mjs} +1 -1
  14. package/dist/cache/index.d.mts +3 -2
  15. package/dist/cache/index.mjs +3 -3
  16. package/dist/cli/commands/docs.mjs +2 -2
  17. package/dist/cli/commands/generate.mjs +37 -27
  18. package/dist/cli/commands/init.mjs +47 -34
  19. package/dist/cli/commands/introspect.mjs +1 -1
  20. package/dist/context/index.d.mts +58 -0
  21. package/dist/context/index.mjs +2 -0
  22. package/dist/core/index.d.mts +3 -3
  23. package/dist/core/index.mjs +4 -3
  24. package/dist/core-DXdSSFW-.mjs +1037 -0
  25. package/dist/createActionRouter-BwaSM0No.mjs +166 -0
  26. package/dist/{createApp-BuvPma24.mjs → createApp-DvNYEhpb.mjs} +118 -36
  27. package/dist/docs/index.d.mts +2 -2
  28. package/dist/docs/index.mjs +1 -1
  29. package/dist/{elevation-C7hgL_aI.mjs → elevation-DOFoxoDs.mjs} +1 -1
  30. package/dist/errorHandler-Co3lnVmJ.d.mts +114 -0
  31. package/dist/{eventPlugin-DCUjuiQT.mjs → eventPlugin--5HIkdPU.mjs} +1 -1
  32. package/dist/{eventPlugin-CxWgpd6K.d.mts → eventPlugin-CUNjYYRY.d.mts} +1 -1
  33. package/dist/events/index.d.mts +4 -4
  34. package/dist/events/index.mjs +69 -51
  35. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  36. package/dist/events/transports/redis.d.mts +1 -1
  37. package/dist/factory/index.d.mts +1 -1
  38. package/dist/factory/index.mjs +2 -2
  39. package/dist/{fields-Lo1VUDpt.d.mts → fields-C8Y0XLAu.d.mts} +1 -1
  40. package/dist/hooks/index.d.mts +1 -1
  41. package/dist/hooks/index.mjs +1 -1
  42. package/dist/idempotency/index.d.mts +3 -3
  43. package/dist/idempotency/index.mjs +38 -27
  44. package/dist/idempotency/redis.d.mts +1 -1
  45. package/dist/{index-ChIw3776.d.mts → index-BYCqHCVu.d.mts} +4 -4
  46. package/dist/{index-Cl0uoKd5.d.mts → index-Cm0vUrr_.d.mts} +2100 -1688
  47. package/dist/{index-DStwgFUK.d.mts → index-DAushRTt.d.mts} +29 -10
  48. package/dist/index-DsJ1MNfC.d.mts +1179 -0
  49. package/dist/{index-8qw4y6ff.d.mts → index-t8pLpPFW.d.mts} +13 -10
  50. package/dist/index.d.mts +7 -251
  51. package/dist/index.mjs +8 -128
  52. package/dist/integrations/event-gateway.d.mts +2 -2
  53. package/dist/integrations/event-gateway.mjs +1 -1
  54. package/dist/integrations/index.d.mts +2 -2
  55. package/dist/integrations/mcp/index.d.mts +2 -2
  56. package/dist/integrations/mcp/index.mjs +1 -1
  57. package/dist/integrations/mcp/testing.d.mts +1 -1
  58. package/dist/integrations/mcp/testing.mjs +1 -1
  59. package/dist/integrations/streamline.d.mts +46 -5
  60. package/dist/integrations/streamline.mjs +50 -21
  61. package/dist/integrations/websocket-redis.d.mts +1 -1
  62. package/dist/integrations/websocket.d.mts +2 -154
  63. package/dist/integrations/websocket.mjs +292 -224
  64. package/dist/{keys-qcD-TVJl.mjs → keys-CARyUjiR.mjs} +2 -0
  65. package/dist/{loadResources-BAzJItAJ.mjs → loadResources-YNwKHvRA.mjs} +3 -1
  66. package/dist/logger/index.d.mts +81 -0
  67. package/dist/{logger-DLg8-Ueg.mjs → logger/index.mjs} +1 -6
  68. package/dist/middleware/index.d.mts +109 -0
  69. package/dist/middleware/index.mjs +70 -0
  70. package/dist/multipartBody-CvTR1Un6.mjs +123 -0
  71. package/dist/{openapi-B5F8AddX.mjs → openapi-C0L9ar7m.mjs} +9 -7
  72. package/dist/org/index.d.mts +2 -2
  73. package/dist/permissions/index.d.mts +2 -2
  74. package/dist/permissions/index.mjs +1 -3
  75. package/dist/{permissions-Dk6mshja.mjs → permissions-B4vU9L0Q.mjs} +220 -2
  76. package/dist/pipe-DVoIheVC.mjs +62 -0
  77. package/dist/pipeline/index.d.mts +62 -0
  78. package/dist/pipeline/index.mjs +53 -0
  79. package/dist/plugins/index.d.mts +25 -5
  80. package/dist/plugins/index.mjs +10 -10
  81. package/dist/plugins/response-cache.mjs +1 -1
  82. package/dist/plugins/tracing-entry.d.mts +1 -1
  83. package/dist/plugins/tracing-entry.mjs +42 -24
  84. package/dist/presets/filesUpload.d.mts +4 -4
  85. package/dist/presets/filesUpload.mjs +255 -1
  86. package/dist/presets/index.d.mts +1 -1
  87. package/dist/presets/index.mjs +2 -2
  88. package/dist/presets/multiTenant.d.mts +1 -1
  89. package/dist/presets/multiTenant.mjs +48 -8
  90. package/dist/presets/search.d.mts +2 -2
  91. package/dist/presets/search.mjs +1 -1
  92. package/dist/{presets-fLJVXdVn.mjs → presets-k604Lj99.mjs} +1 -1
  93. package/dist/queryCachePlugin-BUXBSm4F.d.mts +34 -0
  94. package/dist/{queryCachePlugin-DQCEfJis.mjs → queryCachePlugin-Bq6bO6vc.mjs} +3 -3
  95. package/dist/{redis-DqyeggCa.d.mts → redis-Cm1gnRDf.d.mts} +1 -1
  96. package/dist/{redis-stream-CakIQmwR.d.mts → redis-stream-CM8TXTix.d.mts} +1 -1
  97. package/dist/registry/index.d.mts +1 -1
  98. package/dist/registry/index.mjs +2 -2
  99. package/dist/{requestContext-xHIKedG6.mjs → requestContext-CfRkaxwf.mjs} +1 -1
  100. package/dist/{resourceToTools-BElv3xPT.mjs → resourceToTools--okX6QBr.mjs} +534 -415
  101. package/dist/routerShared-DeESFp4a.mjs +515 -0
  102. package/dist/schemaIR-BlG9bY7v.mjs +137 -0
  103. package/dist/scope/index.d.mts +2 -2
  104. package/dist/scope/index.mjs +1 -1
  105. package/dist/{sse-yBCgOLGu.mjs → sse-V7aXc3bW.mjs} +1 -1
  106. package/dist/{store-helpers-ZCSMJJAX.mjs → store-helpers-BhrzxvyQ.mjs} +4 -0
  107. package/dist/testing/index.d.mts +367 -711
  108. package/dist/testing/index.mjs +646 -1434
  109. package/dist/testing/storageContract.d.mts +1 -1
  110. package/dist/{tracing-65B51Dw3.d.mts → tracing-DokiEsuz.d.mts} +9 -4
  111. package/dist/types/index.d.mts +5 -5
  112. package/dist/types/index.mjs +1 -3
  113. package/dist/types/storage.d.mts +1 -1
  114. package/dist/{types-Co8k3NyS.d.mts → types-CgikqKAj.d.mts} +133 -21
  115. package/dist/{types-Btdda02s.d.mts → types-D9NqiYIw.d.mts} +1 -1
  116. package/dist/utils/index.d.mts +2 -898
  117. package/dist/utils/index.mjs +4 -5
  118. package/dist/utils-D3Yxnrwr.mjs +1639 -0
  119. package/dist/versioning-M9lNLhO8.d.mts +117 -0
  120. package/dist/websocket-CyJ1VIFI.d.mts +186 -0
  121. package/package.json +26 -8
  122. package/skills/arc/SKILL.md +124 -39
  123. package/skills/arc/references/testing.md +212 -183
  124. package/dist/applyPermissionResult-QhV1Pa-g.mjs +0 -37
  125. package/dist/core-CcR01lup.mjs +0 -1411
  126. package/dist/createActionRouter-Bp_5c_2b.mjs +0 -249
  127. package/dist/errorHandler-DRQ3EqfL.d.mts +0 -218
  128. package/dist/errors-CCSsMpXE.d.mts +0 -140
  129. package/dist/fields-bxkeltzz.mjs +0 -126
  130. package/dist/filesUpload-t21LS-py.mjs +0 -377
  131. package/dist/queryParser-DBqBB6AC.mjs +0 -352
  132. package/dist/types-Csi3FLfq.mjs +0 -27
  133. package/dist/utils-B2fNOD_i.mjs +0 -929
  134. /package/dist/{EventTransport-CUw5NNWe.d.mts → EventTransport-CfVEGaEl.d.mts} +0 -0
  135. /package/dist/{HookSystem-BNYKnrXF.mjs → HookSystem-CGsMd6oK.mjs} +0 -0
  136. /package/dist/{ResourceRegistry-BPd6NQDm.mjs → ResourceRegistry-DkAeAuTX.mjs} +0 -0
  137. /package/dist/{caching-CBpK_SCM.mjs → caching-CheW3m-S.mjs} +0 -0
  138. /package/dist/{elevation-C5SwtkAn.d.mts → elevation-s5ykdNHr.d.mts} +0 -0
  139. /package/dist/{errorHandler-Bb49BvPD.mjs → errorHandler-BQm8ZxTK.mjs} +0 -0
  140. /package/dist/{externalPaths-BQ8QijNH.d.mts → externalPaths-Bapitwvd.d.mts} +0 -0
  141. /package/dist/{interface-CSbZdv_3.d.mts → interface-CkkWm5uR.d.mts} +0 -0
  142. /package/dist/{interface-D218ikEo.d.mts → interface-Da0r7Lna.d.mts} +0 -0
  143. /package/dist/{memory-B5Amv9A1.mjs → memory-DikHSvWa.mjs} +0 -0
  144. /package/dist/{metrics-DuhiSEZI.mjs → metrics-Csh4nsvv.mjs} +0 -0
  145. /package/dist/{pluralize-A0tWEl1K.mjs → pluralize-BneOJkpi.mjs} +0 -0
  146. /package/dist/{registry-B3lRFBWo.mjs → registry-D63ee7fl.mjs} +0 -0
  147. /package/dist/{replyHelpers-CXtJDAZ0.mjs → replyHelpers-ByllIXXV.mjs} +0 -0
  148. /package/dist/{schemaConverter-BxFDdtXu.mjs → schemaConverter-B0oKLuqI.mjs} +0 -0
  149. /package/dist/{sessionManager-BkzVU8h2.d.mts → sessionManager-D-oNWHz3.d.mts} +0 -0
  150. /package/dist/{storage-CVk_SEn2.d.mts → storage-BwGQXUpd.d.mts} +0 -0
  151. /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
  152. /package/dist/{types-BD85MlEK.d.mts → types-tgR4Pt8F.d.mts} +0 -0
  153. /package/dist/{versioning-C2U_bLY0.mjs → versioning-CGPjkqAg.mjs} +0 -0
@@ -1,1411 +0,0 @@
1
- import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-BhY1OHoH.mjs";
2
- import { _ as isElevated, n as PUBLIC_SCOPE, v as isMember } from "./types-AOD8fxIw.mjs";
3
- import { t as BaseController } from "./BaseController-CbKKIflT.mjs";
4
- import { i as resolveEffectiveRoles, t as applyFieldReadPermissions } from "./fields-bxkeltzz.mjs";
5
- import { t as getUserRoles } from "./types-DV9WDfeg.mjs";
6
- import { r as ForbiddenError } from "./errors-D5c-5BJL.mjs";
7
- import { t as requestContext } from "./requestContext-xHIKedG6.mjs";
8
- import { n as normalizePermissionResult, t as applyPermissionResult } from "./applyPermissionResult-QhV1Pa-g.mjs";
9
- import { i as getDefaultCrudSchemas } from "./utils-B2fNOD_i.mjs";
10
- import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-BxFDdtXu.mjs";
11
- import { t as hasEvents } from "./typeGuards-Cj5Rgvlg.mjs";
12
- import { r as getAvailablePresets, t as applyPresets } from "./presets-fLJVXdVn.mjs";
13
- //#region src/pipeline/pipe.ts
14
- /**
15
- * Compose pipeline steps into an ordered array.
16
- * Accepts guards, transforms, and interceptors in any order.
17
- */
18
- function pipe(...steps) {
19
- return steps;
20
- }
21
- /**
22
- * Check if a step applies to the given operation.
23
- */
24
- function appliesTo(step, operation) {
25
- if (!step.operations || step.operations.length === 0) return true;
26
- return step.operations.includes(operation);
27
- }
28
- /**
29
- * Execute a pipeline against a request context.
30
- *
31
- * This is the core runtime that createCrudRouter uses to execute pipelines.
32
- * External usage is not needed — this is wired automatically when `pipe` is set.
33
- *
34
- * @param steps - Pipeline steps to execute
35
- * @param ctx - The pipeline context (extends IRequestContext)
36
- * @param handler - The actual controller method to call
37
- * @param operation - The CRUD operation name
38
- * @returns The controller response (possibly modified by interceptors)
39
- */
40
- async function executePipeline(steps, ctx, handler, operation) {
41
- const guards = [];
42
- const transforms = [];
43
- const interceptors = [];
44
- for (const step of steps) {
45
- if (!appliesTo(step, operation)) continue;
46
- switch (step._type) {
47
- case "guard":
48
- guards.push(step);
49
- break;
50
- case "transform":
51
- transforms.push(step);
52
- break;
53
- case "interceptor":
54
- interceptors.push(step);
55
- break;
56
- }
57
- }
58
- for (const g of guards) if (!await g.handler(ctx)) throw new ForbiddenError(`Guard '${g.name}' denied access`);
59
- let currentCtx = ctx;
60
- for (const t of transforms) {
61
- const result = await t.handler(currentCtx);
62
- if (result) currentCtx = result;
63
- }
64
- let chain = () => handler(currentCtx);
65
- for (let i = interceptors.length - 1; i >= 0; i--) {
66
- const interceptor = interceptors[i];
67
- const next = chain;
68
- chain = () => interceptor.handler(currentCtx, next);
69
- }
70
- return chain();
71
- }
72
- //#endregion
73
- //#region src/core/fastifyAdapter.ts
74
- /** Type guard for Mongoose-like documents with toObject() */
75
- function isMongooseDoc(obj) {
76
- return !!obj && typeof obj === "object" && "toObject" in obj && typeof obj.toObject === "function";
77
- }
78
- /**
79
- * Apply field mask to a single object
80
- * Filters fields based on include/exclude rules
81
- */
82
- function applyFieldMaskToObject(obj, fieldMask) {
83
- if (!obj || typeof obj !== "object") return obj;
84
- const plain = isMongooseDoc(obj) ? obj.toObject() : obj;
85
- const { include, exclude } = fieldMask;
86
- if (include && include.length > 0) {
87
- const filtered = {};
88
- for (const field of include) if (field in plain) filtered[field] = plain[field];
89
- return filtered;
90
- }
91
- if (exclude && exclude.length > 0) {
92
- const filtered = { ...plain };
93
- for (const field of exclude) delete filtered[field];
94
- return filtered;
95
- }
96
- return plain;
97
- }
98
- /**
99
- * Apply field mask to response data (handles both objects and arrays)
100
- */
101
- function applyFieldMask(data, fieldMask) {
102
- if (!fieldMask) return data;
103
- if (Array.isArray(data)) return data.map((item) => applyFieldMaskToObject(item, fieldMask));
104
- if (data && typeof data === "object") return applyFieldMaskToObject(data, fieldMask);
105
- return data;
106
- }
107
- /**
108
- * Create IRequestContext from Fastify request
109
- *
110
- * Extracts framework-agnostic context from Fastify-specific request object
111
- */
112
- function createRequestContext(req) {
113
- const reqWithExtras = req;
114
- const requestContext = reqWithExtras.context ?? {};
115
- const srv = req.server;
116
- const serverAccessor = {
117
- events: srv && "events" in srv ? srv.events : void 0,
118
- audit: srv && "audit" in srv ? srv.audit : void 0,
119
- queryCache: srv && "queryCache" in srv ? srv.queryCache : void 0,
120
- log: req.log
121
- };
122
- return {
123
- query: reqWithExtras.query ?? {},
124
- body: reqWithExtras.body ?? {},
125
- params: reqWithExtras.params ?? {},
126
- headers: reqWithExtras.headers,
127
- user: reqWithExtras.user ? (() => {
128
- const user = reqWithExtras.user;
129
- const rawId = user._id ?? user.id;
130
- const normalizedId = rawId ? String(rawId) : void 0;
131
- return {
132
- ...user,
133
- id: normalizedId,
134
- _id: normalizedId
135
- };
136
- })() : null,
137
- context: requestContext,
138
- metadata: {
139
- ...reqWithExtras.context,
140
- arc: reqWithExtras.arc,
141
- _scope: reqWithExtras.scope,
142
- _ownershipCheck: reqWithExtras._ownershipCheck,
143
- _policyFilters: reqWithExtras._policyFilters ?? {},
144
- log: reqWithExtras.log
145
- },
146
- server: serverAccessor
147
- };
148
- }
149
- /**
150
- * Get typed auth context from an IRequestContext.
151
- * Use this in controller overrides to access request context.
152
- *
153
- * For org scope, use `getControllerScope(req)` instead.
154
- */
155
- function getControllerContext(req) {
156
- return req.context ?? req.metadata ?? {};
157
- }
158
- /**
159
- * Get request scope from an IRequestContext.
160
- * Returns the RequestScope set by auth adapters.
161
- */
162
- function getControllerScope(req) {
163
- return req.metadata?._scope ?? PUBLIC_SCOPE;
164
- }
165
- /**
166
- * Compute per-field capability metadata for the current user.
167
- * Only includes fields that have restrictions — unrestricted fields
168
- * are omitted (frontend defaults to { readable: true, writable: true }).
169
- */
170
- function computeFieldCapabilities(fieldPerms, effectiveRoles) {
171
- const caps = {};
172
- for (const [field, perm] of Object.entries(fieldPerms)) {
173
- let readable = true;
174
- let writable = true;
175
- switch (perm._type) {
176
- case "hidden":
177
- readable = false;
178
- writable = false;
179
- break;
180
- case "visibleTo":
181
- readable = perm.roles?.some((r) => effectiveRoles.includes(r)) ?? false;
182
- break;
183
- case "writableBy":
184
- writable = perm.roles?.some((r) => effectiveRoles.includes(r)) ?? false;
185
- break;
186
- }
187
- caps[field] = {
188
- readable,
189
- writable
190
- };
191
- }
192
- return caps;
193
- }
194
- /**
195
- * Send IControllerResponse via Fastify reply
196
- *
197
- * Converts framework-agnostic response to Fastify response
198
- * Applies field masking if specified in request
199
- */
200
- function sendControllerResponse(reply, response, request) {
201
- const reqWithExtras = request;
202
- const fieldMaskConfig = reqWithExtras?.fieldMask;
203
- const arcMeta = reqWithExtras?.arc;
204
- const scope = reqWithExtras?.scope ?? PUBLIC_SCOPE;
205
- const fieldPerms = isElevated(scope) ? void 0 : arcMeta?.fields;
206
- const effectiveRoles = fieldPerms ? resolveEffectiveRoles(getUserRoles(reqWithExtras?.user), isMember(scope) ? scope.orgRoles : []) : [];
207
- const fieldCaps = fieldPerms ? computeFieldCapabilities(fieldPerms, effectiveRoles) : void 0;
208
- const hasFieldRestrictions = !!(fieldMaskConfig || fieldPerms);
209
- /** Apply both field mask and field-level permissions to a data item */
210
- const applyPermissions = (data) => {
211
- let result = fieldMaskConfig ? applyFieldMask(data, fieldMaskConfig) : data;
212
- if (fieldPerms && result && typeof result === "object") if (Array.isArray(result)) result = result.map((item) => applyFieldReadPermissions(item, fieldPerms, effectiveRoles));
213
- else result = applyFieldReadPermissions(result, fieldPerms, effectiveRoles);
214
- return result;
215
- };
216
- if (response.headers) for (const [key, value] of Object.entries(response.headers)) reply.header(key, value);
217
- if (response.success && response.data && typeof response.data === "object" && "docs" in response.data) {
218
- const paginatedData = response.data;
219
- const filteredDocs = hasFieldRestrictions ? applyPermissions(paginatedData.docs) : paginatedData.docs;
220
- reply.code(response.status ?? 200).send({
221
- success: true,
222
- docs: filteredDocs,
223
- page: paginatedData.page,
224
- limit: paginatedData.limit,
225
- total: paginatedData.total,
226
- pages: paginatedData.pages,
227
- hasNext: paginatedData.hasNext,
228
- hasPrev: paginatedData.hasPrev,
229
- ...response.meta ?? {},
230
- ...fieldCaps ? { fieldPermissions: fieldCaps } : {}
231
- });
232
- return;
233
- }
234
- const filteredData = hasFieldRestrictions ? applyPermissions(response.data) : response.data;
235
- reply.code(response.status ?? (response.success ? 200 : 400)).send({
236
- success: response.success,
237
- data: filteredData,
238
- error: response.error,
239
- details: response.details,
240
- ...response.meta ?? {},
241
- ...fieldCaps ? { fieldPermissions: fieldCaps } : {}
242
- });
243
- }
244
- /**
245
- * Create Fastify route handler from IController method
246
- *
247
- * Wraps framework-agnostic controller method in Fastify-specific handler
248
- *
249
- * @example
250
- * ```typescript
251
- * const controller = new BaseController(repository);
252
- *
253
- * // Create Fastify handler
254
- * const listHandler = createFastifyHandler(controller.list.bind(controller));
255
- *
256
- * // Register route
257
- * fastify.get('/products', listHandler);
258
- * ```
259
- */
260
- function createFastifyHandler(controllerMethod) {
261
- return async (req, reply) => {
262
- sendControllerResponse(reply, await controllerMethod(createRequestContext(req)), req);
263
- };
264
- }
265
- /**
266
- * Create Fastify adapters for all CRUD methods of an IController
267
- *
268
- * Returns Fastify-compatible handlers for each CRUD operation
269
- *
270
- * @example
271
- * ```typescript
272
- * const controller = new BaseController(repository);
273
- * const handlers = createCrudHandlers(controller);
274
- *
275
- * fastify.get('/', handlers.list);
276
- * fastify.get('/:id', handlers.get);
277
- * fastify.post('/', handlers.create);
278
- * fastify.patch('/:id', handlers.update);
279
- * fastify.delete('/:id', handlers.delete);
280
- * ```
281
- */
282
- function createCrudHandlers(controller) {
283
- return {
284
- list: createFastifyHandler(controller.list.bind(controller)),
285
- get: createFastifyHandler(controller.get.bind(controller)),
286
- create: createFastifyHandler(controller.create.bind(controller)),
287
- update: createFastifyHandler(controller.update.bind(controller)),
288
- delete: createFastifyHandler(controller.delete.bind(controller))
289
- };
290
- }
291
- //#endregion
292
- //#region src/core/createCrudRouter.ts
293
- /**
294
- * Build per-route rate limit config object.
295
- *
296
- * Returns a `config` object suitable for Fastify's `route()` options,
297
- * or `undefined` if no rate limit is configured for this resource.
298
- *
299
- * - `RateLimitConfig` object -> apply that limit to the route
300
- * - `false` -> explicitly disable rate limiting for the route
301
- * - `undefined` -> no override (inherits instance-level config)
302
- */
303
- function buildRateLimitConfig(rateLimit) {
304
- if (rateLimit === void 0) return void 0;
305
- if (rateLimit === false) return { rateLimit: false };
306
- return { rateLimit: {
307
- max: rateLimit.max,
308
- timeWindow: rateLimit.timeWindow
309
- } };
310
- }
311
- /**
312
- * Check if a permission requires authentication
313
- *
314
- * A permission requires auth if:
315
- * - It exists AND
316
- * - It doesn't have _isPublic flag set to true
317
- *
318
- * This is used to automatically add fastify.authenticate
319
- * to the preHandler chain for non-public routes.
320
- */
321
- function requiresAuthentication(permission) {
322
- if (!permission) return false;
323
- return !permission._isPublic;
324
- }
325
- /**
326
- * Build authentication middleware
327
- *
328
- * - Protected routes (requireAuth, requireRoles, etc.): uses fastify.authenticate (fails without token)
329
- * - Public routes (allowPublic): uses fastify.optionalAuthenticate (parses token if present, doesn't fail)
330
- *
331
- * This ensures request.user is populated on public routes when a Bearer token is sent,
332
- * enabling downstream middleware (e.g. multiTenant flexible filter) to apply org-scoped queries.
333
- */
334
- function buildAuthMiddleware(fastify, permission) {
335
- if (requiresAuthentication(permission)) return fastify.authenticate ?? null;
336
- return fastify.optionalAuthenticate ?? null;
337
- }
338
- /**
339
- * Build permission middleware from PermissionCheck function
340
- *
341
- * Creates a Fastify preHandler that:
342
- * 1. Executes the permission check
343
- * 2. Returns 401 if authentication required but user absent
344
- * 3. Returns 403 if permission denied
345
- * 4. Applies query filters from PermissionResult if present
346
- */
347
- function buildPermissionMiddleware(permissionCheck, resourceName, action) {
348
- if (!permissionCheck) return null;
349
- return async (request, reply) => {
350
- const reqWithExtras = request;
351
- const params = request.params;
352
- const context = {
353
- user: reqWithExtras.user ?? null,
354
- request,
355
- resource: resourceName,
356
- action,
357
- resourceId: params?.id,
358
- params,
359
- data: request.body
360
- };
361
- let result;
362
- try {
363
- result = await permissionCheck(context);
364
- } catch (err) {
365
- request.log?.warn?.({
366
- err,
367
- resource: resourceName,
368
- action
369
- }, "Permission check threw");
370
- reply.code(403).send({
371
- success: false,
372
- error: "Permission denied"
373
- });
374
- return;
375
- }
376
- const permResult = normalizePermissionResult(result);
377
- if (!permResult.granted) {
378
- const defaultMsg = context.user ? "Permission denied" : "Authentication required";
379
- const reason = permResult.reason && permResult.reason.length <= 100 ? permResult.reason : defaultMsg;
380
- reply.code(context.user ? 403 : 401).send({
381
- success: false,
382
- error: reason
383
- });
384
- return;
385
- }
386
- applyPermissionResult(permResult, request);
387
- };
388
- }
389
- /**
390
- * Mount custom routes (from presets or user-defined `routes`) on Fastify.
391
- * `wrapHandler` is derived inline from `!route.raw`.
392
- */
393
- function createCustomRoutes(fastify, routes, controller, options) {
394
- const { tag, resourceName, arcDecorator, rateLimitConfig, cacheMw, idempotencyMw, pipeline, routeGuards } = options;
395
- for (const route of routes) {
396
- const opName = route.operation ?? (typeof route.handler === "string" ? route.handler : `${route.method.toLowerCase()}${route.path.replace(/[/:]/g, "_")}`);
397
- const wrapHandler = !route.raw;
398
- let handler;
399
- if (typeof route.handler === "string") {
400
- 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.`);
401
- const method = controller[route.handler];
402
- if (typeof method !== "function") throw new Error(`Handler '${route.handler}' not found on controller`);
403
- const boundMethod = method.bind(controller);
404
- if (wrapHandler) {
405
- const steps = pipeline ? resolvePipelineSteps(pipeline, opName) : [];
406
- if (steps.length > 0) handler = createPipelineHandler(boundMethod, steps, opName, resourceName);
407
- else handler = createFastifyHandler(boundMethod);
408
- } else handler = boundMethod;
409
- } else if (wrapHandler) {
410
- const steps = pipeline ? resolvePipelineSteps(pipeline, opName) : [];
411
- if (steps.length > 0) handler = createPipelineHandler(route.handler, steps, opName, resourceName);
412
- else handler = createFastifyHandler(route.handler);
413
- } else handler = route.handler;
414
- const routeTags = route.tags ?? (tag ? [tag] : void 0);
415
- const convertedSchema = route.schema ? convertRouteSchema(route.schema) : void 0;
416
- const schema = {
417
- ...routeTags ? { tags: routeTags } : {},
418
- ...route.summary ? { summary: route.summary } : {},
419
- ...route.description ? { description: route.description } : {},
420
- ...convertedSchema ?? {}
421
- };
422
- const authMw = buildAuthMiddleware(fastify, route.permissions);
423
- const permissionMw = buildPermissionMiddleware(route.permissions, resourceName, opName);
424
- const customPreHandlers = typeof route.preHandler === "function" ? route.preHandler(fastify) : route.preHandler ?? [];
425
- const pluginMw = route.method === "GET" ? cacheMw : [
426
- "POST",
427
- "PUT",
428
- "PATCH"
429
- ].includes(route.method) ? idempotencyMw : null;
430
- const preHandler = [
431
- ...route.preAuth ?? [],
432
- arcDecorator,
433
- authMw,
434
- permissionMw,
435
- pluginMw,
436
- ...routeGuards,
437
- ...customPreHandlers
438
- ].filter(Boolean);
439
- const isStream = route.streamResponse === true;
440
- fastify.route({
441
- method: route.method,
442
- url: route.path,
443
- schema,
444
- preHandler: preHandler.length > 0 ? preHandler : void 0,
445
- handler: isStream ? async (request, reply) => {
446
- reply.raw.setHeader("Content-Type", "text/event-stream");
447
- reply.raw.setHeader("Cache-Control", "no-cache");
448
- reply.raw.setHeader("Connection", "keep-alive");
449
- return handler(request, reply);
450
- } : handler,
451
- ...rateLimitConfig ? { config: rateLimitConfig } : {}
452
- });
453
- }
454
- }
455
- /**
456
- * Resolve pipeline steps for a specific operation.
457
- * If pipeline is a flat array, all steps are returned.
458
- * If it's a per-operation map, only matching steps are returned.
459
- */
460
- function resolvePipelineSteps(pipeline, operation) {
461
- if (!pipeline) return [];
462
- if (Array.isArray(pipeline)) return pipeline;
463
- return pipeline[operation] ?? [];
464
- }
465
- /**
466
- * Create a Fastify handler that wraps a controller method with pipeline execution.
467
- */
468
- function createPipelineHandler(controllerMethod, steps, operation, resourceName) {
469
- return async (req, reply) => {
470
- sendControllerResponse(reply, await executePipeline(steps, {
471
- ...createRequestContext(req),
472
- resource: resourceName,
473
- operation
474
- }, (ctx) => controllerMethod(ctx), operation), req);
475
- };
476
- }
477
- /**
478
- * Create CRUD routes for a controller
479
- *
480
- * @param fastify - Fastify instance with Arc decorators
481
- * @param controller - CRUD controller with handler methods
482
- * @param options - Router configuration
483
- */
484
- function createCrudRouter(fastify, controller, options = {}) {
485
- const { tag = "Resource", schemas = {}, permissions = {}, middlewares = {}, routeGuards = [], routes: customRoutes = [], disableDefaultRoutes = false, disabledRoutes = [], resourceName = "unknown", schemaOptions, rateLimit, pipe: pipeline, fields: fieldPermissions, updateMethod = DEFAULT_UPDATE_METHOD } = options;
486
- const rateLimitConfig = buildRateLimitConfig(rateLimit);
487
- const cacheMw = !(fastify.hasDecorator("queryCache") && controller && typeof controller._cacheConfig !== "undefined" && controller._cacheConfig !== void 0) && fastify.hasDecorator("responseCache") ? fastify.responseCache.middleware : null;
488
- const idempotencyMw = fastify.hasDecorator("idempotency") ? fastify.idempotency.middleware : null;
489
- const arcMeta = Object.freeze({
490
- resourceName,
491
- schemaOptions,
492
- permissions,
493
- hooks: fastify.arc?.hooks,
494
- events: fastify.events,
495
- fields: fieldPermissions
496
- });
497
- const arcDecorator = async (req, _reply) => {
498
- req.arc = arcMeta;
499
- const store = requestContext.get();
500
- if (store) store.resourceName = resourceName;
501
- };
502
- const mw = {
503
- list: middlewares.list ?? [],
504
- get: middlewares.get ?? [],
505
- create: middlewares.create ?? [],
506
- update: middlewares.update ?? [],
507
- delete: middlewares.delete ?? []
508
- };
509
- const idParamsSchema = {
510
- type: "object",
511
- properties: { id: { type: "string" } },
512
- required: ["id"]
513
- };
514
- const defaultSchemas = getDefaultCrudSchemas();
515
- /**
516
- * Build route schema by merging: base (tags/summary) → defaults (response/querystring) → user overrides.
517
- * User-provided schemas always take precedence. Defaults enable fast-json-stringify when no user schema is set.
518
- */
519
- const buildSchema = (base, defaults, userSchema) => ({
520
- ...defaults,
521
- ...base,
522
- ...userSchema ?? {}
523
- });
524
- let handlers;
525
- if (!disableDefaultRoutes) {
526
- if (!controller) throw new Error("Controller is required when disableDefaultRoutes is not true. Provide a controller or use defineResource which auto-creates BaseController.");
527
- const ctrl = controller;
528
- if (pipeline) {
529
- const ops = CRUD_OPERATIONS;
530
- const wrapped = {};
531
- for (const op of ops) {
532
- const steps = resolvePipelineSteps(pipeline, op);
533
- if (steps.length > 0) wrapped[op] = createPipelineHandler(ctrl[op].bind(ctrl), steps, op, resourceName);
534
- }
535
- handlers = {
536
- ...createCrudHandlers(ctrl),
537
- ...wrapped
538
- };
539
- } else handlers = createCrudHandlers(ctrl);
540
- }
541
- if (!disableDefaultRoutes && handlers) {
542
- if (!disabledRoutes.includes("list")) {
543
- const listPreHandler = [
544
- arcDecorator,
545
- buildAuthMiddleware(fastify, permissions.list),
546
- buildPermissionMiddleware(permissions.list, resourceName, "list"),
547
- cacheMw,
548
- ...routeGuards,
549
- ...mw.list
550
- ].filter(Boolean);
551
- fastify.route({
552
- method: "GET",
553
- url: "/",
554
- schema: buildSchema({
555
- tags: [tag],
556
- summary: `List ${tag}`
557
- }, defaultSchemas.list, schemas.list),
558
- preHandler: listPreHandler.length > 0 ? listPreHandler : void 0,
559
- handler: handlers.list,
560
- ...rateLimitConfig ? { config: rateLimitConfig } : {}
561
- });
562
- }
563
- if (!disabledRoutes.includes("get")) {
564
- const getPreHandler = [
565
- arcDecorator,
566
- buildAuthMiddleware(fastify, permissions.get),
567
- buildPermissionMiddleware(permissions.get, resourceName, "get"),
568
- cacheMw,
569
- ...routeGuards,
570
- ...mw.get
571
- ].filter(Boolean);
572
- fastify.route({
573
- method: "GET",
574
- url: "/:id",
575
- schema: buildSchema({
576
- tags: [tag],
577
- summary: `Get ${tag} by ID`,
578
- params: idParamsSchema
579
- }, defaultSchemas.get, schemas.get),
580
- preHandler: getPreHandler.length > 0 ? getPreHandler : void 0,
581
- handler: handlers.get,
582
- ...rateLimitConfig ? { config: rateLimitConfig } : {}
583
- });
584
- }
585
- if (!disabledRoutes.includes("create")) {
586
- const createPreHandler = [
587
- arcDecorator,
588
- buildAuthMiddleware(fastify, permissions.create),
589
- buildPermissionMiddleware(permissions.create, resourceName, "create"),
590
- idempotencyMw,
591
- ...routeGuards,
592
- ...mw.create
593
- ].filter(Boolean);
594
- fastify.route({
595
- method: "POST",
596
- url: "/",
597
- schema: buildSchema({
598
- tags: [tag],
599
- summary: `Create ${tag}`
600
- }, defaultSchemas.create, schemas.create),
601
- preHandler: createPreHandler.length > 0 ? createPreHandler : void 0,
602
- handler: handlers.create,
603
- ...rateLimitConfig ? { config: rateLimitConfig } : {}
604
- });
605
- }
606
- if (!disabledRoutes.includes("update")) {
607
- const updateMethods = updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod];
608
- const updatePreHandler = [
609
- arcDecorator,
610
- buildAuthMiddleware(fastify, permissions.update),
611
- buildPermissionMiddleware(permissions.update, resourceName, "update"),
612
- idempotencyMw,
613
- ...routeGuards,
614
- ...mw.update
615
- ].filter(Boolean);
616
- for (const method of updateMethods) fastify.route({
617
- method,
618
- url: "/:id",
619
- schema: buildSchema({
620
- tags: [tag],
621
- summary: `${method === "PUT" ? "Replace" : "Update"} ${tag}`,
622
- params: idParamsSchema
623
- }, defaultSchemas.update, schemas.update),
624
- preHandler: updatePreHandler.length > 0 ? updatePreHandler : void 0,
625
- handler: handlers.update,
626
- ...rateLimitConfig ? { config: rateLimitConfig } : {}
627
- });
628
- }
629
- if (!disabledRoutes.includes("delete")) {
630
- const deletePreHandler = [
631
- arcDecorator,
632
- buildAuthMiddleware(fastify, permissions.delete),
633
- buildPermissionMiddleware(permissions.delete, resourceName, "delete"),
634
- ...routeGuards,
635
- ...mw.delete
636
- ].filter(Boolean);
637
- fastify.route({
638
- method: "DELETE",
639
- url: "/:id",
640
- schema: buildSchema({
641
- tags: [tag],
642
- summary: `Delete ${tag}`,
643
- params: idParamsSchema
644
- }, defaultSchemas.delete, schemas.delete),
645
- preHandler: deletePreHandler.length > 0 ? deletePreHandler : void 0,
646
- handler: handlers.delete,
647
- ...rateLimitConfig ? { config: rateLimitConfig } : {}
648
- });
649
- }
650
- }
651
- if (customRoutes.length > 0) createCustomRoutes(fastify, customRoutes, controller, {
652
- tag,
653
- resourceName,
654
- arcDecorator,
655
- rateLimitConfig,
656
- cacheMw,
657
- idempotencyMw,
658
- pipeline,
659
- routeGuards
660
- });
661
- }
662
- /**
663
- * Create permission middleware from PermissionCheck
664
- * Useful for custom route registration
665
- */
666
- function createPermissionMiddleware(permission, resourceName, action) {
667
- return buildPermissionMiddleware(permission, resourceName, action);
668
- }
669
- //#endregion
670
- //#region src/core/validateResourceConfig.ts
671
- /**
672
- * Resource Configuration Validator
673
- *
674
- * Fail-fast validation at definition time.
675
- * Invalid configs throw immediately with clear, actionable errors.
676
- *
677
- * @example
678
- * const result = validateResourceConfig(config);
679
- * if (!result.valid) {
680
- * console.error(formatValidationErrors(result.errors));
681
- * }
682
- */
683
- /**
684
- * Validate a resource configuration
685
- */
686
- function validateResourceConfig(config, options = {}) {
687
- const errors = [];
688
- const warnings = [];
689
- if (!config.name) errors.push({
690
- field: "name",
691
- message: "Resource name is required",
692
- suggestion: "Add a unique resource name (e.g., \"product\", \"user\")"
693
- });
694
- else if (!/^[a-z][a-z0-9-]*$/i.test(config.name)) errors.push({
695
- field: "name",
696
- message: `Invalid resource name "${config.name}"`,
697
- suggestion: "Use alphanumeric characters and hyphens, starting with a letter"
698
- });
699
- const crudRoutes = CRUD_OPERATIONS;
700
- const disabledRoutes = new Set(config.disabledRoutes ?? []);
701
- const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
702
- if (!config.disableDefaultRoutes && enabledCrudRoutes.length > 0) {
703
- if (!config.adapter) errors.push({
704
- field: "adapter",
705
- message: "Data adapter is required when CRUD routes are enabled",
706
- suggestion: "Provide an adapter: createMongooseAdapter({ model, repository })"
707
- });
708
- else if (!config.adapter.repository) errors.push({
709
- field: "adapter.repository",
710
- message: "Adapter must provide a repository",
711
- suggestion: "Ensure your adapter returns a valid StandardRepo (see @classytic/repo-core)"
712
- });
713
- } else if (!config.adapter && !config.routes?.length) warnings.push({
714
- field: "config",
715
- message: "Resource has no adapter and no routes",
716
- suggestion: "Provide either adapter for CRUD or routes for custom logic"
717
- });
718
- if (config.controller && !options.skipControllerCheck && !config.disableDefaultRoutes) {
719
- const ctrl = config.controller;
720
- const requiredMethods = CRUD_OPERATIONS;
721
- for (const method of requiredMethods) if (typeof ctrl[method] !== "function") errors.push({
722
- field: `controller.${method}`,
723
- message: `Missing required CRUD method "${method}"`,
724
- suggestion: "Extend BaseController which implements IController interface"
725
- });
726
- }
727
- if (config.controller && config.routes) validateRouteHandlers(config.controller, config.routes, errors);
728
- if (config.permissions) validatePermissionKeys(config, options, errors, warnings);
729
- if (config.presets && !options.allowUnknownPresets) validatePresets(config.presets, errors, warnings);
730
- if (config.prefix) {
731
- if (!config.prefix.startsWith("/")) errors.push({
732
- field: "prefix",
733
- message: `Prefix must start with "/" (got "${config.prefix}")`,
734
- suggestion: `Change to "/${config.prefix}"`
735
- });
736
- if (config.prefix.endsWith("/") && config.prefix !== "/") warnings.push({
737
- field: "prefix",
738
- message: `Prefix should not end with "/" (got "${config.prefix}")`,
739
- suggestion: `Change to "${config.prefix.slice(0, -1)}"`
740
- });
741
- }
742
- if (config.routes) validateRoutes(config.routes, errors);
743
- return {
744
- valid: errors.length === 0,
745
- errors,
746
- warnings
747
- };
748
- }
749
- function validateRouteHandlers(controller, routes, errors) {
750
- const ctrl = controller;
751
- for (const route of routes) if (typeof route.handler === "string") {
752
- if (typeof ctrl[route.handler] !== "function") errors.push({
753
- field: `routes[${route.method} ${route.path}]`,
754
- message: `Handler "${route.handler}" not found on controller`,
755
- suggestion: `Add method "${route.handler}" to controller or use a function handler`
756
- });
757
- }
758
- }
759
- function validatePermissionKeys(config, options, _errors, warnings) {
760
- const validKeys = new Set([...CRUD_OPERATIONS, ...options.additionalPermissionKeys ?? []]);
761
- for (const route of config.routes ?? []) if (typeof route.handler === "string") validKeys.add(route.handler);
762
- for (const preset of config.presets ?? []) {
763
- const presetName = typeof preset === "string" ? preset : preset.name;
764
- if (presetName === "softDelete") {
765
- validKeys.add("deleted");
766
- validKeys.add("restore");
767
- }
768
- if (presetName === "slugLookup") validKeys.add("getBySlug");
769
- if (presetName === "tree") {
770
- validKeys.add("tree");
771
- validKeys.add("children");
772
- validKeys.add("getTree");
773
- validKeys.add("getChildren");
774
- }
775
- }
776
- for (const key of Object.keys(config.permissions ?? {})) if (!validKeys.has(key)) warnings.push({
777
- field: `permissions.${key}`,
778
- message: `Unknown permission key "${key}"`,
779
- suggestion: `Valid keys: ${Array.from(validKeys).join(", ")}`
780
- });
781
- }
782
- function validatePresets(presets, errors, warnings) {
783
- const availablePresets = getAvailablePresets();
784
- for (const preset of presets) {
785
- if (typeof preset === "object" && ("middlewares" in preset || "routes" in preset)) continue;
786
- const presetName = typeof preset === "string" ? preset : preset.name;
787
- if (!availablePresets.includes(presetName)) errors.push({
788
- field: "presets",
789
- message: `Unknown preset "${presetName}"`,
790
- suggestion: `Available presets: ${availablePresets.join(", ")}`
791
- });
792
- if (typeof preset === "object") validatePresetOptions(preset, warnings);
793
- }
794
- }
795
- function validatePresetOptions(preset, warnings) {
796
- const validOptions = {
797
- slugLookup: ["slugField"],
798
- tree: ["parentField"],
799
- softDelete: ["deletedField"],
800
- ownedByUser: ["ownerField"],
801
- multiTenant: ["tenantField", "allowPublic"]
802
- }[preset.name] ?? [];
803
- const providedOptions = Object.keys(preset).filter((k) => k !== "name");
804
- for (const opt of providedOptions) if (!validOptions.includes(opt)) warnings.push({
805
- field: `presets[${preset.name}].${opt}`,
806
- message: `Unknown option "${opt}" for preset "${preset.name}"`,
807
- suggestion: validOptions.length > 0 ? `Valid options: ${validOptions.join(", ")}` : `Preset "${preset.name}" has no configurable options`
808
- });
809
- }
810
- function validateRoutes(routes, errors) {
811
- const validMethods = [
812
- "GET",
813
- "POST",
814
- "PUT",
815
- "PATCH",
816
- "DELETE",
817
- "OPTIONS",
818
- "HEAD"
819
- ];
820
- const seenRoutes = /* @__PURE__ */ new Set();
821
- for (const [i, route] of routes.entries()) {
822
- if (!validMethods.includes(route.method)) errors.push({
823
- field: `routes[${i}].method`,
824
- message: `Invalid HTTP method "${route.method}"`,
825
- suggestion: `Valid methods: ${validMethods.join(", ")}`
826
- });
827
- if (!route.path) errors.push({
828
- field: `routes[${i}].path`,
829
- message: "Route path is required"
830
- });
831
- else if (!route.path.startsWith("/")) errors.push({
832
- field: `routes[${i}].path`,
833
- message: `Route path must start with "/" (got "${route.path}")`,
834
- suggestion: `Change to "/${route.path}"`
835
- });
836
- if (!route.handler) errors.push({
837
- field: `routes[${i}].handler`,
838
- message: "Route handler is required"
839
- });
840
- const routeKey = `${route.method} ${route.path}`;
841
- if (seenRoutes.has(routeKey)) errors.push({
842
- field: `routes[${i}]`,
843
- message: `Duplicate route "${routeKey}"`
844
- });
845
- seenRoutes.add(routeKey);
846
- }
847
- }
848
- /**
849
- * Format validation errors for display
850
- */
851
- function formatValidationErrors(resourceName, result) {
852
- const lines = [];
853
- if (result.errors.length > 0) {
854
- lines.push(`Resource "${resourceName}" validation failed:`);
855
- lines.push("");
856
- lines.push("ERRORS:");
857
- for (const err of result.errors) {
858
- lines.push(` ✗ ${err.field}: ${err.message}`);
859
- if (err.suggestion) lines.push(` → ${err.suggestion}`);
860
- }
861
- }
862
- if (result.warnings.length > 0) {
863
- if (lines.length > 0) lines.push("");
864
- lines.push("WARNINGS:");
865
- for (const warn of result.warnings) {
866
- lines.push(` ⚠ ${warn.field}: ${warn.message}`);
867
- if (warn.suggestion) lines.push(` → ${warn.suggestion}`);
868
- }
869
- }
870
- return lines.join("\n");
871
- }
872
- /**
873
- * Validate and throw if invalid
874
- */
875
- function assertValidConfig(config, options) {
876
- const result = validateResourceConfig(config, options);
877
- if (!result.valid) {
878
- const errorMsg = formatValidationErrors(config.name ?? "unknown", result);
879
- throw new Error(errorMsg);
880
- }
881
- }
882
- //#endregion
883
- //#region src/core/defineResource.ts
884
- /**
885
- * Define a resource with database adapter
886
- *
887
- * This is the MAIN entry point for creating Arc resources.
888
- * The adapter provides both repository and schema metadata.
889
- */
890
- function defineResource(config) {
891
- if (!config.skipValidation) {
892
- assertValidConfig(config, { skipControllerCheck: true });
893
- if (config.permissions) {
894
- 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.`);
895
- }
896
- for (const route of config.routes ?? []) 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.`);
897
- if (config.actions) {
898
- const CRUD_OPS = new Set([
899
- "create",
900
- "update",
901
- "delete",
902
- "list",
903
- "get"
904
- ]);
905
- for (const [name, entry] of Object.entries(config.actions)) {
906
- if (CRUD_OPS.has(name)) throw new Error(`[Arc] Resource '${config.name}': action '${name}' conflicts with CRUD operation.\nUse a different name (e.g., '${name}_item', 'do_${name}').`);
907
- if (typeof entry !== "function") {
908
- const def = entry;
909
- if (typeof def.handler !== "function") throw new Error(`[Arc] Resource '${config.name}': actions.${name}.handler must be a function.`);
910
- if (def.permissions !== void 0 && typeof def.permissions !== "function") throw new Error(`[Arc] Resource '${config.name}': actions.${name}.permissions must be a PermissionCheck function.`);
911
- }
912
- }
913
- }
914
- }
915
- const repository = config.adapter?.repository;
916
- if (config.idField === void 0 && repository) {
917
- const repoIdField = repository.idField;
918
- if (typeof repoIdField === "string" && repoIdField !== "_id") config = {
919
- ...config,
920
- idField: repoIdField
921
- };
922
- }
923
- const crudRoutes = CRUD_OPERATIONS;
924
- const disabledRoutes = new Set(config.disabledRoutes ?? []);
925
- const hasCrudRoutes = !config.disableDefaultRoutes && crudRoutes.some((route) => !disabledRoutes.has(route));
926
- const originalPresets = (config.presets ?? []).map((p) => typeof p === "string" ? p : p.name);
927
- const resolvedConfig = config.presets?.length ? applyPresets(config, config.presets) : config;
928
- resolvedConfig._appliedPresets = originalPresets;
929
- let controller = resolvedConfig.controller;
930
- if (!controller && hasCrudRoutes && repository) {
931
- const qp = resolvedConfig.queryParser;
932
- let maxLimitFromParser;
933
- if (qp?.getQuerySchema) {
934
- const limitProp = qp.getQuerySchema()?.properties?.limit;
935
- if (limitProp?.maximum) maxLimitFromParser = limitProp.maximum;
936
- }
937
- controller = new BaseController(repository, {
938
- resourceName: resolvedConfig.name,
939
- schemaOptions: resolvedConfig.schemaOptions,
940
- queryParser: resolvedConfig.queryParser,
941
- maxLimit: maxLimitFromParser,
942
- tenantField: resolvedConfig.tenantField,
943
- idField: resolvedConfig.idField,
944
- matchesFilter: config.adapter?.matchesFilter,
945
- cache: resolvedConfig.cache,
946
- onFieldWriteDenied: resolvedConfig.onFieldWriteDenied,
947
- presetFields: resolvedConfig._controllerOptions ? {
948
- slugField: resolvedConfig._controllerOptions.slugField,
949
- parentField: resolvedConfig._controllerOptions.parentField
950
- } : void 0
951
- });
952
- }
953
- const resource = new ResourceDefinition({
954
- ...resolvedConfig,
955
- adapter: config.adapter,
956
- controller
957
- });
958
- if (!config.skipValidation && controller) resource._validateControllerMethods();
959
- if (resolvedConfig._hooks?.length) resource._pendingHooks.push(...resolvedConfig._hooks.map((hook) => ({
960
- operation: hook.operation,
961
- phase: hook.phase,
962
- handler: hook.handler,
963
- priority: hook.priority ?? 10
964
- })));
965
- if (config.hooks) {
966
- const h = config.hooks;
967
- const inlineHooks = [];
968
- const toCtx = (ctx) => ({
969
- data: ctx.data ?? ctx.result ?? {},
970
- user: ctx.user,
971
- meta: ctx.meta
972
- });
973
- if (h.beforeCreate) {
974
- const fn = h.beforeCreate;
975
- inlineHooks.push({
976
- operation: "create",
977
- phase: "before",
978
- priority: 10,
979
- handler: (ctx) => fn(toCtx(ctx))
980
- });
981
- }
982
- if (h.afterCreate) {
983
- const fn = h.afterCreate;
984
- inlineHooks.push({
985
- operation: "create",
986
- phase: "after",
987
- priority: 10,
988
- handler: (ctx) => fn(toCtx(ctx))
989
- });
990
- }
991
- if (h.beforeUpdate) {
992
- const fn = h.beforeUpdate;
993
- inlineHooks.push({
994
- operation: "update",
995
- phase: "before",
996
- priority: 10,
997
- handler: (ctx) => fn(toCtx(ctx))
998
- });
999
- }
1000
- if (h.afterUpdate) {
1001
- const fn = h.afterUpdate;
1002
- inlineHooks.push({
1003
- operation: "update",
1004
- phase: "after",
1005
- priority: 10,
1006
- handler: (ctx) => fn(toCtx(ctx))
1007
- });
1008
- }
1009
- if (h.beforeDelete) {
1010
- const fn = h.beforeDelete;
1011
- inlineHooks.push({
1012
- operation: "delete",
1013
- phase: "before",
1014
- priority: 10,
1015
- handler: (ctx) => fn(toCtx(ctx))
1016
- });
1017
- }
1018
- if (h.afterDelete) {
1019
- const fn = h.afterDelete;
1020
- inlineHooks.push({
1021
- operation: "delete",
1022
- phase: "after",
1023
- priority: 10,
1024
- handler: (ctx) => fn(toCtx(ctx))
1025
- });
1026
- }
1027
- resource._pendingHooks.push(...inlineHooks);
1028
- }
1029
- if (!config.skipRegistry) try {
1030
- let openApiSchemas;
1031
- if (config.adapter?.generateSchemas) {
1032
- const adapterContext = {
1033
- idField: config.idField,
1034
- resourceName: config.name
1035
- };
1036
- const generated = config.adapter.generateSchemas(config.schemaOptions, adapterContext);
1037
- if (generated) openApiSchemas = generated;
1038
- }
1039
- if (config.idField && config.idField !== "_id" && openApiSchemas?.params && typeof openApiSchemas.params === "object") {
1040
- const params = openApiSchemas.params;
1041
- const properties = params.properties;
1042
- const idProp = properties?.id;
1043
- if (idProp && typeof idProp === "object") {
1044
- const pattern = idProp.pattern;
1045
- if (typeof pattern === "string" && (pattern === "^[0-9a-fA-F]{24}$" || pattern === "^[a-f\\d]{24}$" || pattern === "^[a-fA-F0-9]{24}$" || /^\^\[[a-fA-F0-9\\d]+\]\{24\}\$$/.test(pattern))) {
1046
- const cleanedId = { ...idProp };
1047
- delete cleanedId.pattern;
1048
- delete cleanedId.minLength;
1049
- delete cleanedId.maxLength;
1050
- if (!cleanedId.description) cleanedId.description = `${config.idField} (custom ID field)`;
1051
- openApiSchemas = {
1052
- ...openApiSchemas,
1053
- params: {
1054
- ...params,
1055
- properties: {
1056
- ...properties,
1057
- id: cleanedId
1058
- }
1059
- }
1060
- };
1061
- }
1062
- }
1063
- }
1064
- const queryParser = config.queryParser;
1065
- if (queryParser?.getQuerySchema) {
1066
- const querySchema = queryParser.getQuerySchema();
1067
- if (querySchema) openApiSchemas = {
1068
- ...openApiSchemas,
1069
- listQuery: querySchema
1070
- };
1071
- }
1072
- if (config.openApiSchemas) openApiSchemas = {
1073
- ...openApiSchemas,
1074
- ...config.openApiSchemas
1075
- };
1076
- if (openApiSchemas) openApiSchemas = convertOpenApiSchemas(openApiSchemas);
1077
- resource._registryMeta = {
1078
- module: config.module,
1079
- openApiSchemas
1080
- };
1081
- } catch {}
1082
- return resource;
1083
- }
1084
- var ResourceDefinition = class {
1085
- name;
1086
- displayName;
1087
- tag;
1088
- prefix;
1089
- adapter;
1090
- controller;
1091
- schemaOptions;
1092
- customSchemas;
1093
- permissions;
1094
- routes;
1095
- middlewares;
1096
- routeGuards;
1097
- disableDefaultRoutes;
1098
- disabledRoutes;
1099
- actions;
1100
- actionPermissions;
1101
- events;
1102
- rateLimit;
1103
- audit;
1104
- updateMethod;
1105
- pipe;
1106
- fields;
1107
- cache;
1108
- skipGlobalPrefix;
1109
- tenantField;
1110
- idField;
1111
- queryParser;
1112
- _appliedPresets;
1113
- _pendingHooks;
1114
- _registryMeta;
1115
- constructor(config) {
1116
- this.name = config.name;
1117
- this.displayName = config.displayName ?? `${capitalize(config.name)}s`;
1118
- this.tag = config.tag ?? this.displayName;
1119
- this.prefix = config.prefix ?? `/${config.name}s`;
1120
- this.skipGlobalPrefix = config.skipGlobalPrefix ?? false;
1121
- this.adapter = config.adapter;
1122
- this.controller = config.controller;
1123
- this.schemaOptions = config.schemaOptions ?? {};
1124
- this.customSchemas = config.customSchemas ?? {};
1125
- this.permissions = config.permissions ?? {};
1126
- this.routes = config.routes ?? [];
1127
- this.middlewares = config.middlewares ?? {};
1128
- this.routeGuards = config.routeGuards;
1129
- this.disableDefaultRoutes = config.disableDefaultRoutes ?? false;
1130
- this.disabledRoutes = config.disabledRoutes ?? [];
1131
- this.actions = config.actions;
1132
- this.actionPermissions = config.actionPermissions;
1133
- this.events = config.events ?? {};
1134
- this.rateLimit = config.rateLimit;
1135
- this.audit = config.audit;
1136
- this.updateMethod = config.updateMethod;
1137
- this.pipe = config.pipe;
1138
- this.fields = config.fields;
1139
- this.cache = config.cache;
1140
- this.tenantField = config.tenantField;
1141
- this.idField = config.idField;
1142
- this.queryParser = config.queryParser;
1143
- this._appliedPresets = config._appliedPresets ?? [];
1144
- this._pendingHooks = config._pendingHooks ?? [];
1145
- }
1146
- /** Get repository from adapter (if available) */
1147
- get repository() {
1148
- return this.adapter?.repository;
1149
- }
1150
- _validateControllerMethods() {
1151
- const errors = [];
1152
- const crudRoutes = CRUD_OPERATIONS;
1153
- const disabledRoutes = new Set(this.disabledRoutes ?? []);
1154
- const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
1155
- if (!this.disableDefaultRoutes && enabledCrudRoutes.length > 0) if (!this.controller) errors.push("Controller is required when CRUD routes are enabled");
1156
- else {
1157
- const ctrl = this.controller;
1158
- for (const method of enabledCrudRoutes) if (typeof ctrl[method] !== "function") errors.push(`CRUD method '${method}' not found on controller`);
1159
- }
1160
- for (const route of this.routes) if (typeof route.handler === "string") {
1161
- if (!this.controller) errors.push(`Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller`);
1162
- else if (typeof this.controller[route.handler] !== "function") errors.push(`Route ${route.method} ${route.path}: handler '${route.handler}' not found`);
1163
- }
1164
- if (errors.length > 0) {
1165
- const errorMsg = [
1166
- `Resource '${this.name}' validation failed:`,
1167
- ...errors.map((e) => ` - ${e}`),
1168
- "",
1169
- "Ensure controller implements IController<TDoc> interface.",
1170
- "For preset routes (softDelete, tree), add corresponding methods to controller."
1171
- ].join("\n");
1172
- throw new Error(errorMsg);
1173
- }
1174
- }
1175
- toPlugin() {
1176
- const self = this;
1177
- return async function resourcePlugin(fastify, _opts) {
1178
- const arc = fastify.arc;
1179
- if (arc?.registry && self._registryMeta) try {
1180
- arc.registry.register(self, self._registryMeta);
1181
- } catch (err) {
1182
- fastify.log?.warn?.(`Failed to register resource '${self.name}' in registry: ${err instanceof Error ? err.message : err}`);
1183
- }
1184
- if (self._pendingHooks.length > 0) {
1185
- const arc = fastify.arc;
1186
- if (arc?.hooks) for (const hook of self._pendingHooks) arc.hooks.register({
1187
- resource: self.name,
1188
- operation: hook.operation,
1189
- phase: hook.phase,
1190
- handler: hook.handler,
1191
- priority: hook.priority
1192
- });
1193
- }
1194
- const registerRule = fastify.registerCacheInvalidationRule;
1195
- if (self.cache?.invalidateOn && typeof registerRule === "function") for (const [pattern, tags] of Object.entries(self.cache.invalidateOn)) registerRule({
1196
- pattern,
1197
- tags
1198
- });
1199
- await fastify.register(async (instance) => {
1200
- const typedInstance = instance;
1201
- let schemas = null;
1202
- const openApi = self._registryMeta?.openApiSchemas;
1203
- if (openApi && (!self.customSchemas || Object.keys(self.customSchemas).length === 0)) {
1204
- const generated = {};
1205
- const { createBody, updateBody, params } = openApi;
1206
- const safeBody = (schema) => {
1207
- if (schema && typeof schema === "object" && schema.type === "object") return {
1208
- additionalProperties: true,
1209
- ...schema
1210
- };
1211
- return schema;
1212
- };
1213
- if (createBody) generated.create = { body: safeBody(createBody) };
1214
- if (updateBody) {
1215
- const patchBody = { ...updateBody };
1216
- delete patchBody.required;
1217
- generated.update = { body: safeBody(patchBody) };
1218
- if (params) generated.update.params = params;
1219
- }
1220
- if (params) {
1221
- generated.get = { params };
1222
- generated.delete = { params };
1223
- if (!generated.update) generated.update = { params };
1224
- else if (!generated.update.params) generated.update.params = params;
1225
- }
1226
- if (Object.keys(generated).length > 0) schemas = generated;
1227
- }
1228
- if (self.customSchemas && Object.keys(self.customSchemas).length > 0) {
1229
- schemas = schemas ?? {};
1230
- for (const [op, customSchema] of Object.entries(self.customSchemas)) {
1231
- const key = op;
1232
- const converted = convertRouteSchema(customSchema);
1233
- schemas[key] = schemas[key] ? deepMergeSchemas(schemas[key], converted) : converted;
1234
- }
1235
- }
1236
- const listQuerySchema = self._registryMeta?.openApiSchemas?.listQuery;
1237
- if (listQuerySchema) {
1238
- const NORMALIZED_PROPS = {
1239
- page: {
1240
- type: "integer",
1241
- minimum: 1
1242
- },
1243
- limit: {
1244
- type: "integer",
1245
- minimum: 1
1246
- },
1247
- sort: {},
1248
- search: {},
1249
- select: {},
1250
- after: {},
1251
- populate: {},
1252
- lookup: {},
1253
- aggregate: {}
1254
- };
1255
- const props = listQuerySchema.properties;
1256
- const normalizedProps = props ? { ...props } : void 0;
1257
- if (normalizedProps) {
1258
- const originalLimit = normalizedProps.limit;
1259
- if (originalLimit?.maximum) NORMALIZED_PROPS.limit = {
1260
- ...NORMALIZED_PROPS.limit,
1261
- maximum: originalLimit.maximum
1262
- };
1263
- for (const key of Object.keys(normalizedProps)) normalizedProps[key] = NORMALIZED_PROPS[key] ?? {};
1264
- }
1265
- const normalizedSchema = {
1266
- ...listQuerySchema,
1267
- ...normalizedProps ? { properties: normalizedProps } : {},
1268
- additionalProperties: listQuerySchema.additionalProperties ?? true
1269
- };
1270
- schemas = schemas ?? {};
1271
- schemas.list = schemas.list ? deepMergeSchemas({ querystring: normalizedSchema }, schemas.list) : { querystring: normalizedSchema };
1272
- }
1273
- createCrudRouter(typedInstance, self.controller, {
1274
- tag: self.tag,
1275
- schemas: schemas ?? void 0,
1276
- permissions: self.permissions,
1277
- middlewares: self.middlewares,
1278
- routeGuards: self.routeGuards,
1279
- routes: self.routes,
1280
- disableDefaultRoutes: self.disableDefaultRoutes,
1281
- disabledRoutes: self.disabledRoutes,
1282
- resourceName: self.name,
1283
- schemaOptions: self.schemaOptions,
1284
- rateLimit: self.rateLimit,
1285
- updateMethod: self.updateMethod,
1286
- pipe: self.pipe,
1287
- fields: self.fields
1288
- });
1289
- if (self.actions && Object.keys(self.actions).length > 0) {
1290
- const { createActionRouter } = await import("./createActionRouter-Bp_5c_2b.mjs").then((n) => n.n);
1291
- createActionRouter(instance, normalizeActionsToRouterConfig(self.actions, self.actionPermissions, self.tag));
1292
- }
1293
- if (self.events && Object.keys(self.events).length > 0) typedInstance.log?.debug?.(`Resource '${self.name}' defined ${Object.keys(self.events).length} events`);
1294
- }, { prefix: self.prefix });
1295
- if (hasEvents(fastify)) try {
1296
- await fastify.events.publish("arc.resource.registered", {
1297
- resource: self.name,
1298
- prefix: self.prefix,
1299
- presets: self._appliedPresets,
1300
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1301
- });
1302
- } catch {}
1303
- };
1304
- }
1305
- /**
1306
- * Get event definitions for registry
1307
- */
1308
- getEvents() {
1309
- return Object.entries(this.events).map(([action, meta]) => ({
1310
- name: `${this.name}:${action}`,
1311
- module: this.name,
1312
- schema: meta.schema,
1313
- description: meta.description
1314
- }));
1315
- }
1316
- /**
1317
- * Get resource metadata
1318
- */
1319
- getMetadata() {
1320
- return {
1321
- name: this.name,
1322
- displayName: this.displayName,
1323
- tag: this.tag,
1324
- prefix: this.prefix,
1325
- presets: this._appliedPresets,
1326
- permissions: this.permissions,
1327
- customRoutes: (this.routes ?? []).map((r) => ({
1328
- method: r.method,
1329
- path: r.path,
1330
- handler: typeof r.handler === "string" ? r.handler : r.handler.name || "anonymous",
1331
- operation: r.operation,
1332
- summary: r.summary,
1333
- description: r.description,
1334
- permissions: r.permissions,
1335
- raw: r.raw,
1336
- schema: r.schema
1337
- })),
1338
- routes: [],
1339
- events: Object.keys(this.events)
1340
- };
1341
- }
1342
- };
1343
- function deepMergeSchemas(base, override) {
1344
- if (!override) return base;
1345
- if (!base) return override;
1346
- const result = { ...base };
1347
- for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [...new Set([...result[key], ...value])];
1348
- else if (value && typeof value === "object" && !Array.isArray(value)) result[key] = deepMergeSchemas(result[key], value);
1349
- else result[key] = value;
1350
- return result;
1351
- }
1352
- function capitalize(str) {
1353
- if (!str) return "";
1354
- return str.charAt(0).toUpperCase() + str.slice(1);
1355
- }
1356
- /**
1357
- * Normalize ActionsMap into the ActionRouterConfig shape that createActionRouter expects.
1358
- */
1359
- function normalizeActionsToRouterConfig(actions, globalAuth, tag) {
1360
- const handlers = {};
1361
- const permissions = {};
1362
- const schemas = {};
1363
- for (const [name, entry] of Object.entries(actions)) if (typeof entry === "function") handlers[name] = entry;
1364
- else {
1365
- const def = entry;
1366
- handlers[name] = def.handler;
1367
- if (def.permissions) permissions[name] = def.permissions;
1368
- if (def.schema) schemas[name] = def.schema;
1369
- }
1370
- return {
1371
- tag,
1372
- actions: handlers,
1373
- actionPermissions: permissions,
1374
- actionSchemas: schemas,
1375
- globalAuth
1376
- };
1377
- }
1378
- //#endregion
1379
- //#region src/core/defineResourceVariants.ts
1380
- /**
1381
- * Define multiple resources from a shared base config and per-variant overrides.
1382
- *
1383
- * Each variant is independently passed through `defineResource()` — the
1384
- * returned `ResourceDefinition`s are real, fully-registered resources.
1385
- * Register each one's plugin in your app:
1386
- *
1387
- * ```typescript
1388
- * await app.register(articlePublic.toPlugin());
1389
- * await app.register(articleAdmin.toPlugin());
1390
- * ```
1391
- *
1392
- * @param base Shared config — adapter, queryParser, schemaOptions, hooks, etc.
1393
- * Must NOT include `name` or `prefix` (those are per-variant).
1394
- * @param variants Map of variant key → override. Each variant must declare
1395
- * its own `name` and `prefix`. Other fields override the base.
1396
- * @returns A record where each key from `variants` maps to a real
1397
- * `ResourceDefinition` ready for `.toPlugin()` registration.
1398
- */
1399
- function defineResourceVariants(base, variants) {
1400
- const out = {};
1401
- for (const key of Object.keys(variants)) {
1402
- const override = variants[key];
1403
- out[key] = defineResource({
1404
- ...base,
1405
- ...override
1406
- });
1407
- }
1408
- return out;
1409
- }
1410
- //#endregion
1411
- export { formatValidationErrors as a, createPermissionMiddleware as c, createRequestContext as d, getControllerContext as f, pipe as h, assertValidConfig as i, createCrudHandlers as l, sendControllerResponse as m, ResourceDefinition as n, validateResourceConfig as o, getControllerScope as p, defineResource as r, createCrudRouter as s, defineResourceVariants as t, createFastifyHandler as u };