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