@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
@@ -0,0 +1,1037 @@
1
+ import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-BhY1OHoH.mjs";
2
+ import { arcLog } from "./logger/index.mjs";
3
+ import { m as assertValidConfig, y as getDefaultCrudSchemas } from "./utils-D3Yxnrwr.mjs";
4
+ import { t as BaseController } from "./BaseController-JNV08qOT.mjs";
5
+ import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-B0oKLuqI.mjs";
6
+ import { c as buildPreHandlerChain, d as resolveRouterPluginMw, f as selectPluginMw, i as buildAuthMiddleware, l as buildRateLimitConfig, m as createFastifyHandler, o as buildCrudPermissionMw, p as createCrudHandlers, r as buildArcDecorator, s as buildPipelineHandler, u as resolvePipelineSteps, y as buildRequestScopeProjection } from "./routerShared-DeESFp4a.mjs";
7
+ import { t as applyPresets } from "./presets-k604Lj99.mjs";
8
+ import { t as hasEvents } from "./typeGuards-CcFZXgU7.mjs";
9
+ import { t as resolveActionPermission } from "./actionPermissions-C8YYU92K.mjs";
10
+ //#region src/core/createCrudRouter.ts
11
+ /**
12
+ * Mount custom routes (from presets or user-defined `routes`) on Fastify.
13
+ * `wrapHandler` is derived inline from `!route.raw`.
14
+ */
15
+ function createCustomRoutes(fastify, routes, controller, options) {
16
+ const { tag, resourceName, arcDecorator, rateLimitConfig, pluginMw, pipeline, routeGuards } = options;
17
+ for (const route of routes) {
18
+ const opName = route.operation ?? (typeof route.handler === "string" ? route.handler : `${route.method.toLowerCase()}${route.path.replace(/[/:]/g, "_")}`);
19
+ const wrapHandler = !route.raw;
20
+ let handler;
21
+ if (typeof route.handler === "string") {
22
+ 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.`);
23
+ const method = controller[route.handler];
24
+ if (typeof method !== "function") throw new Error(`Handler '${route.handler}' not found on controller`);
25
+ const boundMethod = method.bind(controller);
26
+ if (wrapHandler) {
27
+ const steps = resolvePipelineSteps(pipeline, opName);
28
+ handler = steps.length > 0 ? buildPipelineHandler(boundMethod, steps, opName, resourceName) : createFastifyHandler(boundMethod);
29
+ } else handler = boundMethod;
30
+ } else if (wrapHandler) {
31
+ const steps = resolvePipelineSteps(pipeline, opName);
32
+ handler = steps.length > 0 ? buildPipelineHandler(route.handler, steps, opName, resourceName) : createFastifyHandler(route.handler);
33
+ } else handler = route.handler;
34
+ const routeTags = route.tags ?? (tag ? [tag] : void 0);
35
+ const convertedSchema = route.schema ? convertRouteSchema(route.schema) : void 0;
36
+ const schema = {
37
+ ...routeTags ? { tags: routeTags } : {},
38
+ ...route.summary ? { summary: route.summary } : {},
39
+ ...route.description ? { description: route.description } : {},
40
+ ...convertedSchema ?? {}
41
+ };
42
+ const customPreHandlers = typeof route.preHandler === "function" ? route.preHandler(fastify) : route.preHandler ?? [];
43
+ const preHandler = buildPreHandlerChain({
44
+ preAuth: route.preAuth ?? [],
45
+ arcDecorator,
46
+ authMw: buildAuthMiddleware(fastify, route.permissions),
47
+ permissionMw: buildCrudPermissionMw(route.permissions, resourceName, opName),
48
+ pluginMw: selectPluginMw(route.method, pluginMw),
49
+ routeGuards,
50
+ customMws: customPreHandlers
51
+ });
52
+ const isStream = route.streamResponse === true;
53
+ fastify.route({
54
+ method: route.method,
55
+ url: route.path,
56
+ schema,
57
+ preHandler: preHandler.length > 0 ? preHandler : void 0,
58
+ handler: isStream ? async (request, reply) => {
59
+ reply.raw.setHeader("Content-Type", "text/event-stream");
60
+ reply.raw.setHeader("Cache-Control", "no-cache");
61
+ reply.raw.setHeader("Connection", "keep-alive");
62
+ return handler(request, reply);
63
+ } : handler,
64
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
65
+ });
66
+ }
67
+ }
68
+ /**
69
+ * Create CRUD routes for a controller.
70
+ *
71
+ * @param fastify - Fastify instance with Arc decorators
72
+ * @param controller - CRUD controller with handler methods (optional when
73
+ * `disableDefaultRoutes: true` and only custom `routes`
74
+ * are being registered)
75
+ * @param options - Router configuration
76
+ */
77
+ function createCrudRouter(fastify, controller, options = {}) {
78
+ 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;
79
+ const rateLimitConfig = buildRateLimitConfig(rateLimit);
80
+ const resourceHasQueryCache = fastify.hasDecorator("queryCache") && controller && typeof controller._cacheConfig !== "undefined" && controller._cacheConfig !== void 0;
81
+ const pluginMw = resolveRouterPluginMw(fastify, Boolean(resourceHasQueryCache));
82
+ const arcDecorator = buildArcDecorator({
83
+ resourceName,
84
+ schemaOptions,
85
+ permissions,
86
+ hooks: fastify.arc?.hooks,
87
+ events: fastify.events,
88
+ fields: fieldPermissions
89
+ });
90
+ const mw = {
91
+ list: middlewares.list ?? [],
92
+ get: middlewares.get ?? [],
93
+ create: middlewares.create ?? [],
94
+ update: middlewares.update ?? [],
95
+ delete: middlewares.delete ?? []
96
+ };
97
+ const idParamsSchema = {
98
+ type: "object",
99
+ properties: { id: { type: "string" } },
100
+ required: ["id"]
101
+ };
102
+ const defaultSchemas = getDefaultCrudSchemas();
103
+ /**
104
+ * Merge: base (tags/summary) → defaults (response/querystring) → user overrides.
105
+ * User-provided schemas always win; defaults enable fast-json-stringify when
106
+ * no user schema is set.
107
+ */
108
+ const buildSchema = (base, defaults, userSchema) => ({
109
+ ...defaults,
110
+ ...base,
111
+ ...userSchema ?? {}
112
+ });
113
+ if (!disableDefaultRoutes) {
114
+ if (!controller) throw new Error("Controller is required when disableDefaultRoutes is not true. Provide a controller or use defineResource which auto-creates BaseController.");
115
+ const handlers = buildCrudHandlers(controller, pipeline, resourceName);
116
+ const crudTable = [
117
+ {
118
+ op: "list",
119
+ method: "GET",
120
+ url: "/",
121
+ summary: `List ${tag}`,
122
+ hasIdParams: false
123
+ },
124
+ {
125
+ op: "get",
126
+ method: "GET",
127
+ url: "/:id",
128
+ summary: `Get ${tag} by ID`,
129
+ hasIdParams: true
130
+ },
131
+ {
132
+ op: "create",
133
+ method: "POST",
134
+ url: "/",
135
+ summary: `Create ${tag}`,
136
+ hasIdParams: false
137
+ },
138
+ {
139
+ op: "update",
140
+ method: "PATCH",
141
+ url: "/:id",
142
+ summary: `Update ${tag}`,
143
+ hasIdParams: true
144
+ },
145
+ {
146
+ op: "delete",
147
+ method: "DELETE",
148
+ url: "/:id",
149
+ summary: `Delete ${tag}`,
150
+ hasIdParams: true
151
+ }
152
+ ];
153
+ for (const spec of crudTable) {
154
+ if (disabledRoutes.includes(spec.op)) continue;
155
+ const permission = permissions[spec.op];
156
+ const preHandler = buildPreHandlerChain({
157
+ arcDecorator,
158
+ authMw: buildAuthMiddleware(fastify, permission),
159
+ permissionMw: buildCrudPermissionMw(permission, resourceName, spec.op),
160
+ pluginMw: selectPluginMw(spec.method, pluginMw),
161
+ routeGuards,
162
+ customMws: mw[spec.op]
163
+ });
164
+ const methodsToRegister = spec.op === "update" ? updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod] : [spec.method];
165
+ for (const method of methodsToRegister) {
166
+ const summary = spec.op === "update" ? `${method === "PUT" ? "Replace" : "Update"} ${tag}` : spec.summary;
167
+ fastify.route({
168
+ method,
169
+ url: spec.url,
170
+ schema: buildSchema({
171
+ tags: [tag],
172
+ summary,
173
+ ...spec.hasIdParams ? { params: idParamsSchema } : {}
174
+ }, defaultSchemas[spec.op], schemas[spec.op]),
175
+ preHandler: preHandler.length > 0 ? preHandler : void 0,
176
+ handler: handlers[spec.op],
177
+ ...rateLimitConfig ? { config: rateLimitConfig } : {}
178
+ });
179
+ }
180
+ }
181
+ }
182
+ if (customRoutes.length > 0) createCustomRoutes(fastify, customRoutes, controller, {
183
+ tag,
184
+ resourceName,
185
+ arcDecorator,
186
+ rateLimitConfig,
187
+ pluginMw,
188
+ pipeline,
189
+ routeGuards
190
+ });
191
+ }
192
+ function buildCrudHandlers(ctrl, pipeline, resourceName) {
193
+ const standardHandlers = createCrudHandlers(ctrl);
194
+ if (!pipeline) return standardHandlers;
195
+ const wrapped = { ...standardHandlers };
196
+ for (const op of CRUD_OPERATIONS) {
197
+ const steps = resolvePipelineSteps(pipeline, op);
198
+ if (steps.length === 0) continue;
199
+ wrapped[op] = buildPipelineHandler(ctrl[op].bind(ctrl), steps, op, resourceName);
200
+ }
201
+ return wrapped;
202
+ }
203
+ /**
204
+ * Build a permission middleware from a PermissionCheck — useful when hosts
205
+ * register their own routes outside the resource system but still want to
206
+ * evaluate permissions through the shared applicator.
207
+ */
208
+ function createPermissionMiddleware(permission, resourceName, action) {
209
+ return buildCrudPermissionMw(permission, resourceName, action);
210
+ }
211
+ //#endregion
212
+ //#region src/core/schemaOptions.ts
213
+ /**
214
+ * Inject the tenant-scoping field rule into `schemaOptions.fieldRules`:
215
+ *
216
+ * { [tenantField]: { systemManaged: true, preserveForElevated: true } }
217
+ *
218
+ * Why both flags: `systemManaged` tells `BodySanitizer` to strip the
219
+ * field from inbound bodies (so member clients can't forge a target
220
+ * tenant). `preserveForElevated` exempts elevated-admin scopes from the
221
+ * strip, so platform admins without a pinned org can still pick a target
222
+ * org via the request body (the only channel they have —
223
+ * `BaseController.create` can't re-stamp from scope when scope has no
224
+ * orgId).
225
+ *
226
+ * **Returns a new `RouteSchemaOptions`** — the input is never mutated.
227
+ * Callers should assign the return value to whatever config slot they
228
+ * read from downstream (always the `resolvedConfig`, never raw `config`).
229
+ *
230
+ * **No-op when:**
231
+ * - `tenantField` is `false` (platform-universal resource)
232
+ * - `tenantField` is undefined
233
+ * - The caller already declared `fieldRules[tenantField].systemManaged`
234
+ * (even as `false`) — explicit opt-outs are respected
235
+ *
236
+ * `preserveForElevated` defaults to `true` but is preserved verbatim
237
+ * when the caller set it explicitly.
238
+ */
239
+ function autoInjectTenantFieldRules(schemaOptions, tenantField) {
240
+ if (tenantField === false || tenantField === void 0) return schemaOptions;
241
+ const fieldName = tenantField || "organizationId";
242
+ const existing = schemaOptions?.fieldRules ?? {};
243
+ const existingRule = existing[fieldName];
244
+ if (existingRule && existingRule.systemManaged !== void 0) return schemaOptions;
245
+ return {
246
+ ...schemaOptions ?? {},
247
+ fieldRules: {
248
+ ...existing,
249
+ [fieldName]: {
250
+ ...existingRule ?? {},
251
+ systemManaged: true,
252
+ preserveForElevated: existingRule?.preserveForElevated ?? true
253
+ }
254
+ }
255
+ };
256
+ }
257
+ /**
258
+ * Remove a field from a JSON Schema's `required[]` array. Leaves `properties`
259
+ * intact so advanced callers can still send the value — the field just isn't
260
+ * mandatory at validation time.
261
+ *
262
+ * Returns a fresh schema (no mutation). No-op when the schema is undefined,
263
+ * lacks a `required[]`, or the field is already absent from it.
264
+ */
265
+ function stripFromRequired(schema, fieldName) {
266
+ if (!schema || typeof schema !== "object") return schema;
267
+ const required = schema.required;
268
+ if (!Array.isArray(required) || !required.includes(fieldName)) return schema;
269
+ const filtered = required.filter((f) => f !== fieldName);
270
+ const next = { ...schema };
271
+ if (filtered.length > 0) next.required = filtered;
272
+ else delete next.required;
273
+ return next;
274
+ }
275
+ /**
276
+ * Strip framework-injected fields from the `required[]` list of every
277
+ * body-shaped slot in an adapter's generated schemas (v2.11.0).
278
+ *
279
+ * A "framework-injected field" is any field marked `systemManaged: true`
280
+ * in `schemaOptions.fieldRules`. Arc populates those fields from the
281
+ * request scope / preset middleware / controller — the client is never
282
+ * expected to supply them, so they must not be in the wire contract's
283
+ * `required[]` even if the underlying engine's Mongoose/Zod schema
284
+ * declares them as required at the DB layer.
285
+ *
286
+ * **The primary gotcha this closes:** engines built on
287
+ * `@classytic/primitives` (mongokit, pricelist, and every downstream
288
+ * `@classytic/*` engine) default to `tenant: { required: true }` in
289
+ * `resolveTenantConfig()`. That stamps `organizationId: { required: true }`
290
+ * on the Mongoose schema, which the adapter faithfully reflects into the
291
+ * generated `createBody` / `updateBody` schema's `required[]`. Fastify's
292
+ * preValidation runs BEFORE arc's preHandler chain, so
293
+ * `multiTenantPreset`'s tenant-injection hook never gets a chance to run —
294
+ * the request is rejected with `must have required property 'organizationId'`
295
+ * even though the client correctly supplied `x-organization-id` and the
296
+ * framework had already promised to inject the value.
297
+ *
298
+ * The only workaround before 2.11 was
299
+ * `createEngine({ tenant: { required: false } })` at every consumer site —
300
+ * a leaky abstraction every new engine-backed resource had to remember.
301
+ *
302
+ * **Secondary coverage (defense-in-depth):** the same transform also fires
303
+ * for `auditedPreset`'s `createdBy` / `updatedBy`, any future preset that
304
+ * marks fields `systemManaged`, and any host-declared `fieldRules` with
305
+ * `systemManaged: true`. Every framework-injected field gets the wire
306
+ * contract / runtime pairing for free.
307
+ *
308
+ * **Leaves `properties` intact** — elevated admins or advanced callers can
309
+ * still send systemManaged fields in the body. `BodySanitizer` enforces
310
+ * the runtime policy (`preserveForElevated`, `strip` vs `reject`, etc.).
311
+ *
312
+ * **No-op when:**
313
+ * - `schemaOptions.fieldRules` is undefined / empty
314
+ * - No rule has `systemManaged: true`
315
+ * - The generated schemas object is undefined (adapter didn't generate any)
316
+ *
317
+ * Applies to both `createBody` and `updateBody` — update middleware also
318
+ * injects tenant/audit fields, so the update wire contract has the same
319
+ * problem as create.
320
+ */
321
+ function stripSystemManagedFromBodyRequired(schemas, schemaOptions) {
322
+ if (!schemas) return schemas;
323
+ const rules = schemaOptions?.fieldRules;
324
+ if (!rules) return schemas;
325
+ const systemManagedFields = Object.entries(rules).filter(([, rule]) => rule?.systemManaged === true).map(([field]) => field);
326
+ if (systemManagedFields.length === 0) return schemas;
327
+ const next = { ...schemas };
328
+ let createBody = schemas.createBody;
329
+ for (const field of systemManagedFields) createBody = stripFromRequired(createBody, field);
330
+ if (createBody !== schemas.createBody) next.createBody = createBody;
331
+ let updateBody = schemas.updateBody;
332
+ for (const field of systemManagedFields) updateBody = stripFromRequired(updateBody, field);
333
+ if (updateBody !== schemas.updateBody) next.updateBody = updateBody;
334
+ return next;
335
+ }
336
+ //#endregion
337
+ //#region src/core/defineResource.ts
338
+ /**
339
+ * Define a resource with database adapter.
340
+ *
341
+ * This is the MAIN entry point for creating Arc resources — the adapter
342
+ * provides both repository and schema metadata.
343
+ *
344
+ * Staged into seven named phases so future refactors touch one phase at a
345
+ * time instead of threading changes through a 450-line function:
346
+ *
347
+ * 1. validate — fail-fast structural checks
348
+ * 2. resolveIdField — auto-derive `idField` from repository
349
+ * 3. applyPresetsAndAutoInject — clone + apply presets + tenant-field rules
350
+ * 4. resolveController — reuse user controller or auto-create BaseController
351
+ * 5. buildResource — construct ResourceDefinition + validate methods
352
+ * 6. wireHooks — push preset + inline `config.hooks` onto _pendingHooks
353
+ * 7. resolveOpenApiSchemas — adapter schemas → parser listQuery → user override
354
+ *
355
+ * Each phase has a single responsibility; `resolvedConfig` is the canonical
356
+ * post-preset, post-auto-inject config that every later phase reads. Raw
357
+ * `config` is only consulted for things presets don't touch (adapter,
358
+ * skipRegistry, skipValidation, hooks — which are wired separately from
359
+ * preset hooks).
360
+ */
361
+ function defineResource(config) {
362
+ if (!config.skipValidation) validateDefineResourceConfig(config);
363
+ const repository = config.adapter?.repository;
364
+ const configWithId = resolveIdField(config, repository);
365
+ const resolvedConfig = applyPresetsAndAutoInject(configWithId);
366
+ const hasCrudRoutes = computeHasCrudRoutes(resolvedConfig);
367
+ const narrowedConfig = resolvedConfig;
368
+ const narrowedAdapter = configWithId.adapter;
369
+ const controller = resolveOrAutoCreateController(narrowedConfig, narrowedAdapter, repository, hasCrudRoutes);
370
+ const resource = new ResourceDefinition({
371
+ ...resolvedConfig,
372
+ adapter: configWithId.adapter,
373
+ controller
374
+ });
375
+ if (!config.skipValidation && controller) resource._validateControllerMethods();
376
+ wireHooks(resource, narrowedConfig, configWithId.hooks);
377
+ if (!config.skipRegistry) {
378
+ const registryMeta = resolveOpenApiSchemas(narrowedConfig);
379
+ if (registryMeta) resource._registryMeta = registryMeta;
380
+ }
381
+ return resource;
382
+ }
383
+ function validateDefineResourceConfig(config) {
384
+ assertValidConfig(config, { skipControllerCheck: true });
385
+ if (config.permissions) {
386
+ 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.`);
387
+ }
388
+ 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.`);
389
+ if (config.actions) {
390
+ const CRUD_OPS = new Set([
391
+ "create",
392
+ "update",
393
+ "delete",
394
+ "list",
395
+ "get"
396
+ ]);
397
+ for (const [name, entry] of Object.entries(config.actions)) {
398
+ 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}').`);
399
+ if (typeof entry !== "function") {
400
+ const def = entry;
401
+ if (typeof def.handler !== "function") throw new Error(`[Arc] Resource '${config.name}': actions.${name}.handler must be a function.`);
402
+ if (def.permissions !== void 0 && typeof def.permissions !== "function") throw new Error(`[Arc] Resource '${config.name}': actions.${name}.permissions must be a PermissionCheck function.`);
403
+ }
404
+ }
405
+ }
406
+ }
407
+ /**
408
+ * Auto-derive `idField` from the repository when the user didn't set one
409
+ * explicitly. MongoKit-style repositories declare their primary key field
410
+ * via `repository.idField`. By picking it up here (BEFORE preset resolution),
411
+ * the user configures idField in ONE place (the repo) and arc threads it
412
+ * through `BaseController`, AJV params schema, `ResourceDefinition.idField`,
413
+ * and preset field wiring consistently.
414
+ *
415
+ * Returns a fresh config — never mutates the caller's reference.
416
+ */
417
+ function resolveIdField(config, repository) {
418
+ if (config.idField !== void 0 || !repository) return config;
419
+ const repoIdField = repository.idField;
420
+ if (typeof repoIdField === "string" && repoIdField !== "_id") return {
421
+ ...config,
422
+ idField: repoIdField
423
+ };
424
+ return config;
425
+ }
426
+ /**
427
+ * Produce the canonical `resolvedConfig` — a fresh clone of the caller's
428
+ * config with presets applied and tenant-field schema rules auto-injected.
429
+ *
430
+ * v2.11.0: always returns a fresh object so downstream mutations
431
+ * (`_appliedPresets`, `schemaOptions` auto-inject, `_controllerOptions`,
432
+ * `_pendingHooks`) never leak onto the caller's config. Before 2.11 the
433
+ * no-preset branch returned the raw caller reference, which mutated
434
+ * resource-config fragments hosts were reusing.
435
+ *
436
+ * Full rationale for tenant-field auto-inject lives in
437
+ * `autoInjectTenantFieldRules` (src/core/schemaOptions.ts). Centralised here
438
+ * so every downstream reader (`BodySanitizer`, adapter `generateSchemas()`,
439
+ * MCP tool generator, OpenAPI builder) sees the same post-inject shape.
440
+ */
441
+ function applyPresetsAndAutoInject(config) {
442
+ const originalPresets = (config.presets ?? []).map((p) => typeof p === "string" ? p : p.name);
443
+ const resolvedConfig = config.presets?.length ? applyPresets(config, config.presets) : { ...config };
444
+ resolvedConfig._appliedPresets = originalPresets;
445
+ resolvedConfig.schemaOptions = autoInjectTenantFieldRules(resolvedConfig.schemaOptions, resolvedConfig.tenantField);
446
+ return resolvedConfig;
447
+ }
448
+ function computeHasCrudRoutes(config) {
449
+ const disabled = new Set(config.disabledRoutes ?? []);
450
+ return !config.disableDefaultRoutes && CRUD_OPERATIONS.some((op) => !disabled.has(op));
451
+ }
452
+ /**
453
+ * Pick the controller for the resource:
454
+ * - user-supplied controller → forward `queryParser` to it (duck-typed)
455
+ * - no controller + CRUD routes + repository → auto-create BaseController
456
+ * - otherwise → undefined (custom-routes-only resource)
457
+ *
458
+ * Duck-typed `setQueryParser()` forwarding (v2.10.9) ensures operator filters
459
+ * like `[contains]` / `[like]` work in custom controllers too. Controllers
460
+ * that don't implement the method get a boot-time warn (v2.11) so authors
461
+ * of hand-rolled controllers see the dropped parser instead of silently
462
+ * debugging stale filter semantics. `BaseController` subclasses pick it up
463
+ * automatically.
464
+ */
465
+ function resolveOrAutoCreateController(resolvedConfig, adapter, repository, hasCrudRoutes) {
466
+ let controller = resolvedConfig.controller;
467
+ if (controller && resolvedConfig.queryParser) {
468
+ const ctrl = controller;
469
+ if (typeof ctrl.setQueryParser === "function") ctrl.setQueryParser(resolvedConfig.queryParser);
470
+ else arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" declares a custom \`queryParser\` but its controller does not expose \`setQueryParser(qp)\`. The parser will NOT be threaded into the controller's query resolution — operator filters (\`[contains]\`, \`[like]\`, etc.) may fall back to the controller's internal default. Extend \`BaseController\` / \`BaseCrudController\` (both implement \`setQueryParser\`) OR add the method to your custom controller to honor the resource-level parser.`);
471
+ }
472
+ if (controller || !hasCrudRoutes || !repository) return controller;
473
+ const qp = resolvedConfig.queryParser;
474
+ let maxLimitFromParser;
475
+ if (qp?.getQuerySchema) {
476
+ const limitProp = qp.getQuerySchema()?.properties?.limit;
477
+ if (limitProp?.maximum) maxLimitFromParser = limitProp.maximum;
478
+ }
479
+ controller = new BaseController(repository, {
480
+ resourceName: resolvedConfig.name,
481
+ schemaOptions: resolvedConfig.schemaOptions,
482
+ queryParser: resolvedConfig.queryParser,
483
+ maxLimit: maxLimitFromParser,
484
+ tenantField: resolvedConfig.tenantField,
485
+ idField: resolvedConfig.idField,
486
+ ...resolvedConfig.defaultSort !== void 0 ? { defaultSort: resolvedConfig.defaultSort } : {},
487
+ matchesFilter: adapter?.matchesFilter,
488
+ cache: resolvedConfig.cache,
489
+ onFieldWriteDenied: resolvedConfig.onFieldWriteDenied,
490
+ presetFields: resolvedConfig._controllerOptions ? {
491
+ slugField: resolvedConfig._controllerOptions.slugField,
492
+ parentField: resolvedConfig._controllerOptions.parentField
493
+ } : void 0
494
+ });
495
+ return controller;
496
+ }
497
+ /**
498
+ * Push preset-collected hooks and inline `config.hooks` onto the resource's
499
+ * `_pendingHooks`. The inline `config.hooks` handlers get a
500
+ * `ResourceHookContext` projection (v2.10.8) so they can reach `scope` /
501
+ * `context` without reaching into internal fields.
502
+ */
503
+ function wireHooks(resource, resolvedConfig, inlineHooksConfig) {
504
+ if (resolvedConfig._hooks?.length) resource._pendingHooks.push(...resolvedConfig._hooks.map((hook) => ({
505
+ operation: hook.operation,
506
+ phase: hook.phase,
507
+ handler: hook.handler,
508
+ priority: hook.priority ?? 10
509
+ })));
510
+ if (!inlineHooksConfig) return;
511
+ const toCtx = (ctx) => {
512
+ const context = ctx.context;
513
+ const rawScope = context?._scope;
514
+ return {
515
+ data: ctx.data ?? ctx.result ?? {},
516
+ user: ctx.user,
517
+ context,
518
+ scope: buildRequestScopeProjection(rawScope),
519
+ meta: ctx.meta
520
+ };
521
+ };
522
+ const INLINE_HOOK_SPECS = [
523
+ {
524
+ key: "beforeCreate",
525
+ operation: "create",
526
+ phase: "before"
527
+ },
528
+ {
529
+ key: "afterCreate",
530
+ operation: "create",
531
+ phase: "after"
532
+ },
533
+ {
534
+ key: "beforeUpdate",
535
+ operation: "update",
536
+ phase: "before"
537
+ },
538
+ {
539
+ key: "afterUpdate",
540
+ operation: "update",
541
+ phase: "after"
542
+ },
543
+ {
544
+ key: "beforeDelete",
545
+ operation: "delete",
546
+ phase: "before"
547
+ },
548
+ {
549
+ key: "afterDelete",
550
+ operation: "delete",
551
+ phase: "after"
552
+ }
553
+ ];
554
+ const h = inlineHooksConfig;
555
+ for (const spec of INLINE_HOOK_SPECS) {
556
+ const fn = h[spec.key];
557
+ if (typeof fn !== "function") continue;
558
+ resource._pendingHooks.push({
559
+ operation: spec.operation,
560
+ phase: spec.phase,
561
+ priority: 10,
562
+ handler: (ctx) => fn(toCtx(ctx))
563
+ });
564
+ }
565
+ }
566
+ /**
567
+ * Resolve OpenAPI schemas for a resource with a unified priority order:
568
+ *
569
+ * listQuery:
570
+ * 1. config.openApiSchemas.listQuery (user override — wins)
571
+ * 2. queryParser.getQuerySchema() (parser is source of truth)
572
+ * 3. adapter.generateSchemas().listQuery (fallback placeholder)
573
+ *
574
+ * createBody / updateBody / response / params:
575
+ * 1. config.openApiSchemas.{slot} (user override — wins)
576
+ * 2. adapter.generateSchemas().{slot} (auto-generated from DB schema)
577
+ *
578
+ * Why parser beats adapter for listQuery: the QueryParser knows the real
579
+ * query semantics (filter operators, max limit, sort whitelist, pagination).
580
+ * The adapter only knows persistence — it can't infer `name_contains` or
581
+ * `limit.maximum`.
582
+ *
583
+ * **Every downstream read comes from `resolvedConfig`, never raw `config`.**
584
+ * This is an audited convention — 2.10.6 shipped with a single
585
+ * `config.schemaOptions` slip that broke auto-inject forwarding, so every
586
+ * access is normalised through `resolvedConfig` to close the bug class.
587
+ *
588
+ * Non-fatal: if any phase throws, returns registry metadata anyway (with
589
+ * `openApiSchemas: undefined`) and warns. The resource still boots — docs
590
+ * and MCP tool schemas degrade visibly instead of silently drifting.
591
+ */
592
+ function resolveOpenApiSchemas(resolvedConfig) {
593
+ try {
594
+ let openApiSchemas = generateAdapterSchemas(resolvedConfig);
595
+ openApiSchemas = stripSystemManagedFromBodyRequired(openApiSchemas, resolvedConfig.schemaOptions);
596
+ openApiSchemas = cleanLegacyObjectIdParams(openApiSchemas, resolvedConfig.idField);
597
+ openApiSchemas = layerQueryParserListQuery(openApiSchemas, resolvedConfig.queryParser);
598
+ openApiSchemas = mergeUserOpenApiOverrides(openApiSchemas, resolvedConfig.openApiSchemas);
599
+ if (openApiSchemas) openApiSchemas = convertOpenApiSchemas(openApiSchemas);
600
+ return {
601
+ module: resolvedConfig.module,
602
+ openApiSchemas
603
+ };
604
+ } catch (err) {
605
+ arcLog("defineResource").warn(`OpenAPI/MCP schema generation failed for resource "${resolvedConfig.name}": ${err instanceof Error ? err.message : String(err)}. Resource will boot without registry metadata — OpenAPI docs and MCP tool schemas will be missing.`);
606
+ return;
607
+ }
608
+ }
609
+ function generateAdapterSchemas(resolvedConfig) {
610
+ if (!resolvedConfig.adapter?.generateSchemas) return void 0;
611
+ const adapterContext = {
612
+ idField: resolvedConfig.idField,
613
+ resourceName: resolvedConfig.name
614
+ };
615
+ return resolvedConfig.adapter.generateSchemas(resolvedConfig.schemaOptions, adapterContext);
616
+ }
617
+ /**
618
+ * Safety net: when `idField` is overridden to a non-default value (UUIDs,
619
+ * slugs, ORD-2026-0001), strip any ObjectId pattern left on `params.id` by
620
+ * legacy adapters or plugins that didn't honor `AdapterSchemaContext.idField`.
621
+ * Custom IDs must not be rejected by AJV before BaseController runs the
622
+ * actual lookup.
623
+ */
624
+ function cleanLegacyObjectIdParams(openApiSchemas, idField) {
625
+ if (!openApiSchemas || !idField || idField === "_id") return openApiSchemas;
626
+ const params = openApiSchemas.params;
627
+ if (!params || typeof params !== "object") return openApiSchemas;
628
+ const properties = params.properties;
629
+ const idProp = properties?.id;
630
+ if (!idProp || typeof idProp !== "object") return openApiSchemas;
631
+ const pattern = idProp.pattern;
632
+ 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)))) return openApiSchemas;
633
+ const cleanedId = { ...idProp };
634
+ delete cleanedId.pattern;
635
+ delete cleanedId.minLength;
636
+ delete cleanedId.maxLength;
637
+ if (!cleanedId.description) cleanedId.description = `${idField} (custom ID field)`;
638
+ return {
639
+ ...openApiSchemas,
640
+ params: {
641
+ ...params,
642
+ properties: {
643
+ ...properties,
644
+ id: cleanedId
645
+ }
646
+ }
647
+ };
648
+ }
649
+ function layerQueryParserListQuery(openApiSchemas, queryParser) {
650
+ const qp = queryParser;
651
+ if (!qp?.getQuerySchema) return openApiSchemas;
652
+ const querySchema = qp.getQuerySchema();
653
+ if (!querySchema) return openApiSchemas;
654
+ return {
655
+ ...openApiSchemas,
656
+ listQuery: querySchema
657
+ };
658
+ }
659
+ function mergeUserOpenApiOverrides(openApiSchemas, userOverrides) {
660
+ if (!userOverrides) return openApiSchemas;
661
+ return {
662
+ ...openApiSchemas,
663
+ ...userOverrides
664
+ };
665
+ }
666
+ var ResourceDefinition = class {
667
+ name;
668
+ displayName;
669
+ tag;
670
+ prefix;
671
+ adapter;
672
+ controller;
673
+ schemaOptions;
674
+ customSchemas;
675
+ permissions;
676
+ routes;
677
+ middlewares;
678
+ routeGuards;
679
+ disableDefaultRoutes;
680
+ disabledRoutes;
681
+ actions;
682
+ actionPermissions;
683
+ events;
684
+ rateLimit;
685
+ audit;
686
+ updateMethod;
687
+ pipe;
688
+ fields;
689
+ cache;
690
+ skipGlobalPrefix;
691
+ tenantField;
692
+ idField;
693
+ queryParser;
694
+ _appliedPresets;
695
+ _pendingHooks;
696
+ _registryMeta;
697
+ constructor(config) {
698
+ this.name = config.name;
699
+ this.displayName = config.displayName ?? `${capitalize(config.name)}s`;
700
+ this.tag = config.tag ?? this.displayName;
701
+ this.prefix = config.prefix ?? `/${config.name}s`;
702
+ this.skipGlobalPrefix = config.skipGlobalPrefix ?? false;
703
+ this.adapter = config.adapter;
704
+ this.controller = config.controller;
705
+ this.schemaOptions = config.schemaOptions ?? {};
706
+ this.customSchemas = config.customSchemas ?? {};
707
+ this.permissions = config.permissions ?? {};
708
+ this.routes = config.routes ?? [];
709
+ this.middlewares = config.middlewares ?? {};
710
+ this.routeGuards = config.routeGuards;
711
+ this.disableDefaultRoutes = config.disableDefaultRoutes ?? false;
712
+ this.disabledRoutes = config.disabledRoutes ?? [];
713
+ this.actions = config.actions;
714
+ this.actionPermissions = config.actionPermissions;
715
+ this.events = config.events ?? {};
716
+ this.rateLimit = config.rateLimit;
717
+ this.audit = config.audit;
718
+ this.updateMethod = config.updateMethod;
719
+ this.pipe = config.pipe;
720
+ this.fields = config.fields;
721
+ this.cache = config.cache;
722
+ this.tenantField = config.tenantField;
723
+ this.idField = config.idField;
724
+ this.queryParser = config.queryParser;
725
+ this._appliedPresets = config._appliedPresets ?? [];
726
+ this._pendingHooks = config._pendingHooks ?? [];
727
+ }
728
+ /** Get repository from adapter (if available) */
729
+ get repository() {
730
+ return this.adapter?.repository;
731
+ }
732
+ _validateControllerMethods() {
733
+ const errors = [];
734
+ const crudRoutes = CRUD_OPERATIONS;
735
+ const disabledRoutes = new Set(this.disabledRoutes ?? []);
736
+ const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
737
+ if (!this.disableDefaultRoutes && enabledCrudRoutes.length > 0) if (!this.controller) errors.push("Controller is required when CRUD routes are enabled");
738
+ else {
739
+ const ctrl = this.controller;
740
+ for (const method of enabledCrudRoutes) if (typeof ctrl[method] !== "function") errors.push(`CRUD method '${method}' not found on controller`);
741
+ }
742
+ for (const route of this.routes) if (typeof route.handler === "string") {
743
+ if (!this.controller) errors.push(`Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller`);
744
+ else if (typeof this.controller[route.handler] !== "function") errors.push(`Route ${route.method} ${route.path}: handler '${route.handler}' not found`);
745
+ }
746
+ if (errors.length > 0) {
747
+ const errorMsg = [
748
+ `Resource '${this.name}' validation failed:`,
749
+ ...errors.map((e) => ` - ${e}`),
750
+ "",
751
+ "Ensure controller implements IController<TDoc> interface.",
752
+ "For preset routes (softDelete, tree), add corresponding methods to controller."
753
+ ].join("\n");
754
+ throw new Error(errorMsg);
755
+ }
756
+ }
757
+ toPlugin() {
758
+ const self = this;
759
+ return async function resourcePlugin(fastify, _opts) {
760
+ const arc = fastify.arc;
761
+ if (arc?.registry && self._registryMeta) try {
762
+ arc.registry.register(self, self._registryMeta);
763
+ } catch (err) {
764
+ fastify.log?.warn?.(`Failed to register resource '${self.name}' in registry: ${err instanceof Error ? err.message : err}`);
765
+ }
766
+ if (self._pendingHooks.length > 0) {
767
+ const arc = fastify.arc;
768
+ if (arc?.hooks) for (const hook of self._pendingHooks) arc.hooks.register({
769
+ resource: self.name,
770
+ operation: hook.operation,
771
+ phase: hook.phase,
772
+ handler: hook.handler,
773
+ priority: hook.priority
774
+ });
775
+ }
776
+ const registerRule = fastify.registerCacheInvalidationRule;
777
+ if (self.cache?.invalidateOn && typeof registerRule === "function") for (const [pattern, tags] of Object.entries(self.cache.invalidateOn)) registerRule({
778
+ pattern,
779
+ tags
780
+ });
781
+ await fastify.register(async (instance) => {
782
+ const typedInstance = instance;
783
+ let schemas = null;
784
+ const openApi = self._registryMeta?.openApiSchemas;
785
+ if (openApi && (!self.customSchemas || Object.keys(self.customSchemas).length === 0)) {
786
+ const generated = {};
787
+ const { createBody, updateBody, params } = openApi;
788
+ const safeBody = (schema) => {
789
+ if (schema && typeof schema === "object" && schema.type === "object") return {
790
+ additionalProperties: true,
791
+ ...schema
792
+ };
793
+ return schema;
794
+ };
795
+ if (createBody) generated.create = { body: safeBody(createBody) };
796
+ if (updateBody) {
797
+ const patchBody = { ...updateBody };
798
+ delete patchBody.required;
799
+ generated.update = { body: safeBody(patchBody) };
800
+ if (params) generated.update.params = params;
801
+ }
802
+ if (params) {
803
+ generated.get = { params };
804
+ generated.delete = { params };
805
+ if (!generated.update) generated.update = { params };
806
+ else if (!generated.update.params) generated.update.params = params;
807
+ }
808
+ if (Object.keys(generated).length > 0) schemas = generated;
809
+ }
810
+ if (self.customSchemas && Object.keys(self.customSchemas).length > 0) {
811
+ schemas = schemas ?? {};
812
+ for (const [op, customSchema] of Object.entries(self.customSchemas)) {
813
+ const key = op;
814
+ const converted = convertRouteSchema(customSchema);
815
+ schemas[key] = schemas[key] ? deepMergeSchemas(schemas[key], converted) : converted;
816
+ }
817
+ }
818
+ const listQuerySchema = self._registryMeta?.openApiSchemas?.listQuery;
819
+ if (listQuerySchema) {
820
+ const NORMALIZED_PROPS = {
821
+ page: {
822
+ type: "integer",
823
+ minimum: 1
824
+ },
825
+ limit: {
826
+ type: "integer",
827
+ minimum: 1
828
+ },
829
+ sort: {},
830
+ search: {},
831
+ select: {},
832
+ after: {},
833
+ populate: {},
834
+ lookup: {},
835
+ aggregate: {}
836
+ };
837
+ const props = listQuerySchema.properties;
838
+ const normalizedProps = props ? { ...props } : void 0;
839
+ if (normalizedProps) {
840
+ const originalLimit = normalizedProps.limit;
841
+ if (originalLimit?.maximum) NORMALIZED_PROPS.limit = {
842
+ ...NORMALIZED_PROPS.limit,
843
+ maximum: originalLimit.maximum
844
+ };
845
+ for (const key of Object.keys(normalizedProps)) normalizedProps[key] = NORMALIZED_PROPS[key] ?? {};
846
+ }
847
+ const normalizedSchema = {
848
+ ...listQuerySchema,
849
+ ...normalizedProps ? { properties: normalizedProps } : {},
850
+ additionalProperties: listQuerySchema.additionalProperties ?? true
851
+ };
852
+ schemas = schemas ?? {};
853
+ schemas.list = schemas.list ? deepMergeSchemas({ querystring: normalizedSchema }, schemas.list) : { querystring: normalizedSchema };
854
+ }
855
+ createCrudRouter(typedInstance, self.controller, {
856
+ tag: self.tag,
857
+ schemas: schemas ?? void 0,
858
+ permissions: self.permissions,
859
+ middlewares: self.middlewares,
860
+ routeGuards: self.routeGuards,
861
+ routes: self.routes,
862
+ disableDefaultRoutes: self.disableDefaultRoutes,
863
+ disabledRoutes: self.disabledRoutes,
864
+ resourceName: self.name,
865
+ schemaOptions: self.schemaOptions,
866
+ rateLimit: self.rateLimit,
867
+ updateMethod: self.updateMethod,
868
+ pipe: self.pipe,
869
+ fields: self.fields
870
+ });
871
+ if (self.actions && Object.keys(self.actions).length > 0) {
872
+ const { createActionRouter } = await import("./createActionRouter-BwaSM0No.mjs").then((n) => n.n);
873
+ createActionRouter(typedInstance, {
874
+ ...normalizeActionsToRouterConfig(self.actions, self.actionPermissions, self.tag, self.permissions, self.name, typedInstance.log),
875
+ resourceName: self.name,
876
+ fields: self.fields,
877
+ schemaOptions: self.schemaOptions,
878
+ permissions: self.permissions,
879
+ routeGuards: self.routeGuards,
880
+ pipeline: self.pipe,
881
+ rateLimit: self.rateLimit
882
+ });
883
+ }
884
+ if (self.events && Object.keys(self.events).length > 0) typedInstance.log?.debug?.(`Resource '${self.name}' defined ${Object.keys(self.events).length} events`);
885
+ }, { prefix: self.prefix });
886
+ if (hasEvents(fastify)) try {
887
+ await fastify.events.publish("arc.resource.registered", {
888
+ resource: self.name,
889
+ prefix: self.prefix,
890
+ presets: self._appliedPresets,
891
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
892
+ });
893
+ } catch {}
894
+ };
895
+ }
896
+ /**
897
+ * Get event definitions for registry
898
+ */
899
+ getEvents() {
900
+ return Object.entries(this.events).map(([action, meta]) => ({
901
+ name: `${this.name}:${action}`,
902
+ module: this.name,
903
+ schema: meta.schema,
904
+ description: meta.description
905
+ }));
906
+ }
907
+ /**
908
+ * Get resource metadata
909
+ */
910
+ getMetadata() {
911
+ return {
912
+ name: this.name,
913
+ displayName: this.displayName,
914
+ tag: this.tag,
915
+ prefix: this.prefix,
916
+ presets: this._appliedPresets,
917
+ permissions: this.permissions,
918
+ customRoutes: (this.routes ?? []).map((r) => ({
919
+ method: r.method,
920
+ path: r.path,
921
+ handler: typeof r.handler === "string" ? r.handler : r.handler.name || "anonymous",
922
+ operation: r.operation,
923
+ summary: r.summary,
924
+ description: r.description,
925
+ permissions: r.permissions,
926
+ raw: r.raw,
927
+ schema: r.schema
928
+ })),
929
+ routes: [],
930
+ events: Object.keys(this.events)
931
+ };
932
+ }
933
+ };
934
+ function deepMergeSchemas(base, override) {
935
+ if (!override) return base;
936
+ if (!base) return override;
937
+ const result = { ...base };
938
+ for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [...new Set([...result[key], ...value])];
939
+ else if (value && typeof value === "object" && !Array.isArray(value)) result[key] = deepMergeSchemas(result[key], value);
940
+ else result[key] = value;
941
+ return result;
942
+ }
943
+ function capitalize(str) {
944
+ if (!str) return "";
945
+ return str.charAt(0).toUpperCase() + str.slice(1);
946
+ }
947
+ /**
948
+ * Normalize `ActionsMap` into the `ActionRouterConfig` shape that
949
+ * `createActionRouter` expects.
950
+ *
951
+ * **Permission fallback chain (fail-closed, v2.10.5):**
952
+ * Actions mutate state, so "no permission declared" historically meant
953
+ * "authenticated users can call it" — a silent authz hole for apps using
954
+ * the function shorthand `actions: { send: async (id, data, req) => ... }`.
955
+ *
956
+ * The chain is now:
957
+ * 1. `ActionDefinition.permissions` — explicit per-action check.
958
+ * 2. Resource-level `actionPermissions` — explicit global-for-actions.
959
+ * 3. Resource-level `permissions.update` — sensible default (actions mutate).
960
+ * 4. Boot-time error — forces the author to pick an explicit gate.
961
+ *
962
+ * When step 3 fires, we log a warning (not a throw) so upgrading apps
963
+ * aren't bricked by the behavior change, but the gap is visible. Apps
964
+ * that genuinely want public actions must declare `allowPublic()`
965
+ * explicitly — auth-by-accident is no longer a supported state.
966
+ */
967
+ function normalizeActionsToRouterConfig(actions, globalAuth, tag, resourcePermissions, resourceName, log) {
968
+ const handlers = {};
969
+ const permissions = {};
970
+ const schemas = {};
971
+ for (const [name, entry] of Object.entries(actions)) {
972
+ const explicit = typeof entry !== "function" && entry.permissions ? entry.permissions : void 0;
973
+ if (typeof entry === "function") handlers[name] = entry;
974
+ else {
975
+ const def = entry;
976
+ handlers[name] = def.handler;
977
+ if (def.permissions) permissions[name] = def.permissions;
978
+ if (def.schema) schemas[name] = def.schema;
979
+ }
980
+ const effective = resolveActionPermission({
981
+ action: entry,
982
+ resourcePermissions,
983
+ resourceActionPermissions: void 0,
984
+ globalAuth
985
+ });
986
+ if (!explicit && !globalAuth && effective && effective === resourcePermissions?.update) {
987
+ permissions[name] = effective;
988
+ log?.warn?.({
989
+ resource: resourceName,
990
+ action: name,
991
+ fallback: "permissions.update"
992
+ }, `[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.`);
993
+ }
994
+ 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.`);
995
+ }
996
+ return {
997
+ tag,
998
+ actions: handlers,
999
+ actionPermissions: permissions,
1000
+ actionSchemas: schemas,
1001
+ globalAuth
1002
+ };
1003
+ }
1004
+ //#endregion
1005
+ //#region src/core/defineResourceVariants.ts
1006
+ /**
1007
+ * Define multiple resources from a shared base config and per-variant overrides.
1008
+ *
1009
+ * Each variant is independently passed through `defineResource()` — the
1010
+ * returned `ResourceDefinition`s are real, fully-registered resources.
1011
+ * Register each one's plugin in your app:
1012
+ *
1013
+ * ```typescript
1014
+ * await app.register(articlePublic.toPlugin());
1015
+ * await app.register(articleAdmin.toPlugin());
1016
+ * ```
1017
+ *
1018
+ * @param base Shared config — adapter, queryParser, schemaOptions, hooks, etc.
1019
+ * Must NOT include `name` or `prefix` (those are per-variant).
1020
+ * @param variants Map of variant key → override. Each variant must declare
1021
+ * its own `name` and `prefix`. Other fields override the base.
1022
+ * @returns A record where each key from `variants` maps to a real
1023
+ * `ResourceDefinition` ready for `.toPlugin()` registration.
1024
+ */
1025
+ function defineResourceVariants(base, variants) {
1026
+ const out = {};
1027
+ for (const key of Object.keys(variants)) {
1028
+ const override = variants[key];
1029
+ out[key] = defineResource({
1030
+ ...base,
1031
+ ...override
1032
+ });
1033
+ }
1034
+ return out;
1035
+ }
1036
+ //#endregion
1037
+ export { createPermissionMiddleware as a, createCrudRouter as i, ResourceDefinition as n, defineResource as r, defineResourceVariants as t };