@classytic/arc 2.3.0 → 2.4.2

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 (175) hide show
  1. package/README.md +187 -18
  2. package/bin/arc.js +11 -3
  3. package/dist/BaseController-CkM5dUh_.mjs +1031 -0
  4. package/dist/{EventTransport-BkUDYZEb.d.mts → EventTransport-wc5hSLik.d.mts} +1 -1
  5. package/dist/{HookSystem-BsGV-j2l.mjs → HookSystem-COkyWztM.mjs} +2 -3
  6. package/dist/{ResourceRegistry-7Ic20ZMw.mjs → ResourceRegistry-DeCIFlix.mjs} +8 -5
  7. package/dist/adapters/index.d.mts +3 -5
  8. package/dist/adapters/index.mjs +2 -3
  9. package/dist/{prisma-DJbMt3yf.mjs → adapters-DTC4Ug66.mjs} +45 -12
  10. package/dist/audit/index.d.mts +4 -7
  11. package/dist/audit/index.mjs +2 -29
  12. package/dist/audit/mongodb.d.mts +1 -4
  13. package/dist/audit/mongodb.mjs +2 -3
  14. package/dist/auth/index.d.mts +7 -9
  15. package/dist/auth/index.mjs +65 -63
  16. package/dist/auth/redis-session.d.mts +1 -1
  17. package/dist/auth/redis-session.mjs +1 -2
  18. package/dist/{betterAuthOpenApi-DjWDddNc.mjs → betterAuthOpenApi-lz0IRbXJ.mjs} +4 -6
  19. package/dist/cache/index.d.mts +23 -23
  20. package/dist/cache/index.mjs +4 -6
  21. package/dist/{caching-GSDJcA6-.mjs → caching-BSXB-Xr7.mjs} +2 -24
  22. package/dist/chunk-BpYLSNr0.mjs +14 -0
  23. package/dist/circuitBreaker-BOBOpN2w.mjs +284 -0
  24. package/dist/circuitBreaker-JP2GdJ4b.d.mts +206 -0
  25. package/dist/cli/commands/describe.mjs +24 -7
  26. package/dist/cli/commands/docs.mjs +6 -7
  27. package/dist/cli/commands/doctor.d.mts +10 -0
  28. package/dist/cli/commands/doctor.mjs +156 -0
  29. package/dist/cli/commands/generate.mjs +66 -17
  30. package/dist/cli/commands/init.mjs +315 -45
  31. package/dist/cli/commands/introspect.mjs +2 -4
  32. package/dist/cli/index.d.mts +1 -10
  33. package/dist/cli/index.mjs +4 -153
  34. package/dist/{constants-DdXFXQtN.mjs → constants-Cxde4rpC.mjs} +1 -2
  35. package/dist/core/index.d.mts +3 -5
  36. package/dist/core/index.mjs +5 -4
  37. package/dist/core-C1XCMtqM.mjs +185 -0
  38. package/dist/{createApp-CgKOPhA4.mjs → createApp-ByWNRsZj.mjs} +64 -35
  39. package/dist/{defineResource-DWbpJYtm.mjs → defineResource-D9aY5Cy6.mjs} +108 -1157
  40. package/dist/discovery/index.mjs +37 -5
  41. package/dist/docs/index.d.mts +6 -9
  42. package/dist/docs/index.mjs +3 -21
  43. package/dist/dynamic/index.d.mts +93 -0
  44. package/dist/dynamic/index.mjs +122 -0
  45. package/dist/{elevation-DSTbVvYj.mjs → elevation-BEdACOLB.mjs} +5 -36
  46. package/dist/{elevation-DGo5shaX.d.mts → elevation-Ca_yveIO.d.mts} +41 -7
  47. package/dist/{errorHandler-C3GY3_ow.mjs → errorHandler--zp54tGc.mjs} +3 -5
  48. package/dist/errorHandler-Do4vVQ1f.d.mts +139 -0
  49. package/dist/{errors-DBANPbGr.mjs → errors-rxhfP7Hf.mjs} +1 -2
  50. package/dist/{eventPlugin-BEOvaDqo.mjs → eventPlugin-Ba00swHF.mjs} +25 -27
  51. package/dist/{eventPlugin-H6wDDjGO.d.mts → eventPlugin-iGrSEmwJ.d.mts} +105 -5
  52. package/dist/events/index.d.mts +72 -7
  53. package/dist/events/index.mjs +216 -4
  54. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  55. package/dist/events/transports/redis-stream-entry.mjs +19 -7
  56. package/dist/events/transports/redis.d.mts +1 -1
  57. package/dist/events/transports/redis.mjs +3 -4
  58. package/dist/factory/index.d.mts +23 -9
  59. package/dist/factory/index.mjs +48 -3
  60. package/dist/{fields-Bi_AVKSo.d.mts → fields-DFwdaWCq.d.mts} +1 -1
  61. package/dist/{fields-CTd_CrKr.mjs → fields-ipsbIRPK.mjs} +1 -2
  62. package/dist/hooks/index.d.mts +1 -3
  63. package/dist/hooks/index.mjs +2 -3
  64. package/dist/idempotency/index.d.mts +5 -5
  65. package/dist/idempotency/index.mjs +3 -7
  66. package/dist/idempotency/mongodb.d.mts +1 -1
  67. package/dist/idempotency/mongodb.mjs +4 -5
  68. package/dist/idempotency/redis.d.mts +1 -1
  69. package/dist/idempotency/redis.mjs +2 -5
  70. package/dist/{fastifyAdapter-6b_eRDBw.d.mts → index-BL8CaQih.d.mts} +56 -57
  71. package/dist/index-Diqcm14c.d.mts +369 -0
  72. package/dist/{prisma-Dy5S5F5i.d.mts → index-yhxyjqNb.d.mts} +4 -5
  73. package/dist/index.d.mts +100 -105
  74. package/dist/index.mjs +85 -58
  75. package/dist/integrations/event-gateway.d.mts +1 -1
  76. package/dist/integrations/event-gateway.mjs +8 -4
  77. package/dist/integrations/index.d.mts +4 -2
  78. package/dist/integrations/index.mjs +1 -1
  79. package/dist/integrations/jobs.d.mts +2 -2
  80. package/dist/integrations/jobs.mjs +63 -14
  81. package/dist/integrations/mcp/index.d.mts +219 -0
  82. package/dist/integrations/mcp/index.mjs +572 -0
  83. package/dist/integrations/mcp/testing.d.mts +53 -0
  84. package/dist/integrations/mcp/testing.mjs +104 -0
  85. package/dist/integrations/streamline.mjs +39 -19
  86. package/dist/integrations/webhooks.d.mts +56 -0
  87. package/dist/integrations/webhooks.mjs +139 -0
  88. package/dist/integrations/websocket-redis.d.mts +46 -0
  89. package/dist/integrations/websocket-redis.mjs +50 -0
  90. package/dist/integrations/websocket.d.mts +68 -2
  91. package/dist/integrations/websocket.mjs +96 -13
  92. package/dist/{interface-CSNjltAc.d.mts → interface-B4awm1RJ.d.mts} +2 -2
  93. package/dist/interface-DGmPxakH.d.mts +2213 -0
  94. package/dist/{keys-DhqDRxv3.mjs → keys-qcD-TVJl.mjs} +3 -4
  95. package/dist/{logger-ByrvQWZO.mjs → logger-Dz3j1ItV.mjs} +2 -4
  96. package/dist/{memory-B2v7KrCB.mjs → memory-Cb_7iy9e.mjs} +2 -4
  97. package/dist/metrics-Csh4nsvv.mjs +224 -0
  98. package/dist/migrations/index.d.mts +113 -44
  99. package/dist/migrations/index.mjs +84 -102
  100. package/dist/{mongodb-DNKEExbf.mjs → mongodb-BuQ7fNTg.mjs} +1 -4
  101. package/dist/{mongodb-ClykrfGo.d.mts → mongodb-CUpYfxfD.d.mts} +2 -3
  102. package/dist/{mongodb-Dg8O_gvd.d.mts → mongodb-bga9AbkD.d.mts} +2 -2
  103. package/dist/{openapi-9nB_kiuR.mjs → openapi-CBmZ6EQN.mjs} +4 -21
  104. package/dist/org/index.d.mts +12 -14
  105. package/dist/org/index.mjs +92 -119
  106. package/dist/org/types.d.mts +2 -2
  107. package/dist/org/types.mjs +1 -1
  108. package/dist/permissions/index.d.mts +4 -278
  109. package/dist/permissions/index.mjs +4 -579
  110. package/dist/permissions-CA5zg0yK.mjs +751 -0
  111. package/dist/plugins/index.d.mts +104 -107
  112. package/dist/plugins/index.mjs +203 -313
  113. package/dist/plugins/response-cache.mjs +4 -69
  114. package/dist/plugins/tracing-entry.d.mts +1 -1
  115. package/dist/plugins/tracing-entry.mjs +24 -11
  116. package/dist/{pluralize-CM-jZg7p.mjs → pluralize-CcT6qF0a.mjs} +12 -13
  117. package/dist/policies/index.d.mts +2 -2
  118. package/dist/policies/index.mjs +80 -83
  119. package/dist/presets/index.d.mts +26 -19
  120. package/dist/presets/index.mjs +2 -142
  121. package/dist/presets/multiTenant.d.mts +1 -4
  122. package/dist/presets/multiTenant.mjs +4 -6
  123. package/dist/presets-C9QXJV1u.mjs +422 -0
  124. package/dist/{queryCachePlugin-B6R0d4av.mjs → queryCachePlugin-ClosZdNS.mjs} +6 -27
  125. package/dist/{queryCachePlugin-Q6SYuHZ6.d.mts → queryCachePlugin-DcmETvcB.d.mts} +3 -3
  126. package/dist/queryParser-CgCtsjti.mjs +352 -0
  127. package/dist/{redis-UwjEp8Ea.d.mts → redis-CQ5YxMC5.d.mts} +2 -2
  128. package/dist/{redis-stream-CBg0upHI.d.mts → redis-stream-BW9UKLZM.d.mts} +9 -2
  129. package/dist/registry/index.d.mts +1 -4
  130. package/dist/registry/index.mjs +3 -4
  131. package/dist/{introspectionPlugin-B3JkrjwU.mjs → registry-I-ogLgL9.mjs} +1 -8
  132. package/dist/{requestContext-xi6OKBL-.mjs → requestContext-DYtmNpm5.mjs} +1 -3
  133. package/dist/resourceToTools-PMFE8HIv.mjs +533 -0
  134. package/dist/rpc/index.d.mts +90 -0
  135. package/dist/rpc/index.mjs +248 -0
  136. package/dist/{schemaConverter-Dtg0Kt9T.mjs → schemaConverter-DjzHpFam.mjs} +1 -2
  137. package/dist/schemas/index.d.mts +30 -30
  138. package/dist/schemas/index.mjs +2 -4
  139. package/dist/scope/index.d.mts +13 -2
  140. package/dist/scope/index.mjs +18 -5
  141. package/dist/{sessionManager-D_iEHjQl.d.mts → sessionManager-wbkYj2HL.d.mts} +2 -2
  142. package/dist/{sse-DkqQ1uxb.mjs → sse-BkViJPlT.mjs} +4 -25
  143. package/dist/testing/index.d.mts +551 -567
  144. package/dist/testing/index.mjs +1744 -1799
  145. package/dist/{tracing-8CEbhF0w.d.mts → tracing-bz_U4EM1.d.mts} +6 -1
  146. package/dist/{typeGuards-DwxA1t_L.mjs → typeGuards-Cj5Rgvlg.mjs} +1 -2
  147. package/dist/types/index.d.mts +4 -946
  148. package/dist/types/index.mjs +2 -4
  149. package/dist/types-BJmgxNbF.d.mts +275 -0
  150. package/dist/{types-RLkFVgaw.d.mts → types-BNUccdcf.d.mts} +2 -2
  151. package/dist/{types-Beqn1Un7.mjs → types-C6TQjtdi.mjs} +30 -2
  152. package/dist/{types-tKwaViYB.d.mts → types-Dt0-AI6E.d.mts} +68 -27
  153. package/dist/{types-DelU6kln.mjs → types-ZUu_h0jp.mjs} +1 -2
  154. package/dist/utils/index.d.mts +254 -351
  155. package/dist/utils/index.mjs +7 -6
  156. package/dist/utils-Dc0WhlIl.mjs +594 -0
  157. package/dist/versioning-BzfeHmhj.mjs +37 -0
  158. package/package.json +44 -10
  159. package/skills/arc/SKILL.md +518 -0
  160. package/skills/arc/references/auth.md +250 -0
  161. package/skills/arc/references/events.md +272 -0
  162. package/skills/arc/references/integrations.md +385 -0
  163. package/skills/arc/references/mcp.md +431 -0
  164. package/skills/arc/references/production.md +610 -0
  165. package/skills/arc/references/testing.md +183 -0
  166. package/dist/audited-CGdLiSlE.mjs +0 -140
  167. package/dist/chunk-C7Uep-_p.mjs +0 -20
  168. package/dist/circuitBreaker-CSS2VvL6.mjs +0 -1109
  169. package/dist/errorHandler-CW3OOeYq.d.mts +0 -72
  170. package/dist/interface-BtdYtQUA.d.mts +0 -1114
  171. package/dist/presets-BTeYbw7h.d.mts +0 -57
  172. package/dist/presets-CeFtfDR8.mjs +0 -119
  173. /package/dist/{errors-DAWRdiYP.d.mts → errors-CPpvPHT0.d.mts} +0 -0
  174. /package/dist/{externalPaths-SyPF2tgK.d.mts → externalPaths-DpO-s7r8.d.mts} +0 -0
  175. /package/dist/{interface-DTbsvIWe.d.mts → interface-D_BWALyZ.d.mts} +0 -0
