@classytic/arc 2.11.4 → 2.13.1

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 (166) hide show
  1. package/README.md +16 -12
  2. package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
  3. package/dist/EventTransport-CT_52aWU.d.mts +34 -0
  4. package/dist/EventTransport-DLWoUMHy.mjs +103 -0
  5. package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
  6. package/dist/audit/index.d.mts +2 -2
  7. package/dist/audit/index.mjs +1 -1
  8. package/dist/auth/audit.d.mts +199 -0
  9. package/dist/auth/audit.mjs +288 -0
  10. package/dist/auth/index.d.mts +3 -3
  11. package/dist/auth/index.mjs +117 -191
  12. package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
  13. package/dist/buildHandler-olo-gt94.mjs +610 -0
  14. package/dist/cache/index.mjs +3 -3
  15. package/dist/cli/commands/describe.d.mts +89 -13
  16. package/dist/cli/commands/describe.mjs +56 -2
  17. package/dist/cli/commands/docs.mjs +2 -2
  18. package/dist/cli/commands/generate.mjs +147 -48
  19. package/dist/cli/commands/init.d.mts +13 -0
  20. package/dist/cli/commands/init.mjs +130 -87
  21. package/dist/cli/commands/introspect.mjs +8 -1
  22. package/dist/context/index.mjs +1 -1
  23. package/dist/core/index.d.mts +3 -3
  24. package/dist/core/index.mjs +5 -5
  25. package/dist/core-D72ia0EH.mjs +1399 -0
  26. package/dist/{createActionRouter-CIKOcNA7.mjs → createActionRouter-CEvzKcy8.mjs} +7 -20
  27. package/dist/createAggregationRouter-CyecOxnO.mjs +114 -0
  28. package/dist/{createApp-C9bRrqlX.mjs → createApp-XX2-N0Yd.mjs} +28 -22
  29. package/dist/{defineEvent-D1Ky9M1D.mjs → defineEvent-D5h7EvAx.mjs} +1 -1
  30. package/dist/docs/index.d.mts +1 -1
  31. package/dist/docs/index.mjs +2 -2
  32. package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
  33. package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
  34. package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
  35. package/dist/errors-j4aJm1Wg.mjs +184 -0
  36. package/dist/{eventPlugin-Cts2-Tfj.mjs → eventPlugin-CaKTYkYM.mjs} +28 -4
  37. package/dist/{eventPlugin-DDJoNEPL.d.mts → eventPlugin-qXpqTebY.d.mts} +24 -1
  38. package/dist/events/index.d.mts +6 -6
  39. package/dist/events/index.mjs +11 -35
  40. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  41. package/dist/events/transports/redis.d.mts +1 -1
  42. package/dist/factory/index.d.mts +2 -2
  43. package/dist/factory/index.mjs +2 -2
  44. package/dist/{fields-BRjxOAFp.d.mts → fields-COhcH3fk.d.mts} +23 -2
  45. package/dist/hooks/index.d.mts +1 -1
  46. package/dist/hooks/index.mjs +1 -1
  47. package/dist/idempotency/index.d.mts +1 -1
  48. package/dist/idempotency/index.mjs +1 -20
  49. package/dist/idempotency/redis.mjs +1 -1
  50. package/dist/{index-rHjXmJar.d.mts → index-BTqLEvhu.d.mts} +163 -3
  51. package/dist/{index-CXXRbnf8.d.mts → index-BtW7qYwa.d.mts} +660 -326
  52. package/dist/{index-m8mOOlFW.d.mts → index-Ds61mrJE.d.mts} +50 -4
  53. package/dist/{index-D9t1KNaB.d.mts → index-Dz5IKsrE.d.mts} +360 -219
  54. package/dist/index.d.mts +6 -7
  55. package/dist/index.mjs +9 -10
  56. package/dist/integrations/event-gateway.d.mts +1 -1
  57. package/dist/integrations/event-gateway.mjs +1 -1
  58. package/dist/integrations/index.d.mts +1 -1
  59. package/dist/integrations/mcp/index.d.mts +2 -2
  60. package/dist/integrations/mcp/index.mjs +1 -1
  61. package/dist/integrations/mcp/testing.d.mts +1 -1
  62. package/dist/integrations/mcp/testing.mjs +1 -1
  63. package/dist/integrations/streamline.d.mts +60 -11
  64. package/dist/integrations/streamline.mjs +75 -85
  65. package/dist/integrations/websocket.mjs +2 -8
  66. package/dist/middleware/index.d.mts +1 -1
  67. package/dist/middleware/index.mjs +2 -2
  68. package/dist/migrations/index.d.mts +23 -3
  69. package/dist/migrations/index.mjs +0 -7
  70. package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
  71. package/dist/{openapi-D7G1V7ex.mjs → openapi-CiOMVW1p.mjs} +143 -13
  72. package/dist/org/index.d.mts +2 -2
  73. package/dist/org/index.mjs +1 -1
  74. package/dist/permissions/index.d.mts +3 -3
  75. package/dist/permissions/index.mjs +3 -3
  76. package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
  77. package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
  78. package/dist/pipeline/index.d.mts +1 -1
  79. package/dist/pipeline/index.mjs +1 -1
  80. package/dist/plugins/index.d.mts +16 -31
  81. package/dist/plugins/index.mjs +33 -13
  82. package/dist/plugins/response-cache.mjs +1 -1
  83. package/dist/plugins/tracing-entry.mjs +1 -1
  84. package/dist/presets/filesUpload.d.mts +4 -4
  85. package/dist/presets/filesUpload.mjs +6 -9
  86. package/dist/presets/index.d.mts +1 -1
  87. package/dist/presets/index.mjs +1 -1
  88. package/dist/presets/multiTenant.d.mts +1 -1
  89. package/dist/presets/multiTenant.mjs +2 -2
  90. package/dist/presets/search.d.mts +2 -2
  91. package/dist/presets/search.mjs +6 -8
  92. package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
  93. package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
  94. package/dist/{redis-stream-xTGxB2bm.d.mts → redis-stream-D6HzR1Z_.d.mts} +1 -1
  95. package/dist/registry/index.d.mts +1 -1
  96. package/dist/registry/index.mjs +2 -2
  97. package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
  98. package/dist/{resourceToTools-CxNmI6xF.mjs → resourceToTools-C5coh64w.mjs} +224 -71
  99. package/dist/{routerShared-BqLRb5l7.mjs → routerShared-D6_fEGHh.mjs} +40 -36
  100. package/dist/{schemaIR-Dy2p4MxS.mjs → schemaIR-7Vl611Qs.mjs} +1 -1
  101. package/dist/schemas/index.d.mts +100 -30
  102. package/dist/schemas/index.mjs +86 -29
  103. package/dist/scim/index.d.mts +264 -0
  104. package/dist/scim/index.mjs +963 -0
  105. package/dist/scope/index.d.mts +3 -3
  106. package/dist/scope/index.mjs +4 -4
  107. package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
  108. package/dist/{store-helpers-Cp4uKC1U.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
  109. package/dist/testing/index.d.mts +2 -8
  110. package/dist/testing/index.mjs +16 -24
  111. package/dist/types/index.d.mts +4 -4
  112. package/dist/{types-D7KpfiL1.d.mts → types-BvqwCCSx.d.mts} +73 -25
  113. package/dist/{types-DDyTPc6y.d.mts → types-CTYvcwHe.d.mts} +195 -1
  114. package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
  115. package/dist/{types-BQ9TJQNy.d.mts → types-DQHFc8PM.d.mts} +1 -1
  116. package/dist/utils/index.d.mts +2 -2
  117. package/dist/utils/index.mjs +5 -5
  118. package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
  119. package/dist/{versioning-DsglKfM_.d.mts → versioning-DTTvc80y.d.mts} +1 -1
  120. package/package.json +24 -34
  121. package/skills/arc/SKILL.md +147 -51
  122. package/skills/arc/references/agent-auth.md +238 -0
  123. package/skills/arc/references/api-reference.md +187 -0
  124. package/skills/arc/references/auth.md +354 -7
  125. package/skills/arc/references/enterprise-auth.md +94 -0
  126. package/skills/arc/references/events.md +8 -6
  127. package/skills/arc/references/mcp.md +2 -2
  128. package/skills/arc/references/multi-tenancy.md +11 -2
  129. package/skills/arc/references/production.md +10 -9
  130. package/skills/arc/references/scim.md +247 -0
  131. package/skills/arc/references/testing.md +1 -1
  132. package/skills/arc-code-review/SKILL.md +141 -0
  133. package/skills/arc-code-review/references/anti-patterns.md +911 -0
  134. package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
  135. package/skills/arc-code-review/references/migration-recipes.md +700 -0
  136. package/skills/arc-code-review/references/mongokit-migration.md +386 -0
  137. package/skills/arc-code-review/references/scaffolding.md +230 -0
  138. package/skills/arc-code-review/references/severity.md +127 -0
  139. package/dist/EventTransport-BFQjw9pB.mjs +0 -133
  140. package/dist/EventTransport-CYNUXdCJ.d.mts +0 -293
  141. package/dist/adapters/index.d.mts +0 -3
  142. package/dist/adapters/index.mjs +0 -2
  143. package/dist/adapters-DUUiiimH.mjs +0 -964
  144. package/dist/auth/mongoose.d.mts +0 -191
  145. package/dist/auth/mongoose.mjs +0 -73
  146. package/dist/core-CbcQRIch.mjs +0 -1054
  147. package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
  148. package/dist/errorHandler-DEWmGWPz.d.mts +0 -114
  149. package/dist/errors-D5c-5BJL.mjs +0 -232
  150. package/dist/index-Rg8axYPz.d.mts +0 -370
  151. /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
  152. /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
  153. /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
  154. /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
  155. /package/dist/{elevation-BQQXZ_VR.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
  156. /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
  157. /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
  158. /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
  159. /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
  160. /package/dist/{pluralize-CWP6MB39.mjs → pluralize-DQgqgifU.mjs} +0 -0
  161. /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
  162. /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
  163. /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
  164. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
  165. /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
  166. /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
@@ -1,1054 +0,0 @@
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-CcYTj09l.mjs";
4
- import { t as BaseController } from "./BaseController-swXruJ2_.mjs";
5
- import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-B0oKLuqI.mjs";
6
- import { b as buildRequestScopeProjection, c as buildPreHandlerChain, d as resolveRoutePreHandlers, f as resolveRouterPluginMw, h as createFastifyHandler, i as buildAuthMiddleware, l as buildRateLimitConfig, m as createCrudHandlers, o as buildCrudPermissionMw, p as selectPluginMw, r as buildArcDecorator, s as buildPipelineHandler, u as resolvePipelineSteps } from "./routerShared-BqLRb5l7.mjs";
7
- import { t as applyPresets } from "./presets-Z7P5w4gF.mjs";
8
- import { t as hasEvents } from "./typeGuards-CcFZXgU7.mjs";
9
- import { t as resolveActionPermission } from "./actionPermissions-sUUKDhtP.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 = resolveRoutePreHandlers(route.preHandler, fastify, `${route.method} ${route.path}`);
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) {
473
- const authorOptions = [];
474
- if (resolvedConfig.tenantField !== void 0) authorOptions.push("tenantField");
475
- if (resolvedConfig.schemaOptions !== void 0 && Object.keys(resolvedConfig.schemaOptions).length > 0) authorOptions.push("schemaOptions");
476
- if (resolvedConfig.idField !== void 0) authorOptions.push("idField");
477
- if (resolvedConfig.defaultSort !== void 0) authorOptions.push("defaultSort");
478
- if (resolvedConfig.cache !== void 0) authorOptions.push("cache");
479
- if (resolvedConfig.onFieldWriteDenied !== void 0) authorOptions.push("onFieldWriteDenied");
480
- if (authorOptions.length > 0) arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" declares a custom controller AND resource-level option(s) [${authorOptions.join(", ")}]. Arc only threads these when it auto-builds the controller — when you pass your own, they are dropped silently and the controller falls back to its own defaults (e.g. tenantField → 'organizationId'). Forward them to your controller's \`super(repo, { ... })\` call. Same root cause as the \`queryParser\` warn above.`);
481
- if (resolvedConfig._controllerOptions !== void 0) {
482
- const presetFields = [];
483
- if (resolvedConfig._controllerOptions.slugField) presetFields.push("slugField");
484
- if (resolvedConfig._controllerOptions.parentField) presetFields.push("parentField");
485
- arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" applies a preset that injects controller field(s) [${presetFields.join(", ") || "preset metadata"}] (e.g. slugLookup / softDelete / parent), but the resource also declares a custom controller. Preset metadata only reaches arc's auto-built BaseController — your custom controller will not see \`slugField\`/\`parentField\`/etc. Either (a) drop the preset on this resource (\`presets: [...]\` without it), or (b) extend \`BaseController\` / \`BaseCrudController\` so arc auto-builds the controller and threads the preset fields automatically.`);
486
- }
487
- return controller;
488
- }
489
- if (!hasCrudRoutes || !repository) return controller;
490
- const qp = resolvedConfig.queryParser;
491
- let maxLimitFromParser;
492
- if (qp?.getQuerySchema) {
493
- const limitProp = qp.getQuerySchema()?.properties?.limit;
494
- if (limitProp?.maximum) maxLimitFromParser = limitProp.maximum;
495
- }
496
- controller = new BaseController(repository, {
497
- resourceName: resolvedConfig.name,
498
- schemaOptions: resolvedConfig.schemaOptions,
499
- queryParser: resolvedConfig.queryParser,
500
- maxLimit: maxLimitFromParser,
501
- tenantField: resolvedConfig.tenantField,
502
- idField: resolvedConfig.idField,
503
- ...resolvedConfig.defaultSort !== void 0 ? { defaultSort: resolvedConfig.defaultSort } : {},
504
- matchesFilter: adapter?.matchesFilter,
505
- cache: resolvedConfig.cache,
506
- onFieldWriteDenied: resolvedConfig.onFieldWriteDenied,
507
- presetFields: resolvedConfig._controllerOptions ? {
508
- slugField: resolvedConfig._controllerOptions.slugField,
509
- parentField: resolvedConfig._controllerOptions.parentField
510
- } : void 0
511
- });
512
- return controller;
513
- }
514
- /**
515
- * Push preset-collected hooks and inline `config.hooks` onto the resource's
516
- * `_pendingHooks`. The inline `config.hooks` handlers get a
517
- * `ResourceHookContext` projection (v2.10.8) so they can reach `scope` /
518
- * `context` without reaching into internal fields.
519
- */
520
- function wireHooks(resource, resolvedConfig, inlineHooksConfig) {
521
- if (resolvedConfig._hooks?.length) resource._pendingHooks.push(...resolvedConfig._hooks.map((hook) => ({
522
- operation: hook.operation,
523
- phase: hook.phase,
524
- handler: hook.handler,
525
- priority: hook.priority ?? 10
526
- })));
527
- if (!inlineHooksConfig) return;
528
- const toCtx = (ctx) => {
529
- const context = ctx.context;
530
- const rawScope = context?._scope;
531
- return {
532
- data: ctx.data ?? ctx.result ?? {},
533
- user: ctx.user,
534
- context,
535
- scope: buildRequestScopeProjection(rawScope),
536
- meta: ctx.meta
537
- };
538
- };
539
- const INLINE_HOOK_SPECS = [
540
- {
541
- key: "beforeCreate",
542
- operation: "create",
543
- phase: "before"
544
- },
545
- {
546
- key: "afterCreate",
547
- operation: "create",
548
- phase: "after"
549
- },
550
- {
551
- key: "beforeUpdate",
552
- operation: "update",
553
- phase: "before"
554
- },
555
- {
556
- key: "afterUpdate",
557
- operation: "update",
558
- phase: "after"
559
- },
560
- {
561
- key: "beforeDelete",
562
- operation: "delete",
563
- phase: "before"
564
- },
565
- {
566
- key: "afterDelete",
567
- operation: "delete",
568
- phase: "after"
569
- }
570
- ];
571
- const h = inlineHooksConfig;
572
- for (const spec of INLINE_HOOK_SPECS) {
573
- const fn = h[spec.key];
574
- if (typeof fn !== "function") continue;
575
- resource._pendingHooks.push({
576
- operation: spec.operation,
577
- phase: spec.phase,
578
- priority: 10,
579
- handler: (ctx) => fn(toCtx(ctx))
580
- });
581
- }
582
- }
583
- /**
584
- * Resolve OpenAPI schemas for a resource with a unified priority order:
585
- *
586
- * listQuery:
587
- * 1. config.openApiSchemas.listQuery (user override — wins)
588
- * 2. queryParser.getQuerySchema() (parser is source of truth)
589
- * 3. adapter.generateSchemas().listQuery (fallback placeholder)
590
- *
591
- * createBody / updateBody / response / params:
592
- * 1. config.openApiSchemas.{slot} (user override — wins)
593
- * 2. adapter.generateSchemas().{slot} (auto-generated from DB schema)
594
- *
595
- * Why parser beats adapter for listQuery: the QueryParser knows the real
596
- * query semantics (filter operators, max limit, sort whitelist, pagination).
597
- * The adapter only knows persistence — it can't infer `name_contains` or
598
- * `limit.maximum`.
599
- *
600
- * **Every downstream read comes from `resolvedConfig`, never raw `config`.**
601
- * This is an audited convention — 2.10.6 shipped with a single
602
- * `config.schemaOptions` slip that broke auto-inject forwarding, so every
603
- * access is normalised through `resolvedConfig` to close the bug class.
604
- *
605
- * Non-fatal: if any phase throws, returns registry metadata anyway (with
606
- * `openApiSchemas: undefined`) and warns. The resource still boots — docs
607
- * and MCP tool schemas degrade visibly instead of silently drifting.
608
- */
609
- function resolveOpenApiSchemas(resolvedConfig) {
610
- try {
611
- let openApiSchemas = generateAdapterSchemas(resolvedConfig);
612
- openApiSchemas = stripSystemManagedFromBodyRequired(openApiSchemas, resolvedConfig.schemaOptions);
613
- openApiSchemas = cleanLegacyObjectIdParams(openApiSchemas, resolvedConfig.idField);
614
- openApiSchemas = layerQueryParserListQuery(openApiSchemas, resolvedConfig.queryParser);
615
- openApiSchemas = mergeUserOpenApiOverrides(openApiSchemas, resolvedConfig.openApiSchemas);
616
- if (openApiSchemas) openApiSchemas = convertOpenApiSchemas(openApiSchemas);
617
- return {
618
- module: resolvedConfig.module,
619
- openApiSchemas
620
- };
621
- } catch (err) {
622
- 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.`);
623
- return;
624
- }
625
- }
626
- function generateAdapterSchemas(resolvedConfig) {
627
- if (!resolvedConfig.adapter?.generateSchemas) return void 0;
628
- const adapterContext = {
629
- idField: resolvedConfig.idField,
630
- resourceName: resolvedConfig.name
631
- };
632
- return resolvedConfig.adapter.generateSchemas(resolvedConfig.schemaOptions, adapterContext);
633
- }
634
- /**
635
- * Safety net: when `idField` is overridden to a non-default value (UUIDs,
636
- * slugs, ORD-2026-0001), strip any ObjectId pattern left on `params.id` by
637
- * legacy adapters or plugins that didn't honor `AdapterSchemaContext.idField`.
638
- * Custom IDs must not be rejected by AJV before BaseController runs the
639
- * actual lookup.
640
- */
641
- function cleanLegacyObjectIdParams(openApiSchemas, idField) {
642
- if (!openApiSchemas || !idField || idField === "_id") return openApiSchemas;
643
- const params = openApiSchemas.params;
644
- if (!params || typeof params !== "object") return openApiSchemas;
645
- const properties = params.properties;
646
- const idProp = properties?.id;
647
- if (!idProp || typeof idProp !== "object") return openApiSchemas;
648
- const pattern = idProp.pattern;
649
- 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;
650
- const cleanedId = { ...idProp };
651
- delete cleanedId.pattern;
652
- delete cleanedId.minLength;
653
- delete cleanedId.maxLength;
654
- if (!cleanedId.description) cleanedId.description = `${idField} (custom ID field)`;
655
- return {
656
- ...openApiSchemas,
657
- params: {
658
- ...params,
659
- properties: {
660
- ...properties,
661
- id: cleanedId
662
- }
663
- }
664
- };
665
- }
666
- function layerQueryParserListQuery(openApiSchemas, queryParser) {
667
- const qp = queryParser;
668
- if (!qp?.getQuerySchema) return openApiSchemas;
669
- const querySchema = qp.getQuerySchema();
670
- if (!querySchema) return openApiSchemas;
671
- return {
672
- ...openApiSchemas,
673
- listQuery: querySchema
674
- };
675
- }
676
- function mergeUserOpenApiOverrides(openApiSchemas, userOverrides) {
677
- if (!userOverrides) return openApiSchemas;
678
- return {
679
- ...openApiSchemas,
680
- ...userOverrides
681
- };
682
- }
683
- var ResourceDefinition = class {
684
- name;
685
- displayName;
686
- tag;
687
- prefix;
688
- adapter;
689
- controller;
690
- schemaOptions;
691
- customSchemas;
692
- permissions;
693
- routes;
694
- middlewares;
695
- routeGuards;
696
- disableDefaultRoutes;
697
- disabledRoutes;
698
- actions;
699
- actionPermissions;
700
- events;
701
- rateLimit;
702
- audit;
703
- updateMethod;
704
- pipe;
705
- fields;
706
- cache;
707
- skipGlobalPrefix;
708
- tenantField;
709
- idField;
710
- queryParser;
711
- _appliedPresets;
712
- _pendingHooks;
713
- _registryMeta;
714
- constructor(config) {
715
- this.name = config.name;
716
- this.displayName = config.displayName ?? `${capitalize(config.name)}s`;
717
- this.tag = config.tag ?? this.displayName;
718
- this.prefix = config.prefix ?? `/${config.name}s`;
719
- this.skipGlobalPrefix = config.skipGlobalPrefix ?? false;
720
- this.adapter = config.adapter;
721
- this.controller = config.controller;
722
- this.schemaOptions = config.schemaOptions ?? {};
723
- this.customSchemas = config.customSchemas ?? {};
724
- this.permissions = config.permissions ?? {};
725
- this.routes = config.routes ?? [];
726
- this.middlewares = config.middlewares ?? {};
727
- this.routeGuards = config.routeGuards;
728
- this.disableDefaultRoutes = config.disableDefaultRoutes ?? false;
729
- this.disabledRoutes = config.disabledRoutes ?? [];
730
- this.actions = config.actions;
731
- this.actionPermissions = config.actionPermissions;
732
- this.events = config.events ?? {};
733
- this.rateLimit = config.rateLimit;
734
- this.audit = config.audit;
735
- this.updateMethod = config.updateMethod;
736
- this.pipe = config.pipe;
737
- this.fields = config.fields;
738
- this.cache = config.cache;
739
- this.tenantField = config.tenantField;
740
- this.idField = config.idField;
741
- this.queryParser = config.queryParser;
742
- this._appliedPresets = config._appliedPresets ?? [];
743
- this._pendingHooks = config._pendingHooks ?? [];
744
- }
745
- /** Get repository from adapter (if available) */
746
- get repository() {
747
- return this.adapter?.repository;
748
- }
749
- _validateControllerMethods() {
750
- const errors = [];
751
- const crudRoutes = CRUD_OPERATIONS;
752
- const disabledRoutes = new Set(this.disabledRoutes ?? []);
753
- const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
754
- if (!this.disableDefaultRoutes && enabledCrudRoutes.length > 0) if (!this.controller) errors.push("Controller is required when CRUD routes are enabled");
755
- else {
756
- const ctrl = this.controller;
757
- for (const method of enabledCrudRoutes) if (typeof ctrl[method] !== "function") errors.push(`CRUD method '${method}' not found on controller`);
758
- }
759
- for (const route of this.routes) if (typeof route.handler === "string") {
760
- if (!this.controller) errors.push(`Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller`);
761
- else if (typeof this.controller[route.handler] !== "function") errors.push(`Route ${route.method} ${route.path}: handler '${route.handler}' not found`);
762
- }
763
- if (errors.length > 0) {
764
- const errorMsg = [
765
- `Resource '${this.name}' validation failed:`,
766
- ...errors.map((e) => ` - ${e}`),
767
- "",
768
- "Ensure controller implements IController<TDoc> interface.",
769
- "For preset routes (softDelete, tree), add corresponding methods to controller."
770
- ].join("\n");
771
- throw new Error(errorMsg);
772
- }
773
- }
774
- toPlugin() {
775
- const self = this;
776
- return async function resourcePlugin(fastify, _opts) {
777
- const arc = fastify.arc;
778
- if (arc?.registry && self._registryMeta) try {
779
- arc.registry.register(self, self._registryMeta);
780
- } catch (err) {
781
- fastify.log?.warn?.(`Failed to register resource '${self.name}' in registry: ${err instanceof Error ? err.message : err}`);
782
- }
783
- if (self._pendingHooks.length > 0) {
784
- const arc = fastify.arc;
785
- if (arc?.hooks) for (const hook of self._pendingHooks) arc.hooks.register({
786
- resource: self.name,
787
- operation: hook.operation,
788
- phase: hook.phase,
789
- handler: hook.handler,
790
- priority: hook.priority
791
- });
792
- }
793
- const registerRule = fastify.registerCacheInvalidationRule;
794
- if (self.cache?.invalidateOn && typeof registerRule === "function") for (const [pattern, tags] of Object.entries(self.cache.invalidateOn)) registerRule({
795
- pattern,
796
- tags
797
- });
798
- await fastify.register(async (instance) => {
799
- const typedInstance = instance;
800
- let schemas = null;
801
- const openApi = self._registryMeta?.openApiSchemas;
802
- if (openApi && (!self.customSchemas || Object.keys(self.customSchemas).length === 0)) {
803
- const generated = {};
804
- const { createBody, updateBody, params } = openApi;
805
- const safeBody = (schema) => {
806
- if (schema && typeof schema === "object" && schema.type === "object") return {
807
- additionalProperties: true,
808
- ...schema
809
- };
810
- return schema;
811
- };
812
- if (createBody) generated.create = { body: safeBody(createBody) };
813
- if (updateBody) {
814
- const patchBody = { ...updateBody };
815
- delete patchBody.required;
816
- generated.update = { body: safeBody(patchBody) };
817
- if (params) generated.update.params = params;
818
- }
819
- if (params) {
820
- generated.get = { params };
821
- generated.delete = { params };
822
- if (!generated.update) generated.update = { params };
823
- else if (!generated.update.params) generated.update.params = params;
824
- }
825
- if (Object.keys(generated).length > 0) schemas = generated;
826
- }
827
- if (self.customSchemas && Object.keys(self.customSchemas).length > 0) {
828
- schemas = schemas ?? {};
829
- for (const [op, customSchema] of Object.entries(self.customSchemas)) {
830
- const key = op;
831
- const converted = convertRouteSchema(customSchema);
832
- schemas[key] = schemas[key] ? deepMergeSchemas(schemas[key], converted) : converted;
833
- }
834
- }
835
- const listQuerySchema = self._registryMeta?.openApiSchemas?.listQuery;
836
- if (listQuerySchema) {
837
- const NORMALIZED_PROPS = {
838
- page: {
839
- type: "integer",
840
- minimum: 1
841
- },
842
- limit: {
843
- type: "integer",
844
- minimum: 1
845
- },
846
- sort: {},
847
- search: {},
848
- select: {},
849
- after: {},
850
- populate: {},
851
- lookup: {},
852
- aggregate: {}
853
- };
854
- const props = listQuerySchema.properties;
855
- const normalizedProps = props ? { ...props } : void 0;
856
- if (normalizedProps) {
857
- const originalLimit = normalizedProps.limit;
858
- if (originalLimit?.maximum) NORMALIZED_PROPS.limit = {
859
- ...NORMALIZED_PROPS.limit,
860
- maximum: originalLimit.maximum
861
- };
862
- for (const key of Object.keys(normalizedProps)) normalizedProps[key] = NORMALIZED_PROPS[key] ?? {};
863
- }
864
- const normalizedSchema = {
865
- ...listQuerySchema,
866
- ...normalizedProps ? { properties: normalizedProps } : {},
867
- additionalProperties: listQuerySchema.additionalProperties ?? true
868
- };
869
- schemas = schemas ?? {};
870
- schemas.list = schemas.list ? deepMergeSchemas({ querystring: normalizedSchema }, schemas.list) : { querystring: normalizedSchema };
871
- }
872
- createCrudRouter(typedInstance, self.controller, {
873
- tag: self.tag,
874
- schemas: schemas ?? void 0,
875
- permissions: self.permissions,
876
- middlewares: self.middlewares,
877
- routeGuards: self.routeGuards,
878
- routes: self.routes,
879
- disableDefaultRoutes: self.disableDefaultRoutes,
880
- disabledRoutes: self.disabledRoutes,
881
- resourceName: self.name,
882
- schemaOptions: self.schemaOptions,
883
- rateLimit: self.rateLimit,
884
- updateMethod: self.updateMethod,
885
- pipe: self.pipe,
886
- fields: self.fields
887
- });
888
- if (self.actions && Object.keys(self.actions).length > 0) {
889
- const { createActionRouter } = await import("./createActionRouter-CIKOcNA7.mjs").then((n) => n.n);
890
- createActionRouter(typedInstance, {
891
- ...normalizeActionsToRouterConfig(self.actions, self.actionPermissions, self.tag, self.permissions, self.name, typedInstance.log),
892
- resourceName: self.name,
893
- fields: self.fields,
894
- schemaOptions: self.schemaOptions,
895
- permissions: self.permissions,
896
- routeGuards: self.routeGuards,
897
- pipeline: self.pipe,
898
- rateLimit: self.rateLimit
899
- });
900
- }
901
- if (self.events && Object.keys(self.events).length > 0) typedInstance.log?.debug?.(`Resource '${self.name}' defined ${Object.keys(self.events).length} events`);
902
- }, { prefix: self.prefix });
903
- if (hasEvents(fastify)) try {
904
- await fastify.events.publish("arc.resource.registered", {
905
- resource: self.name,
906
- prefix: self.prefix,
907
- presets: self._appliedPresets,
908
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
909
- });
910
- } catch {}
911
- };
912
- }
913
- /**
914
- * Get event definitions for registry
915
- */
916
- getEvents() {
917
- return Object.entries(this.events).map(([action, meta]) => ({
918
- name: `${this.name}:${action}`,
919
- module: this.name,
920
- schema: meta.schema,
921
- description: meta.description
922
- }));
923
- }
924
- /**
925
- * Get resource metadata
926
- */
927
- getMetadata() {
928
- return {
929
- name: this.name,
930
- displayName: this.displayName,
931
- tag: this.tag,
932
- prefix: this.prefix,
933
- presets: this._appliedPresets,
934
- permissions: this.permissions,
935
- customRoutes: (this.routes ?? []).map((r) => ({
936
- method: r.method,
937
- path: r.path,
938
- handler: typeof r.handler === "string" ? r.handler : r.handler.name || "anonymous",
939
- operation: r.operation,
940
- summary: r.summary,
941
- description: r.description,
942
- permissions: r.permissions,
943
- raw: r.raw,
944
- schema: r.schema
945
- })),
946
- routes: [],
947
- events: Object.keys(this.events)
948
- };
949
- }
950
- };
951
- function deepMergeSchemas(base, override) {
952
- if (!override) return base;
953
- if (!base) return override;
954
- const result = { ...base };
955
- for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [...new Set([...result[key], ...value])];
956
- else if (value && typeof value === "object" && !Array.isArray(value)) result[key] = deepMergeSchemas(result[key], value);
957
- else result[key] = value;
958
- return result;
959
- }
960
- function capitalize(str) {
961
- if (!str) return "";
962
- return str.charAt(0).toUpperCase() + str.slice(1);
963
- }
964
- /**
965
- * Normalize `ActionsMap` into the `ActionRouterConfig` shape that
966
- * `createActionRouter` expects.
967
- *
968
- * **Permission fallback chain (fail-closed, v2.10.5):**
969
- * Actions mutate state, so "no permission declared" historically meant
970
- * "authenticated users can call it" — a silent authz hole for apps using
971
- * the function shorthand `actions: { send: async (id, data, req) => ... }`.
972
- *
973
- * The chain is now:
974
- * 1. `ActionDefinition.permissions` — explicit per-action check.
975
- * 2. Resource-level `actionPermissions` — explicit global-for-actions.
976
- * 3. Resource-level `permissions.update` — sensible default (actions mutate).
977
- * 4. Boot-time error — forces the author to pick an explicit gate.
978
- *
979
- * When step 3 fires, we log a warning (not a throw) so upgrading apps
980
- * aren't bricked by the behavior change, but the gap is visible. Apps
981
- * that genuinely want public actions must declare `allowPublic()`
982
- * explicitly — auth-by-accident is no longer a supported state.
983
- */
984
- function normalizeActionsToRouterConfig(actions, globalAuth, tag, resourcePermissions, resourceName, log) {
985
- const handlers = {};
986
- const permissions = {};
987
- const schemas = {};
988
- for (const [name, entry] of Object.entries(actions)) {
989
- const explicit = typeof entry !== "function" && entry.permissions ? entry.permissions : void 0;
990
- if (typeof entry === "function") handlers[name] = entry;
991
- else {
992
- const def = entry;
993
- handlers[name] = def.handler;
994
- if (def.permissions) permissions[name] = def.permissions;
995
- if (def.schema) schemas[name] = def.schema;
996
- }
997
- const effective = resolveActionPermission({
998
- action: entry,
999
- resourcePermissions,
1000
- resourceActionPermissions: void 0,
1001
- globalAuth
1002
- });
1003
- if (!explicit && !globalAuth && effective && effective === resourcePermissions?.update) {
1004
- permissions[name] = effective;
1005
- log?.warn?.({
1006
- resource: resourceName,
1007
- action: name,
1008
- fallback: "permissions.update"
1009
- }, `[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.`);
1010
- }
1011
- 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.`);
1012
- }
1013
- return {
1014
- tag,
1015
- actions: handlers,
1016
- actionPermissions: permissions,
1017
- actionSchemas: schemas,
1018
- globalAuth
1019
- };
1020
- }
1021
- //#endregion
1022
- //#region src/core/defineResourceVariants.ts
1023
- /**
1024
- * Define multiple resources from a shared base config and per-variant overrides.
1025
- *
1026
- * Each variant is independently passed through `defineResource()` — the
1027
- * returned `ResourceDefinition`s are real, fully-registered resources.
1028
- * Register each one's plugin in your app:
1029
- *
1030
- * ```typescript
1031
- * await app.register(articlePublic.toPlugin());
1032
- * await app.register(articleAdmin.toPlugin());
1033
- * ```
1034
- *
1035
- * @param base Shared config — adapter, queryParser, schemaOptions, hooks, etc.
1036
- * Must NOT include `name` or `prefix` (those are per-variant).
1037
- * @param variants Map of variant key → override. Each variant must declare
1038
- * its own `name` and `prefix`. Other fields override the base.
1039
- * @returns A record where each key from `variants` maps to a real
1040
- * `ResourceDefinition` ready for `.toPlugin()` registration.
1041
- */
1042
- function defineResourceVariants(base, variants) {
1043
- const out = {};
1044
- for (const key of Object.keys(variants)) {
1045
- const override = variants[key];
1046
- out[key] = defineResource({
1047
- ...base,
1048
- ...override
1049
- });
1050
- }
1051
- return out;
1052
- }
1053
- //#endregion
1054
- export { createPermissionMiddleware as a, createCrudRouter as i, ResourceDefinition as n, defineResource as r, defineResourceVariants as t };