@@ -1,918 +1,73 @@
1
- import { a as DEFAULT_SORT, d as MAX_REGEX_LENGTH, h as SYSTEM_FIELDS, n as DEFAULT_ID_FIELD, o as DEFAULT_TENANT_FIELD, r as DEFAULT_LIMIT, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-DdXFXQtN.mjs";
2
- import { c as isElevated, l as isMember, n as PUBLIC_SCOPE, r as getOrgId } from "./types-Beqn1Un7.mjs";
3
- import { getUserId } from "./types/index.mjs";
4
- import { i as resolveEffectiveRoles, n as applyFieldWritePermissions, t as applyFieldReadPermissions } from "./fields-CTd_CrKr.mjs";
5
- import { t as getUserRoles } from "./types-DelU6kln.mjs";
6
- import { C as ArcQueryParser, u as getDefaultCrudSchemas } from "./circuitBreaker-CSS2VvL6.mjs";
7
- import { t as buildQueryKey } from "./keys-DhqDRxv3.mjs";
8
- import { r as ForbiddenError } from "./errors-DBANPbGr.mjs";
9
- import { t as hasEvents } from "./typeGuards-DwxA1t_L.mjs";
10
- import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-Dtg0Kt9T.mjs";
11
- import { t as requestContext } from "./requestContext-xi6OKBL-.mjs";
12
- import { applyPresets, getAvailablePresets } from "./presets/index.mjs";
13
-
14
- //#region src/core/AccessControl.ts
15
- var AccessControl = class AccessControl {
16
- tenantField;
17
- idField;
18
- _adapterMatchesFilter;
19
- /** Patterns that indicate dangerous regex (nested quantifiers, excessive backtracking).
20
- * Uses [^...] character classes instead of .+ to avoid backtracking in the detector itself. */
21
- static DANGEROUS_REGEX = /(\{[0-9]+,\}[^{]*\{[0-9]+,\})|(\+[^+]*\+)|(\*[^*]*\*)|(\.\*){3,}|\\1/;
22
- /** Forbidden paths that could lead to prototype pollution */
23
- static FORBIDDEN_PATHS = [
24
- "__proto__",
25
- "constructor",
26
- "prototype"
27
- ];
28
- constructor(config) {
29
- this.tenantField = config.tenantField;
30
- this.idField = config.idField;
31
- this._adapterMatchesFilter = config.matchesFilter;
32
- }
33
- /**
34
- * Build filter for single-item operations (get/update/delete)
35
- * Combines ID filter with policy/org filters for proper security enforcement
36
- */
37
- buildIdFilter(id, req) {
38
- const filter = { [this.idField]: id };
39
- const arcContext = this._meta(req);
40
- const policyFilters = arcContext?._policyFilters;
41
- if (policyFilters) Object.assign(filter, policyFilters);
42
- const scope = arcContext?._scope;
43
- const orgId = scope ? getOrgId(scope) : void 0;
44
- if (this.tenantField && orgId && !policyFilters?.[this.tenantField]) filter[this.tenantField] = orgId;
45
- return filter;
46
- }
47
- /**
48
- * Check if item matches policy filters (for get/update/delete operations)
49
- * Validates that fetched item satisfies all policy constraints
50
- *
51
- * Delegates to adapter-provided matchesFilter if available (for SQL, etc.),
52
- * otherwise falls back to built-in MongoDB-style matching.
53
- */
54
- checkPolicyFilters(item, req) {
55
- const policyFilters = this._meta(req)?._policyFilters;
56
- if (!policyFilters) return true;
57
- if (this._adapterMatchesFilter) return this._adapterMatchesFilter(item, policyFilters);
58
- return this.defaultMatchesPolicyFilters(item, policyFilters);
59
- }
60
- /**
61
- * Check org/tenant scope for a document — uses configurable tenantField.
62
- *
63
- * SECURITY: When org scope is active (orgId present), documents that are
64
- * missing the tenant field are DENIED by default. This prevents legacy or
65
- * unscoped records from leaking across tenants.
66
- */
67
- checkOrgScope(item, arcContext) {
68
- if (!this.tenantField) return true;
69
- const scope = arcContext?._scope;
70
- const orgId = scope ? getOrgId(scope) : void 0;
71
- if (!item || !orgId) return true;
72
- if (scope && isElevated(scope) && !orgId) return true;
73
- const itemOrgId = item[this.tenantField];
74
- if (!itemOrgId) return false;
75
- return String(itemOrgId) === String(orgId);
76
- }
77
- /** Check ownership for update/delete (ownedByUser preset) */
78
- checkOwnership(item, req) {
79
- const ownershipCheck = this._meta(req)?._ownershipCheck;
80
- if (!item || !ownershipCheck) return true;
81
- const { field, userId } = ownershipCheck;
82
- const itemOwnerId = item[field];
83
- if (!itemOwnerId) return true;
84
- return String(itemOwnerId) === String(userId);
85
- }
86
- /**
87
- * Fetch a single document with full access control enforcement.
88
- * Combines compound DB filter (ID + org + policy) with post-hoc fallback.
89
- *
90
- * Takes repository as a parameter to avoid coupling.
91
- *
92
- * Replaces the duplicated pattern in get/update/delete:
93
- * buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
94
- */
95
- async fetchWithAccessControl(id, req, repository, queryOptions) {
96
- const compoundFilter = this.buildIdFilter(id, req);
97
- const hasCompoundFilters = Object.keys(compoundFilter).length > 1;
98
- try {
99
- if (hasCompoundFilters && typeof repository.getOne === "function") return await repository.getOne(compoundFilter, queryOptions);
100
- const item = await repository.getById(id, queryOptions);
101
- if (!item) return null;
102
- const arcContext = this._meta(req);
103
- if (!this.checkOrgScope(item, arcContext) || !this.checkPolicyFilters(item, req)) return null;
104
- return item;
105
- } catch (error) {
106
- if (error instanceof Error && error.message?.includes("not found")) return null;
107
- throw error;
108
- }
109
- }
110
- /**
111
- * Post-fetch access control validation for items fetched by non-ID queries
112
- * (e.g., getBySlug, restore). Applies org scope, policy filters, and
113
- * ownership checks — the same guarantees as fetchWithAccessControl.
114
- */
115
- validateItemAccess(item, req) {
116
- if (!item) return false;
117
- const arcContext = this._meta(req);
118
- if (!this.checkOrgScope(item, arcContext)) return false;
119
- if (!this.checkPolicyFilters(item, req)) return false;
120
- return true;
121
- }
122
- /** Extract typed Arc internal metadata from request */
123
- _meta(req) {
124
- return req.metadata;
125
- }
126
- /**
127
- * Check if a value matches a MongoDB query operator
128
- */
129
- matchesOperator(itemValue, operator, filterValue) {
130
- const equalsByValue = (a, b) => String(a) === String(b);
131
- switch (operator) {
132
- case "$eq": return equalsByValue(itemValue, filterValue);
133
- case "$ne": return !equalsByValue(itemValue, filterValue);
134
- case "$gt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue > filterValue;
135
- case "$gte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue >= filterValue;
136
- case "$lt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue < filterValue;
137
- case "$lte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue <= filterValue;
138
- case "$in":
139
- if (!Array.isArray(filterValue)) return false;
140
- if (Array.isArray(itemValue)) return itemValue.some((v) => filterValue.some((fv) => equalsByValue(v, fv)));
141
- return filterValue.some((fv) => equalsByValue(itemValue, fv));
142
- case "$nin":
143
- if (!Array.isArray(filterValue)) return false;
144
- if (Array.isArray(itemValue)) return itemValue.every((v) => filterValue.every((fv) => !equalsByValue(v, fv)));
145
- return filterValue.every((fv) => !equalsByValue(itemValue, fv));
146
- case "$exists": return filterValue ? itemValue !== void 0 : itemValue === void 0;
147
- case "$regex":
148
- if (typeof itemValue === "string" && (typeof filterValue === "string" || filterValue instanceof RegExp)) {
149
- const regex = typeof filterValue === "string" ? AccessControl.safeRegex(filterValue) : filterValue;
150
- return regex !== null && regex.test(itemValue);
151
- }
152
- return false;
153
- default: return false;
154
- }
155
- }
156
- /**
157
- * Check if item matches a single filter condition
158
- * Supports nested paths (e.g., "owner.id", "metadata.status")
159
- */
160
- matchesFilter(item, key, filterValue) {
161
- const itemValue = key.includes(".") ? this.getNestedValue(item, key) : item[key];
162
- if (filterValue && typeof filterValue === "object" && !Array.isArray(filterValue)) {
163
- if (Object.keys(filterValue).some((op) => op.startsWith("$"))) {
164
- for (const [operator, opValue] of Object.entries(filterValue)) if (!this.matchesOperator(itemValue, operator, opValue)) return false;
165
- return true;
166
- }
167
- }
168
- if (Array.isArray(itemValue)) return itemValue.some((v) => String(v) === String(filterValue));
169
- return String(itemValue) === String(filterValue);
170
- }
171
- /**
172
- * Built-in MongoDB-style policy filter matching.
173
- * Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
174
- */
175
- defaultMatchesPolicyFilters(item, policyFilters) {
176
- if (policyFilters.$and && Array.isArray(policyFilters.$and)) {
177
- if (!policyFilters.$and.every((condition) => {
178
- return Object.entries(condition).every(([key, value]) => {
179
- return this.matchesFilter(item, key, value);
180
- });
181
- })) return false;
182
- }
183
- if (policyFilters.$or && Array.isArray(policyFilters.$or)) {
184
- if (!policyFilters.$or.some((condition) => {
185
- return Object.entries(condition).every(([key, value]) => {
186
- return this.matchesFilter(item, key, value);
187
- });
188
- })) return false;
189
- }
190
- for (const [key, value] of Object.entries(policyFilters)) {
191
- if (key.startsWith("$")) continue;
192
- if (!this.matchesFilter(item, key, value)) return false;
193
- }
194
- return true;
195
- }
196
- /**
197
- * Get nested value from object using dot notation (e.g., "owner.id")
198
- * Security: Validates path against forbidden patterns to prevent prototype pollution
199
- */
200
- getNestedValue(obj, path) {
201
- if (AccessControl.FORBIDDEN_PATHS.some((p) => path.toLowerCase().includes(p))) return;
202
- const keys = path.split(".");
203
- let value = obj;
204
- for (const key of keys) {
205
- if (value == null) return void 0;
206
- if (AccessControl.FORBIDDEN_PATHS.includes(key.toLowerCase())) return;
207
- value = value[key];
208
- }
209
- return value;
210
- }
211
- /**
212
- * Create a safe RegExp from a string, guarding against ReDoS.
213
- * Returns null if the pattern is invalid or dangerous.
214
- */
215
- static safeRegex(pattern) {
216
- if (pattern.length > MAX_REGEX_LENGTH) return null;
217
- if (AccessControl.DANGEROUS_REGEX.test(pattern)) return null;
218
- try {
219
- return new RegExp(pattern);
220
- } catch {
221
- return null;
222
- }
223
- }
224
- };
225
-
226
- //#endregion
227
- //#region src/core/BodySanitizer.ts
228
- var BodySanitizer = class {
229
- schemaOptions;
230
- constructor(config) {
231
- this.schemaOptions = config.schemaOptions;
232
- }
233
- /**
234
- * Strip readonly and system-managed fields from request body.
235
- * Prevents clients from overwriting _id, timestamps, __v, etc.
236
- *
237
- * Also applies field-level write permissions when the request has
238
- * field permission metadata.
239
- */
240
- sanitize(body, _operation, req, meta) {
241
- let sanitized = { ...body };
242
- for (const field of SYSTEM_FIELDS) delete sanitized[field];
243
- const fieldRules = this.schemaOptions.fieldRules ?? {};
244
- for (const [field, rules] of Object.entries(fieldRules)) if (rules.systemManaged || rules.readonly) delete sanitized[field];
245
- if (req) {
246
- const arcContext = meta ?? req.metadata;
247
- const scope = arcContext?._scope ?? PUBLIC_SCOPE;
248
- if (!isElevated(scope)) {
249
- const fieldPerms = arcContext?.arc?.fields;
250
- if (fieldPerms) {
251
- const effectiveRoles = resolveEffectiveRoles(getUserRoles(req.user), isMember(scope) ? scope.orgRoles : []);
252
- sanitized = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
253
- }
254
- }
255
- }
256
- return sanitized;
257
- }
258
- };
259
-
260
- //#endregion
261
- //#region src/core/QueryResolver.ts
262
- const defaultParser = new ArcQueryParser();
263
- function getDefaultQueryParser() {
264
- return defaultParser;
1
+ import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-Cxde4rpC.mjs";
2
+ import { d as isMember, n as PUBLIC_SCOPE, u as isElevated } from "./types-C6TQjtdi.mjs";
3
+ import { t as BaseController } from "./BaseController-CkM5dUh_.mjs";
4
+ import { i as resolveEffectiveRoles, t as applyFieldReadPermissions } from "./fields-ipsbIRPK.mjs";
5
+ import { t as getUserRoles } from "./types-ZUu_h0jp.mjs";
6
+ import { t as requestContext } from "./requestContext-DYtmNpm5.mjs";
7
+ import { i as getDefaultCrudSchemas } from "./utils-Dc0WhlIl.mjs";
8
+ import { r as ForbiddenError } from "./errors-rxhfP7Hf.mjs";
9
+ import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-DjzHpFam.mjs";
10
+ import { t as hasEvents } from "./typeGuards-Cj5Rgvlg.mjs";
11
+ import { r as getAvailablePresets, t as applyPresets } from "./presets-C9QXJV1u.mjs";
12
+ //#region src/pipeline/pipe.ts
13
+ /**
14
+ * Compose pipeline steps into an ordered array.
15
+ * Accepts guards, transforms, and interceptors in any order.
16
+ */
17
+ function pipe(...steps) {
18
+ return steps;
265
19
  }
266
- var QueryResolver = class {
267
- queryParser;
268
- maxLimit;
269
- defaultLimit;
270
- defaultSort;
271
- schemaOptions;
272
- tenantField;
273
- constructor(config = {}) {
274
- this.queryParser = config.queryParser ?? getDefaultQueryParser();
275
- this.maxLimit = config.maxLimit ?? 100;
276
- this.defaultLimit = config.defaultLimit ?? DEFAULT_LIMIT;
277
- this.defaultSort = config.defaultSort ?? DEFAULT_SORT;
278
- this.schemaOptions = config.schemaOptions ?? {};
279
- this.tenantField = config.tenantField !== void 0 ? config.tenantField : DEFAULT_TENANT_FIELD;
280
- }
281
- /**
282
- * Resolve a request into parsed query options -- ONE parse per request.
283
- * Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
284
- */
285
- resolve(req, meta) {
286
- const parsed = this.queryParser.parse(req.query);
287
- const arcContext = meta ?? req.metadata;
288
- delete parsed.filters?._policyFilters;
289
- const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
290
- const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
291
- const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.defaultSort;
292
- const selectString = this.selectToString(parsed.select) ?? req.query?.select;
293
- const filters = { ...parsed.filters };
294
- const policyFilters = arcContext?._policyFilters;
295
- if (policyFilters) Object.assign(filters, policyFilters);
296
- const scope = arcContext?._scope;
297
- const orgId = scope ? getOrgId(scope) : void 0;
298
- if (this.tenantField && orgId && !policyFilters?.[this.tenantField]) filters[this.tenantField] = orgId;
299
- return {
300
- page,
301
- limit,
302
- sort: sortString,
303
- select: this.sanitizeSelect(selectString, this.schemaOptions),
304
- populate: this.sanitizePopulate(parsed.populate, this.schemaOptions),
305
- populateOptions: parsed.populateOptions,
306
- filters,
307
- search: parsed.search,
308
- after: parsed.after,
309
- user: req.user,
310
- context: arcContext
311
- };
312
- }
313
- /**
314
- * Convert parsed select object to string format
315
- * Converts { name: 1, email: 1, password: 0 } -> 'name email -password'
316
- */
317
- selectToString(select) {
318
- if (!select) return void 0;
319
- if (typeof select === "string") return select;
320
- if (Array.isArray(select)) return select.join(" ");
321
- if (Object.keys(select).length === 0) return void 0;
322
- return Object.entries(select).map(([field, include]) => include === 0 ? `-${field}` : field).join(" ");
323
- }
324
- /** Sanitize select fields */
325
- sanitizeSelect(select, schemaOptions) {
326
- if (!select) return void 0;
327
- const blockedFields = this.getBlockedFields(schemaOptions);
328
- if (blockedFields.length === 0) return select;
329
- const sanitized = select.split(/[\s,]+/).filter(Boolean).filter((f) => {
330
- const fieldName = f.replace(/^-/, "");
331
- return !blockedFields.includes(fieldName);
332
- });
333
- return sanitized.length > 0 ? sanitized.join(" ") : void 0;
334
- }
335
- /** Sanitize populate fields */
336
- sanitizePopulate(populate, schemaOptions) {
337
- if (!populate) return void 0;
338
- const allowedPopulate = schemaOptions.query?.allowedPopulate;
339
- const requested = typeof populate === "string" ? populate.split(",").map((p) => p.trim()) : Array.isArray(populate) ? populate.map(String) : [];
340
- if (requested.length === 0) return void 0;
341
- if (!allowedPopulate) return requested;
342
- const sanitized = requested.filter((p) => allowedPopulate.includes(p));
343
- return sanitized.length > 0 ? sanitized : void 0;
344
- }
345
- /** Get blocked fields from schema options */
346
- getBlockedFields(schemaOptions) {
347
- const fieldRules = schemaOptions.fieldRules ?? {};
348
- return Object.entries(fieldRules).filter(([, rules]) => rules.systemManaged || rules.hidden).map(([field]) => field);
349
- }
350
- };
351
-
352
- //#endregion
353
- //#region src/core/BaseController.ts
354
20
  /**
355
- * Framework-agnostic base controller implementing IController.
21
+ * Check if a step applies to the given operation.
22
+ */
23
+ function appliesTo(step, operation) {
24
+ if (!step.operations || step.operations.length === 0) return true;
25
+ return step.operations.includes(operation);
26
+ }
27
+ /**
28
+ * Execute a pipeline against a request context.
356
29
  *
357
- * Composes AccessControl, BodySanitizer, and QueryResolver for clean
358
- * separation of concerns. CRUD methods delegate directly to these
359
- * composed classes — no intermediate wrapper methods.
30
+ * This is the core runtime that createCrudRouter uses to execute pipelines.
31
+ * External usage is not needed this is wired automatically when `pipe` is set.
360
32
  *
361
- * @template TDoc - The document type
362
- * @template TRepository - The repository type (defaults to RepositoryLike)
33
+ * @param steps - Pipeline steps to execute
34
+ * @param ctx - The pipeline context (extends IRequestContext)
35
+ * @param handler - The actual controller method to call
36
+ * @param operation - The CRUD operation name
37
+ * @returns The controller response (possibly modified by interceptors)
363
38
  */
364
- var BaseController = class {
365
- repository;
366
- schemaOptions;
367
- queryParser;
368
- maxLimit;
369
- defaultLimit;
370
- defaultSort;
371
- resourceName;
372
- tenantField;
373
- idField = DEFAULT_ID_FIELD;
374
- /** Composable access control (ID filtering, policy checks, org scope, ownership) */
375
- accessControl;
376
- /** Composable body sanitization (field permissions, system fields) */
377
- bodySanitizer;
378
- /** Composable query resolution (parsing, pagination, sort, select/populate) */
379
- queryResolver;
380
- _matchesFilter;
381
- _presetFields = {};
382
- _cacheConfig;
383
- constructor(repository, options = {}) {
384
- this.repository = repository;
385
- this.schemaOptions = options.schemaOptions ?? {};
386
- this.queryParser = options.queryParser ?? getDefaultQueryParser();
387
- this.maxLimit = options.maxLimit ?? 100;
388
- this.defaultLimit = options.defaultLimit ?? DEFAULT_LIMIT;
389
- this.defaultSort = options.defaultSort ?? DEFAULT_SORT;
390
- this.resourceName = options.resourceName;
391
- this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
392
- this.idField = options.idField ?? DEFAULT_ID_FIELD;
393
- this._matchesFilter = options.matchesFilter;
394
- if (options.cache) this._cacheConfig = options.cache;
395
- if (options.presetFields) this._presetFields = options.presetFields;
396
- this.accessControl = new AccessControl({
397
- tenantField: this.tenantField,
398
- idField: this.idField,
399
- matchesFilter: this._matchesFilter
400
- });
401
- this.bodySanitizer = new BodySanitizer({ schemaOptions: this.schemaOptions });
402
- this.queryResolver = new QueryResolver({
403
- queryParser: this.queryParser,
404
- maxLimit: this.maxLimit,
405
- defaultLimit: this.defaultLimit,
406
- defaultSort: this.defaultSort,
407
- schemaOptions: this.schemaOptions,
408
- tenantField: this.tenantField
409
- });
410
- this.list = this.list.bind(this);
411
- this.get = this.get.bind(this);
412
- this.create = this.create.bind(this);
413
- this.update = this.update.bind(this);
414
- this.delete = this.delete.bind(this);
415
- }
416
- /**
417
- * Get the tenant field name if multi-tenant scoping is enabled.
418
- * Returns `undefined` when `tenantField` is `false` (platform-universal mode).
419
- *
420
- * Use this in subclass overrides instead of accessing `this.tenantField` directly
421
- * to avoid TypeScript indexing errors with `string | false`.
422
- */
423
- getTenantField() {
424
- return this.tenantField || void 0;
425
- }
426
- /** Extract typed Arc internal metadata from request */
427
- meta(req) {
428
- return req.metadata;
429
- }
430
- /** Get hook system from request context (instance-scoped) */
431
- getHooks(req) {
432
- return this.meta(req)?.arc?.hooks ?? null;
433
- }
434
- /** Resolve cache config for a specific operation, merging per-op overrides */
435
- resolveCacheConfig(operation) {
436
- const cfg = this._cacheConfig;
437
- if (!cfg || cfg.disabled) return null;
438
- const opOverride = cfg[operation];
439
- return {
440
- staleTime: opOverride?.staleTime ?? cfg.staleTime ?? 0,
441
- gcTime: opOverride?.gcTime ?? cfg.gcTime ?? 60,
442
- tags: cfg.tags
443
- };
444
- }
445
- /** Extract user/org IDs from request for cache key scoping */
446
- cacheScope(req) {
447
- const userId = getUserId(req.user);
448
- const scope = this.meta(req)?._scope;
449
- return {
450
- userId,
451
- orgId: scope ? getOrgId(scope) : void 0
452
- };
453
- }
454
- async list(req) {
455
- const options = this.queryResolver.resolve(req, this.meta(req));
456
- const cacheConfig = this.resolveCacheConfig("list");
457
- const qc = req.server?.queryCache;
458
- if (cacheConfig && qc) {
459
- const version = await qc.getResourceVersion(this.resourceName);
460
- const { userId, orgId } = this.cacheScope(req);
461
- const key = buildQueryKey(this.resourceName, "list", version, options, userId, orgId);
462
- const { data, status } = await qc.get(key);
463
- if (status === "fresh") return {
464
- success: true,
465
- data,
466
- status: 200,
467
- headers: { "x-cache": "HIT" }
468
- };
469
- if (status === "stale") {
470
- setImmediate(() => {
471
- this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
472
- });
473
- return {
474
- success: true,
475
- data,
476
- status: 200,
477
- headers: { "x-cache": "STALE" }
478
- };
479
- }
480
- const result = await this.executeListQuery(options, req);
481
- await qc.set(key, result, cacheConfig);
482
- return {
483
- success: true,
484
- data: result,
485
- status: 200,
486
- headers: { "x-cache": "MISS" }
487
- };
488
- }
489
- return {
490
- success: true,
491
- data: await this.executeListQuery(options, req),
492
- status: 200
493
- };
494
- }
495
- /** Execute list query through hooks (extracted for cache revalidation) */
496
- async executeListQuery(options, req) {
497
- const hooks = this.getHooks(req);
498
- const repoGetAll = async () => this.repository.getAll(options);
499
- const result = hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "list", options, repoGetAll, {
500
- user: req.user,
501
- context: this.meta(req)
502
- }) : await repoGetAll();
503
- if (Array.isArray(result)) return {
504
- docs: result,
505
- page: 1,
506
- limit: result.length,
507
- total: result.length,
508
- pages: 1,
509
- hasNext: false,
510
- hasPrev: false
511
- };
512
- return result;
513
- }
514
- async get(req) {
515
- const id = req.params.id;
516
- if (!id) return {
517
- success: false,
518
- error: "ID parameter is required",
519
- status: 400
520
- };
521
- const options = this.queryResolver.resolve(req, this.meta(req));
522
- const cacheConfig = this.resolveCacheConfig("byId");
523
- const qc = req.server?.queryCache;
524
- if (cacheConfig && qc) {
525
- const version = await qc.getResourceVersion(this.resourceName);
526
- const { userId, orgId } = this.cacheScope(req);
527
- const key = buildQueryKey(this.resourceName, "get", version, {
528
- id,
529
- ...options
530
- }, userId, orgId);
531
- const { data, status } = await qc.get(key);
532
- if (status === "fresh") return {
533
- success: true,
534
- data,
535
- status: 200,
536
- headers: { "x-cache": "HIT" }
537
- };
538
- if (status === "stale") {
539
- setImmediate(() => {
540
- this.executeGetQuery(id, options, req).then((fresh) => {
541
- if (fresh) qc.set(key, fresh, cacheConfig);
542
- }).catch(() => {});
543
- });
544
- return {
545
- success: true,
546
- data,
547
- status: 200,
548
- headers: { "x-cache": "STALE" }
549
- };
550
- }
551
- const item = await this.executeGetQuery(id, options, req);
552
- if (!item) return {
553
- success: false,
554
- error: "Resource not found",
555
- status: 404
556
- };
557
- await qc.set(key, item, cacheConfig);
558
- return {
559
- success: true,
560
- data: item,
561
- status: 200,
562
- headers: { "x-cache": "MISS" }
563
- };
564
- }
565
- try {
566
- const item = await this.executeGetQuery(id, options, req);
567
- if (!item) return {
568
- success: false,
569
- error: "Resource not found",
570
- status: 404
571
- };
572
- return {
573
- success: true,
574
- data: item,
575
- status: 200
576
- };
577
- } catch (error) {
578
- if (error instanceof Error && error.message?.includes("not found")) return {
579
- success: false,
580
- error: "Resource not found",
581
- status: 404
582
- };
583
- throw error;
584
- }
585
- }
586
- /** Execute get query through hooks (extracted for cache revalidation) */
587
- async executeGetQuery(id, options, req) {
588
- const hooks = this.getHooks(req);
589
- const fetchItem = async () => this.accessControl.fetchWithAccessControl(id, req, this.repository, options);
590
- return (hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "read", null, fetchItem, {
591
- user: req.user,
592
- context: this.meta(req)
593
- }) : await fetchItem()) ?? null;
594
- }
595
- async create(req) {
596
- const arcContext = this.meta(req);
597
- const data = this.bodySanitizer.sanitize(req.body ?? {}, "create", req, arcContext);
598
- const scope = arcContext?._scope;
599
- const createOrgId = scope ? getOrgId(scope) : void 0;
600
- if (this.tenantField && createOrgId) data[this.tenantField] = createOrgId;
601
- const userId = getUserId(req.user);
602
- if (userId) data.createdBy = userId;
603
- const hooks = this.getHooks(req);
604
- const user = req.user;
605
- let processedData = data;
606
- if (hooks && this.resourceName) try {
607
- processedData = await hooks.executeBefore(this.resourceName, "create", data, {
608
- user,
609
- context: arcContext
610
- });
611
- } catch (err) {
612
- return {
613
- success: false,
614
- error: "Hook execution failed",
615
- details: {
616
- code: "BEFORE_CREATE_HOOK_ERROR",
617
- message: err.message
618
- },
619
- status: 400
620
- };
621
- }
622
- const repoCreate = async () => this.repository.create(processedData, {
623
- user,
624
- context: arcContext
625
- });
626
- let item;
627
- if (hooks && this.resourceName) {
628
- item = await hooks.executeAround(this.resourceName, "create", processedData, repoCreate, {
629
- user,
630
- context: arcContext
631
- });
632
- await hooks.executeAfter(this.resourceName, "create", item, {
633
- user,
634
- context: arcContext
635
- });
636
- } else item = await repoCreate();
637
- return {
638
- success: true,
639
- data: item,
640
- status: 201,
641
- meta: { message: "Created successfully" }
642
- };
643
- }
644
- async update(req) {
645
- const id = req.params.id;
646
- if (!id) return {
647
- success: false,
648
- error: "ID parameter is required",
649
- status: 400
650
- };
651
- const arcContext = this.meta(req);
652
- const data = this.bodySanitizer.sanitize(req.body ?? {}, "update", req, arcContext);
653
- const user = req.user;
654
- const userId = getUserId(user);
655
- if (userId) data.updatedBy = userId;
656
- const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
657
- if (!existing) return {
658
- success: false,
659
- error: "Resource not found",
660
- status: 404
661
- };
662
- if (!this.accessControl.checkOwnership(existing, req)) return {
663
- success: false,
664
- error: "You do not have permission to modify this resource",
665
- details: { code: "OWNERSHIP_DENIED" },
666
- status: 403
667
- };
668
- const hooks = this.getHooks(req);
669
- let processedData = data;
670
- if (hooks && this.resourceName) try {
671
- processedData = await hooks.executeBefore(this.resourceName, "update", data, {
672
- user,
673
- context: arcContext,
674
- meta: {
675
- id,
676
- existing
677
- }
678
- });
679
- } catch (err) {
680
- return {
681
- success: false,
682
- error: "Hook execution failed",
683
- details: {
684
- code: "BEFORE_UPDATE_HOOK_ERROR",
685
- message: err.message
686
- },
687
- status: 400
688
- };
689
- }
690
- const repoUpdate = async () => this.repository.update(id, processedData, {
691
- user,
692
- context: arcContext
693
- });
694
- let item;
695
- if (hooks && this.resourceName) {
696
- item = await hooks.executeAround(this.resourceName, "update", processedData, repoUpdate, {
697
- user,
698
- context: arcContext,
699
- meta: {
700
- id,
701
- existing
702
- }
703
- });
704
- if (item) await hooks.executeAfter(this.resourceName, "update", item, {
705
- user,
706
- context: arcContext,
707
- meta: {
708
- id,
709
- existing
710
- }
711
- });
712
- } else item = await repoUpdate();
713
- if (!item) return {
714
- success: false,
715
- error: "Resource not found",
716
- status: 404
717
- };
718
- return {
719
- success: true,
720
- data: item,
721
- status: 200,
722
- meta: { message: "Updated successfully" }
723
- };
724
- }
725
- async delete(req) {
726
- const id = req.params.id;
727
- if (!id) return {
728
- success: false,
729
- error: "ID parameter is required",
730
- status: 400
731
- };
732
- const arcContext = this.meta(req);
733
- const user = req.user;
734
- const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
735
- if (!existing) return {
736
- success: false,
737
- error: "Resource not found",
738
- status: 404
739
- };
740
- if (!this.accessControl.checkOwnership(existing, req)) return {
741
- success: false,
742
- error: "You do not have permission to delete this resource",
743
- details: { code: "OWNERSHIP_DENIED" },
744
- status: 403
745
- };
746
- const hooks = this.getHooks(req);
747
- if (hooks && this.resourceName) try {
748
- await hooks.executeBefore(this.resourceName, "delete", existing, {
749
- user,
750
- context: arcContext,
751
- meta: { id }
752
- });
753
- } catch (err) {
754
- return {
755
- success: false,
756
- error: "Hook execution failed",
757
- details: {
758
- code: "BEFORE_DELETE_HOOK_ERROR",
759
- message: err.message
760
- },
761
- status: 400
762
- };
39
+ async function executePipeline(steps, ctx, handler, operation) {
40
+ const guards = [];
41
+ const transforms = [];
42
+ const interceptors = [];
43
+ for (const step of steps) {
44
+ if (!appliesTo(step, operation)) continue;
45
+ switch (step._type) {
46
+ case "guard":
47
+ guards.push(step);
48
+ break;
49
+ case "transform":
50
+ transforms.push(step);
51
+ break;
52
+ case "interceptor":
53
+ interceptors.push(step);
54
+ break;
763
55
  }
764
- const repoDelete = async () => this.repository.delete(id, {
765
- user,
766
- context: arcContext
767
- });
768
- let result;
769
- if (hooks && this.resourceName) result = await hooks.executeAround(this.resourceName, "delete", existing, repoDelete, {
770
- user,
771
- context: arcContext,
772
- meta: { id }
773
- });
774
- else result = await repoDelete();
775
- if (!(typeof result === "object" && result !== null ? result.success : result)) return {
776
- success: false,
777
- error: "Resource not found",
778
- status: 404
779
- };
780
- if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "delete", existing, {
781
- user,
782
- context: arcContext,
783
- meta: { id }
784
- });
785
- const deleteResult = typeof result === "object" && result !== null ? result : {};
786
- return {
787
- success: true,
788
- data: {
789
- message: deleteResult.message || "Deleted successfully",
790
- ...id ? { id } : {},
791
- ...deleteResult.soft ? { soft: true } : {}
792
- },
793
- status: 200
794
- };
795
- }
796
- async getBySlug(req) {
797
- const repo = this.repository;
798
- if (!repo.getBySlug) return {
799
- success: false,
800
- error: "Slug lookup not implemented",
801
- status: 501
802
- };
803
- const slugField = this._presetFields.slugField ?? "slug";
804
- const slug = req.params[slugField] ?? req.params.slug;
805
- const options = this.queryResolver.resolve(req, this.meta(req));
806
- const item = await repo.getBySlug(slug, options);
807
- if (!this.accessControl.validateItemAccess(item, req)) return {
808
- success: false,
809
- error: "Resource not found",
810
- status: 404
811
- };
812
- return {
813
- success: true,
814
- data: item,
815
- status: 200
816
- };
817
- }
818
- async getDeleted(req) {
819
- const repo = this.repository;
820
- if (!repo.getDeleted) return {
821
- success: false,
822
- error: "Soft delete not implemented",
823
- status: 501
824
- };
825
- const options = this.queryResolver.resolve(req, this.meta(req));
826
- const result = await repo.getDeleted(options);
827
- if (Array.isArray(result)) return {
828
- success: true,
829
- data: {
830
- docs: result,
831
- page: 1,
832
- limit: result.length,
833
- total: result.length,
834
- pages: 1,
835
- hasNext: false,
836
- hasPrev: false
837
- },
838
- status: 200
839
- };
840
- return {
841
- success: true,
842
- data: result,
843
- status: 200
844
- };
845
56
  }
846
- async restore(req) {
847
- const repo = this.repository;
848
- if (!repo.restore) return {
849
- success: false,
850
- error: "Restore not implemented",
851
- status: 501
852
- };
853
- const id = req.params.id;
854
- if (!id) return {
855
- success: false,
856
- error: "ID parameter is required",
857
- status: 400
858
- };
859
- const existing = await this.accessControl.fetchWithAccessControl(id, req, repo);
860
- if (!existing) return {
861
- success: false,
862
- error: "Resource not found",
863
- status: 404
864
- };
865
- if (!this.accessControl.checkOwnership(existing, req)) return {
866
- success: false,
867
- error: "You do not have permission to restore this resource",
868
- details: { code: "OWNERSHIP_DENIED" },
869
- status: 403
870
- };
871
- const item = await repo.restore(id);
872
- if (!item) return {
873
- success: false,
874
- error: "Resource not found",
875
- status: 404
876
- };
877
- return {
878
- success: true,
879
- data: item,
880
- status: 200,
881
- meta: { message: "Restored successfully" }
882
- };
883
- }
884
- async getTree(req) {
885
- const repo = this.repository;
886
- if (!repo.getTree) return {
887
- success: false,
888
- error: "Tree structure not implemented",
889
- status: 501
890
- };
891
- const options = this.queryResolver.resolve(req, this.meta(req));
892
- return {
893
- success: true,
894
- data: await repo.getTree(options),
895
- status: 200
896
- };
57
+ for (const g of guards) if (!await g.handler(ctx)) throw new ForbiddenError(`Guard '${g.name}' denied access`);
58
+ let currentCtx = ctx;
59
+ for (const t of transforms) {
60
+ const result = await t.handler(currentCtx);
61
+ if (result) currentCtx = result;
897
62
  }
898
- async getChildren(req) {
899
- const repo = this.repository;
900
- if (!repo.getChildren) return {
901
- success: false,
902
- error: "Tree structure not implemented",
903
- status: 501
904
- };
905
- const parentField = this._presetFields.parentField ?? "parent";
906
- const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
907
- const options = this.queryResolver.resolve(req, this.meta(req));
908
- return {
909
- success: true,
910
- data: await repo.getChildren(parentId, options),
911
- status: 200
912
- };
63
+ let chain = () => handler(currentCtx);
64
+ for (let i = interceptors.length - 1; i >= 0; i--) {
65
+ const interceptor = interceptors[i];
66
+ const next = chain;
67
+ chain = () => interceptor.handler(currentCtx, next);
913
68
  }
914
- };
915
-
69
+ return chain();
70
+ }
916
71
  //#endregion
917
72
  //#region src/core/fastifyAdapter.ts
918
73
  /** Type guard for Mongoose-like documents with toObject() */
@@ -1132,68 +287,6 @@ function createCrudHandlers(controller) {
1132
287
  delete: createFastifyHandler(controller.delete.bind(controller))
1133
288
  };
1134
289
  }
1135
-
1136
- //#endregion
1137
- //#region src/pipeline/pipe.ts
1138
- /**
1139
- * Compose pipeline steps into an ordered array.
1140
- * Accepts guards, transforms, and interceptors in any order.
1141
- */
1142
- function pipe(...steps) {
1143
- return steps;
1144
- }
1145
- /**
1146
- * Check if a step applies to the given operation.
1147
- */
1148
- function appliesTo(step, operation) {
1149
- if (!step.operations || step.operations.length === 0) return true;
1150
- return step.operations.includes(operation);
1151
- }
1152
- /**
1153
- * Execute a pipeline against a request context.
1154
- *
1155
- * This is the core runtime that createCrudRouter uses to execute pipelines.
1156
- * External usage is not needed — this is wired automatically when `pipe` is set.
1157
- *
1158
- * @param steps - Pipeline steps to execute
1159
- * @param ctx - The pipeline context (extends IRequestContext)
1160
- * @param handler - The actual controller method to call
1161
- * @param operation - The CRUD operation name
1162
- * @returns The controller response (possibly modified by interceptors)
1163
- */
1164
- async function executePipeline(steps, ctx, handler, operation) {
1165
- const guards = [];
1166
- const transforms = [];
1167
- const interceptors = [];
1168
- for (const step of steps) {
1169
- if (!appliesTo(step, operation)) continue;
1170
- switch (step._type) {
1171
- case "guard":
1172
- guards.push(step);
1173
- break;
1174
- case "transform":
1175
- transforms.push(step);
1176
- break;
1177
- case "interceptor":
1178
- interceptors.push(step);
1179
- break;
1180
- }
1181
- }
1182
- for (const g of guards) if (!await g.handler(ctx)) throw new ForbiddenError(`Guard '${g.name}' denied access`);
1183
- let currentCtx = ctx;
1184
- for (const t of transforms) {
1185
- const result = await t.handler(currentCtx);
1186
- if (result) currentCtx = result;
1187
- }
1188
- let chain = () => handler(currentCtx);
1189
- for (let i = interceptors.length - 1; i >= 0; i--) {
1190
- const interceptor = interceptors[i];
1191
- const next = chain;
1192
- chain = () => interceptor.handler(currentCtx, next);
1193
- }
1194
- return chain();
1195
- }
1196
-
1197
290
  //#endregion
1198
291
  //#region src/core/createCrudRouter.ts
1199
292
  /**
@@ -1291,9 +384,11 @@ function buildPermissionMiddleware(permissionCheck, resourceName, action) {
1291
384
  }
1292
385
  const permResult = result;
1293
386
  if (!permResult.granted) {
387
+ const defaultMsg = context.user ? "Permission denied" : "Authentication required";
388
+ const reason = permResult.reason && permResult.reason.length <= 100 ? permResult.reason : defaultMsg;
1294
389
  reply.code(context.user ? 403 : 401).send({
1295
390
  success: false,
1296
- error: permResult.reason ?? (context.user ? "Permission denied" : "Authentication required")
391
+ error: reason
1297
392
  });
1298
393
  return;
1299
394
  }
@@ -1566,194 +661,20 @@ function createCrudRouter(fastify, controller, options = {}) {
1566
661
  function createPermissionMiddleware(permission, resourceName, action) {
1567
662
  return buildPermissionMiddleware(permission, resourceName, action);
1568
663
  }
1569
-
1570
664
  //#endregion
1571
- //#region src/core/createActionRouter.ts
665
+ //#region src/core/validateResourceConfig.ts
1572
666
  /**
1573
- * Create action-based state transition endpoint
667
+ * Resource Configuration Validator
1574
668
  *
1575
- * Registers: POST /:id/action
1576
- * Body: { action: string, ...actionData }
669
+ * Fail-fast validation at definition time.
670
+ * Invalid configs throw immediately with clear, actionable errors.
1577
671
  *
1578
- * @param fastify - Fastify instance
1579
- * @param config - Action router configuration
1580
- */
1581
- function createActionRouter(fastify, config) {
1582
- const { tag, actions, actionPermissions = {}, actionSchemas = {}, globalAuth, idempotencyService, onError } = config;
1583
- const actionEnum = Object.keys(actions);
1584
- if (actionEnum.length === 0) {
1585
- fastify.log.warn("[createActionRouter] No actions defined, skipping route creation");
1586
- return;
1587
- }
1588
- const bodyProperties = { action: {
1589
- type: "string",
1590
- enum: actionEnum,
1591
- description: `Action to perform: ${actionEnum.join(" | ")}`
1592
- } };
1593
- Object.entries(actionSchemas).forEach(([actionName, schema]) => {
1594
- if (schema && typeof schema === "object") Object.entries(schema).forEach(([propName, propSchema]) => {
1595
- bodyProperties[propName] = {
1596
- ...propSchema,
1597
- description: `${propSchema.description || ""} (for ${actionName} action)`.trim()
1598
- };
1599
- });
1600
- });
1601
- const routeSchema = {
1602
- tags: tag ? [tag] : void 0,
1603
- summary: `Perform action (${actionEnum.join("/")})`,
1604
- description: buildActionDescription(actions, actionPermissions),
1605
- params: {
1606
- type: "object",
1607
- properties: { id: {
1608
- type: "string",
1609
- description: "Resource ID"
1610
- } },
1611
- required: ["id"]
1612
- },
1613
- body: {
1614
- type: "object",
1615
- properties: bodyProperties,
1616
- required: ["action"]
1617
- }
1618
- };
1619
- const preHandler = [];
1620
- const hasPublicActions = Object.entries(actionPermissions).some(([, p]) => p?._isPublic) || globalAuth && globalAuth?._isPublic;
1621
- const hasProtectedActions = Object.entries(actionPermissions).some(([, p]) => !p?._isPublic) || globalAuth && !globalAuth?._isPublic;
1622
- if (hasProtectedActions && !hasPublicActions && fastify.authenticate) preHandler.push(fastify.authenticate);
1623
- fastify.post("/:id/action", {
1624
- schema: routeSchema,
1625
- preHandler: preHandler.length ? preHandler : void 0
1626
- }, async (req, reply) => {
1627
- const { action, ...data } = req.body;
1628
- const { id } = req.params;
1629
- const rawIdempotencyKey = req.headers["idempotency-key"];
1630
- const idempotencyKey = Array.isArray(rawIdempotencyKey) ? rawIdempotencyKey[0] : rawIdempotencyKey;
1631
- const handler = actions[action];
1632
- if (!handler) return reply.code(400).send({
1633
- success: false,
1634
- error: `Invalid action '${action}'. Valid actions: ${actionEnum.join(", ")}`,
1635
- validActions: actionEnum
1636
- });
1637
- const permissionCheck = actionPermissions[action] ?? globalAuth;
1638
- if (hasPublicActions && hasProtectedActions && permissionCheck) {
1639
- if (!permissionCheck?._isPublic && fastify.authenticate) {
1640
- try {
1641
- await fastify.authenticate(req, reply);
1642
- } catch {
1643
- if (!reply.sent) return reply.code(401).send({
1644
- success: false,
1645
- error: "Authentication required"
1646
- });
1647
- return;
1648
- }
1649
- if (reply.sent) return;
1650
- }
1651
- }
1652
- if (permissionCheck) {
1653
- const context = {
1654
- user: req.user ?? null,
1655
- request: req,
1656
- resource: tag ?? "action",
1657
- action,
1658
- resourceId: id,
1659
- params: req.params,
1660
- data
1661
- };
1662
- let result;
1663
- try {
1664
- result = await permissionCheck(context);
1665
- } catch (err) {
1666
- req.log?.warn?.({
1667
- err,
1668
- resource: tag ?? "action",
1669
- action
1670
- }, "Permission check threw");
1671
- return reply.code(403).send({
1672
- success: false,
1673
- error: "Permission denied"
1674
- });
1675
- }
1676
- if (typeof result === "boolean") {
1677
- if (!result) return reply.code(context.user ? 403 : 401).send({
1678
- success: false,
1679
- error: context.user ? `Permission denied for '${action}'` : "Authentication required"
1680
- });
1681
- } else {
1682
- const permResult = result;
1683
- if (!permResult.granted) return reply.code(context.user ? 403 : 401).send({
1684
- success: false,
1685
- error: permResult.reason ?? (context.user ? `Permission denied for '${action}'` : "Authentication required")
1686
- });
1687
- }
1688
- }
1689
- try {
1690
- if (idempotencyKey && idempotencyService) {
1691
- const user = req.user;
1692
- const payloadForHash = {
1693
- action,
1694
- id,
1695
- data,
1696
- userId: (user?._id)?.toString?.() || user?.id || null
1697
- };
1698
- const idempotencyResult = await idempotencyService.check(idempotencyKey, payloadForHash);
1699
- if (!idempotencyResult.isNew && "existingResult" in idempotencyResult) return reply.send({
1700
- success: true,
1701
- data: idempotencyResult.existingResult,
1702
- cached: true
1703
- });
1704
- }
1705
- const result = await handler(id, data, req);
1706
- if (idempotencyService) await idempotencyService.complete(idempotencyKey, result);
1707
- return reply.send({
1708
- success: true,
1709
- data: result
1710
- });
1711
- } catch (error) {
1712
- if (idempotencyService) await idempotencyService.fail(idempotencyKey, error);
1713
- if (onError) {
1714
- const { statusCode, error: errorMsg, code } = onError(error, action, id);
1715
- return reply.code(statusCode).send({
1716
- success: false,
1717
- error: errorMsg,
1718
- code
1719
- });
1720
- }
1721
- const err = error;
1722
- const statusCode = err.statusCode || err.status || 500;
1723
- const errorCode = err.code || "ACTION_FAILED";
1724
- if (statusCode >= 500) req.log.error({
1725
- err: error,
1726
- action,
1727
- id
1728
- }, "Action handler error");
1729
- return reply.code(statusCode).send({
1730
- success: false,
1731
- error: err.message || `Failed to execute '${action}' action`,
1732
- code: errorCode
1733
- });
1734
- }
1735
- });
1736
- fastify.log.debug({
1737
- actions: actionEnum,
1738
- tag
1739
- }, "[createActionRouter] Registered action endpoint: POST /:id/action");
1740
- }
1741
- /**
1742
- * Build description with action details
1743
- * Uses _roles metadata from PermissionCheck functions for OpenAPI docs
672
+ * @example
673
+ * const result = validateResourceConfig(config);
674
+ * if (!result.valid) {
675
+ * console.error(formatValidationErrors(result.errors));
676
+ * }
1744
677
  */
1745
- function buildActionDescription(actions, actionPermissions) {
1746
- const lines = ["Unified action endpoint for state transitions.\n\n**Available actions:**"];
1747
- Object.keys(actions).forEach((action) => {
1748
- const roles = actionPermissions[action]?._roles;
1749
- const roleStr = roles?.length ? ` (requires: ${roles.join(" or ")})` : "";
1750
- lines.push(`- \`${action}\`${roleStr}`);
1751
- });
1752
- return lines.join("\n");
1753
- }
1754
-
1755
- //#endregion
1756
- //#region src/core/validateResourceConfig.ts
1757
678
  /**
1758
679
  * Validate a resource configuration
1759
680
  */
@@ -1830,7 +751,7 @@ function validateAdditionalRouteHandlers(controller, routes, errors) {
1830
751
  });
1831
752
  }
1832
753
  }
1833
- function validatePermissionKeys(config, options, errors, warnings) {
754
+ function validatePermissionKeys(config, options, _errors, warnings) {
1834
755
  const validKeys = new Set([...CRUD_OPERATIONS, ...options.additionalPermissionKeys ?? []]);
1835
756
  for (const route of config.additionalRoutes ?? []) if (typeof route.handler === "string") validKeys.add(route.handler);
1836
757
  for (const preset of config.presets ?? []) {
@@ -1958,7 +879,6 @@ function assertValidConfig(config, options) {
1958
879
  warnings: result.warnings
1959
880
  }));
1960
881
  }
1961
-
1962
882
  //#endregion
1963
883
  //#region src/core/defineResource.ts
1964
884
  /**
@@ -2062,12 +982,15 @@ var ResourceDefinition = class {
2062
982
  pipe;
2063
983
  fields;
2064
984
  cache;
985
+ tenantField;
986
+ idField;
987
+ queryParser;
2065
988
  _appliedPresets;
2066
989
  _pendingHooks;
2067
990
  _registryMeta;
2068
991
  constructor(config) {
2069
992
  this.name = config.name;
2070
- this.displayName = config.displayName ?? capitalize(config.name) + "s";
993
+ this.displayName = config.displayName ?? `${capitalize(config.name)}s`;
2071
994
  this.tag = config.tag ?? this.displayName;
2072
995
  this.prefix = config.prefix ?? `/${config.name}s`;
2073
996
  this.adapter = config.adapter;
@@ -2085,6 +1008,9 @@ var ResourceDefinition = class {
2085
1008
  this.pipe = config.pipe;
2086
1009
  this.fields = config.fields;
2087
1010
  this.cache = config.cache;
1011
+ this.tenantField = config.tenantField;
1012
+ this.idField = config.idField;
1013
+ this.queryParser = config.queryParser;
2088
1014
  this._appliedPresets = config._appliedPresets ?? [];
2089
1015
  this._pendingHooks = config._pendingHooks ?? [];
2090
1016
  }
@@ -2144,6 +1070,32 @@ var ResourceDefinition = class {
2144
1070
  await fastify.register(async (instance) => {
2145
1071
  const typedInstance = instance;
2146
1072
  let schemas = null;
1073
+ const openApi = self._registryMeta?.openApiSchemas;
1074
+ if (openApi && (!self.customSchemas || Object.keys(self.customSchemas).length === 0)) {
1075
+ const generated = {};
1076
+ const { createBody, updateBody, params, response } = openApi;
1077
+ const safeBody = (schema) => {
1078
+ if (schema && typeof schema === "object" && schema.type === "object") return {
1079
+ additionalProperties: true,
1080
+ ...schema
1081
+ };
1082
+ return schema;
1083
+ };
1084
+ if (createBody) generated.create = { body: safeBody(createBody) };
1085
+ if (updateBody) {
1086
+ const patchBody = { ...updateBody };
1087
+ delete patchBody.required;
1088
+ generated.update = { body: safeBody(patchBody) };
1089
+ if (params) generated.update.params = params;
1090
+ }
1091
+ if (params) {
1092
+ generated.get = { params };
1093
+ generated.delete = { params };
1094
+ if (!generated.update) generated.update = { params };
1095
+ else if (!generated.update.params) generated.update.params = params;
1096
+ }
1097
+ if (Object.keys(generated).length > 0) schemas = generated;
1098
+ }
2147
1099
  if (self.customSchemas && Object.keys(self.customSchemas).length > 0) {
2148
1100
  schemas = schemas ?? {};
2149
1101
  for (const [op, customSchema] of Object.entries(self.customSchemas)) {
@@ -2245,6 +1197,5 @@ function capitalize(str) {
2245
1197
  if (!str) return "";
2246
1198
  return str.charAt(0).toUpperCase() + str.slice(1);
2247
1199
  }
2248
-
2249
1200
  //#endregion
2250
- export { QueryResolver as _, validateResourceConfig as a, createPermissionMiddleware as c, createFastifyHandler as d, createRequestContext as f, BaseController as g, sendControllerResponse as h, formatValidationErrors as i, pipe as l, getControllerScope as m, defineResource as n, createActionRouter as o, getControllerContext as p, assertValidConfig as r, createCrudRouter as s, ResourceDefinition as t, createCrudHandlers as u, BodySanitizer as v, AccessControl as y };
1201
+ export { validateResourceConfig as a, createCrudHandlers as c, getControllerContext as d, getControllerScope as f, formatValidationErrors as i, createFastifyHandler as l, pipe as m, defineResource as n, createCrudRouter as o, sendControllerResponse as p, assertValidConfig as r, createPermissionMiddleware as s, ResourceDefinition as t, createRequestContext as u };