@classytic/arc 2.10.3 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +1 -1
  2. package/dist/{BaseController-CbKKIflT.mjs → BaseController-JNV08qOT.mjs} +595 -537
  3. package/dist/{queryCachePlugin-BKbWjgDG.d.mts → QueryCache-DOBNHBE0.d.mts} +2 -32
  4. package/dist/actionPermissions-C8YYU92K.mjs +22 -0
  5. package/dist/adapters/index.d.mts +2 -2
  6. package/dist/adapters/index.mjs +1 -1
  7. package/dist/{adapters-BXY4i-hw.mjs → adapters-D0tT2Tyo.mjs} +54 -0
  8. package/dist/audit/index.d.mts +2 -2
  9. package/dist/audit/index.mjs +15 -17
  10. package/dist/auth/index.d.mts +4 -4
  11. package/dist/auth/index.mjs +3 -3
  12. package/dist/auth/redis-session.d.mts +1 -1
  13. package/dist/{betterAuthOpenApi-BBRVhjQN.mjs → betterAuthOpenApi-DwxtK3uG.mjs} +1 -1
  14. package/dist/cache/index.d.mts +3 -2
  15. package/dist/cache/index.mjs +3 -3
  16. package/dist/cli/commands/docs.mjs +2 -2
  17. package/dist/cli/commands/generate.mjs +37 -27
  18. package/dist/cli/commands/init.mjs +47 -34
  19. package/dist/cli/commands/introspect.mjs +1 -1
  20. package/dist/context/index.d.mts +58 -0
  21. package/dist/context/index.mjs +2 -0
  22. package/dist/core/index.d.mts +3 -3
  23. package/dist/core/index.mjs +4 -3
  24. package/dist/core-DXdSSFW-.mjs +1037 -0
  25. package/dist/createActionRouter-BwaSM0No.mjs +166 -0
  26. package/dist/{createApp-BuvPma24.mjs → createApp-DvNYEhpb.mjs} +118 -36
  27. package/dist/docs/index.d.mts +2 -2
  28. package/dist/docs/index.mjs +1 -1
  29. package/dist/{elevation-C7hgL_aI.mjs → elevation-DOFoxoDs.mjs} +1 -1
  30. package/dist/errorHandler-Co3lnVmJ.d.mts +114 -0
  31. package/dist/{eventPlugin-DCUjuiQT.mjs → eventPlugin--5HIkdPU.mjs} +1 -1
  32. package/dist/{eventPlugin-CxWgpd6K.d.mts → eventPlugin-CUNjYYRY.d.mts} +1 -1
  33. package/dist/events/index.d.mts +4 -4
  34. package/dist/events/index.mjs +69 -51
  35. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  36. package/dist/events/transports/redis.d.mts +1 -1
  37. package/dist/factory/index.d.mts +1 -1
  38. package/dist/factory/index.mjs +2 -2
  39. package/dist/{fields-Lo1VUDpt.d.mts → fields-C8Y0XLAu.d.mts} +1 -1
  40. package/dist/hooks/index.d.mts +1 -1
  41. package/dist/hooks/index.mjs +1 -1
  42. package/dist/idempotency/index.d.mts +3 -3
  43. package/dist/idempotency/index.mjs +38 -27
  44. package/dist/idempotency/redis.d.mts +1 -1
  45. package/dist/{index-ChIw3776.d.mts → index-BYCqHCVu.d.mts} +4 -4
  46. package/dist/{index-Cl0uoKd5.d.mts → index-Cm0vUrr_.d.mts} +2100 -1688
  47. package/dist/{index-DStwgFUK.d.mts → index-DAushRTt.d.mts} +29 -10
  48. package/dist/index-DsJ1MNfC.d.mts +1179 -0
  49. package/dist/{index-8qw4y6ff.d.mts → index-t8pLpPFW.d.mts} +13 -10
  50. package/dist/index.d.mts +7 -251
  51. package/dist/index.mjs +8 -128
  52. package/dist/integrations/event-gateway.d.mts +2 -2
  53. package/dist/integrations/event-gateway.mjs +1 -1
  54. package/dist/integrations/index.d.mts +2 -2
  55. package/dist/integrations/mcp/index.d.mts +2 -2
  56. package/dist/integrations/mcp/index.mjs +1 -1
  57. package/dist/integrations/mcp/testing.d.mts +1 -1
  58. package/dist/integrations/mcp/testing.mjs +1 -1
  59. package/dist/integrations/streamline.d.mts +46 -5
  60. package/dist/integrations/streamline.mjs +50 -21
  61. package/dist/integrations/websocket-redis.d.mts +1 -1
  62. package/dist/integrations/websocket.d.mts +2 -154
  63. package/dist/integrations/websocket.mjs +292 -224
  64. package/dist/{keys-qcD-TVJl.mjs → keys-CARyUjiR.mjs} +2 -0
  65. package/dist/{loadResources-BAzJItAJ.mjs → loadResources-YNwKHvRA.mjs} +3 -1
  66. package/dist/logger/index.d.mts +81 -0
  67. package/dist/{logger-DLg8-Ueg.mjs → logger/index.mjs} +1 -6
  68. package/dist/middleware/index.d.mts +109 -0
  69. package/dist/middleware/index.mjs +70 -0
  70. package/dist/multipartBody-CvTR1Un6.mjs +123 -0
  71. package/dist/{openapi-B5F8AddX.mjs → openapi-C0L9ar7m.mjs} +9 -7
  72. package/dist/org/index.d.mts +2 -2
  73. package/dist/permissions/index.d.mts +2 -2
  74. package/dist/permissions/index.mjs +1 -3
  75. package/dist/{permissions-Dk6mshja.mjs → permissions-B4vU9L0Q.mjs} +220 -2
  76. package/dist/pipe-DVoIheVC.mjs +62 -0
  77. package/dist/pipeline/index.d.mts +62 -0
  78. package/dist/pipeline/index.mjs +53 -0
  79. package/dist/plugins/index.d.mts +25 -5
  80. package/dist/plugins/index.mjs +10 -10
  81. package/dist/plugins/response-cache.mjs +1 -1
  82. package/dist/plugins/tracing-entry.d.mts +1 -1
  83. package/dist/plugins/tracing-entry.mjs +42 -24
  84. package/dist/presets/filesUpload.d.mts +4 -4
  85. package/dist/presets/filesUpload.mjs +255 -1
  86. package/dist/presets/index.d.mts +1 -1
  87. package/dist/presets/index.mjs +2 -2
  88. package/dist/presets/multiTenant.d.mts +1 -1
  89. package/dist/presets/multiTenant.mjs +48 -8
  90. package/dist/presets/search.d.mts +2 -2
  91. package/dist/presets/search.mjs +1 -1
  92. package/dist/{presets-fLJVXdVn.mjs → presets-k604Lj99.mjs} +1 -1
  93. package/dist/queryCachePlugin-BUXBSm4F.d.mts +34 -0
  94. package/dist/{queryCachePlugin-DQCEfJis.mjs → queryCachePlugin-Bq6bO6vc.mjs} +3 -3
  95. package/dist/{redis-DqyeggCa.d.mts → redis-Cm1gnRDf.d.mts} +1 -1
  96. package/dist/{redis-stream-CakIQmwR.d.mts → redis-stream-CM8TXTix.d.mts} +1 -1
  97. package/dist/registry/index.d.mts +1 -1
  98. package/dist/registry/index.mjs +2 -2
  99. package/dist/{requestContext-xHIKedG6.mjs → requestContext-CfRkaxwf.mjs} +1 -1
  100. package/dist/{resourceToTools-BElv3xPT.mjs → resourceToTools--okX6QBr.mjs} +534 -415
  101. package/dist/routerShared-DeESFp4a.mjs +515 -0
  102. package/dist/schemaIR-BlG9bY7v.mjs +137 -0
  103. package/dist/scope/index.d.mts +2 -2
  104. package/dist/scope/index.mjs +1 -1
  105. package/dist/{sse-yBCgOLGu.mjs → sse-V7aXc3bW.mjs} +1 -1
  106. package/dist/{store-helpers-ZCSMJJAX.mjs → store-helpers-BhrzxvyQ.mjs} +4 -0
  107. package/dist/testing/index.d.mts +367 -711
  108. package/dist/testing/index.mjs +646 -1434
  109. package/dist/testing/storageContract.d.mts +1 -1
  110. package/dist/{tracing-65B51Dw3.d.mts → tracing-DokiEsuz.d.mts} +9 -4
  111. package/dist/types/index.d.mts +5 -5
  112. package/dist/types/index.mjs +1 -3
  113. package/dist/types/storage.d.mts +1 -1
  114. package/dist/{types-Co8k3NyS.d.mts → types-CgikqKAj.d.mts} +133 -21
  115. package/dist/{types-Btdda02s.d.mts → types-D9NqiYIw.d.mts} +1 -1
  116. package/dist/utils/index.d.mts +2 -898
  117. package/dist/utils/index.mjs +4 -5
  118. package/dist/utils-D3Yxnrwr.mjs +1639 -0
  119. package/dist/versioning-M9lNLhO8.d.mts +117 -0
  120. package/dist/websocket-CyJ1VIFI.d.mts +186 -0
  121. package/package.json +26 -8
  122. package/skills/arc/SKILL.md +124 -39
  123. package/skills/arc/references/testing.md +212 -183
  124. package/dist/applyPermissionResult-QhV1Pa-g.mjs +0 -37
  125. package/dist/core-CcR01lup.mjs +0 -1411
  126. package/dist/createActionRouter-Bp_5c_2b.mjs +0 -249
  127. package/dist/errorHandler-DRQ3EqfL.d.mts +0 -218
  128. package/dist/errors-CCSsMpXE.d.mts +0 -140
  129. package/dist/fields-bxkeltzz.mjs +0 -126
  130. package/dist/filesUpload-t21LS-py.mjs +0 -377
  131. package/dist/queryParser-DBqBB6AC.mjs +0 -352
  132. package/dist/types-Csi3FLfq.mjs +0 -27
  133. package/dist/utils-B2fNOD_i.mjs +0 -929
  134. /package/dist/{EventTransport-CUw5NNWe.d.mts → EventTransport-CfVEGaEl.d.mts} +0 -0
  135. /package/dist/{HookSystem-BNYKnrXF.mjs → HookSystem-CGsMd6oK.mjs} +0 -0
  136. /package/dist/{ResourceRegistry-BPd6NQDm.mjs → ResourceRegistry-DkAeAuTX.mjs} +0 -0
  137. /package/dist/{caching-CBpK_SCM.mjs → caching-CheW3m-S.mjs} +0 -0
  138. /package/dist/{elevation-C5SwtkAn.d.mts → elevation-s5ykdNHr.d.mts} +0 -0
  139. /package/dist/{errorHandler-Bb49BvPD.mjs → errorHandler-BQm8ZxTK.mjs} +0 -0
  140. /package/dist/{externalPaths-BQ8QijNH.d.mts → externalPaths-Bapitwvd.d.mts} +0 -0
  141. /package/dist/{interface-CSbZdv_3.d.mts → interface-CkkWm5uR.d.mts} +0 -0
  142. /package/dist/{interface-D218ikEo.d.mts → interface-Da0r7Lna.d.mts} +0 -0
  143. /package/dist/{memory-B5Amv9A1.mjs → memory-DikHSvWa.mjs} +0 -0
  144. /package/dist/{metrics-DuhiSEZI.mjs → metrics-Csh4nsvv.mjs} +0 -0
  145. /package/dist/{pluralize-A0tWEl1K.mjs → pluralize-BneOJkpi.mjs} +0 -0
  146. /package/dist/{registry-B3lRFBWo.mjs → registry-D63ee7fl.mjs} +0 -0
  147. /package/dist/{replyHelpers-CXtJDAZ0.mjs → replyHelpers-ByllIXXV.mjs} +0 -0
  148. /package/dist/{schemaConverter-BxFDdtXu.mjs → schemaConverter-B0oKLuqI.mjs} +0 -0
  149. /package/dist/{sessionManager-BkzVU8h2.d.mts → sessionManager-D-oNWHz3.d.mts} +0 -0
  150. /package/dist/{storage-CVk_SEn2.d.mts → storage-BwGQXUpd.d.mts} +0 -0
  151. /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
  152. /package/dist/{types-BD85MlEK.d.mts → types-tgR4Pt8F.d.mts} +0 -0
  153. /package/dist/{versioning-C2U_bLY0.mjs → versioning-CGPjkqAg.mjs} +0 -0
@@ -1,25 +1,25 @@
1
1
  import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-BhY1OHoH.mjs";
2
+ import { arcLog } from "./logger/index.mjs";
2
3
  import { _ as isElevated, n as PUBLIC_SCOPE, o as getOrgId, v as isMember } from "./types-AOD8fxIw.mjs";
3
- import { t as buildQueryKey } from "./keys-qcD-TVJl.mjs";
4
- import { n as getUserId } from "./types-Csi3FLfq.mjs";
5
- import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-bxkeltzz.mjs";
4
+ import { M as simpleEqualityMatcher, j as getUserId, k as ArcQueryParser } from "./utils-D3Yxnrwr.mjs";
5
+ import { t as buildQueryKey } from "./keys-CARyUjiR.mjs";
6
+ import { M as applyFieldWritePermissions, P as resolveEffectiveRoles } from "./permissions-B4vU9L0Q.mjs";
6
7
  import { t as getUserRoles } from "./types-DV9WDfeg.mjs";
7
8
  import { r as ForbiddenError } from "./errors-D5c-5BJL.mjs";
8
- import { t as ArcQueryParser } from "./queryParser-DBqBB6AC.mjs";
9
9
  //#region src/core/AccessControl.ts
10
- var AccessControl = class AccessControl {
10
+ const log = arcLog("access-control");
11
+ var AccessControl = class {
11
12
  tenantField;
12
13
  idField;
13
14
  _adapterMatchesFilter;
14
- /** Patterns that indicate dangerous regex (nested quantifiers, excessive backtracking).
15
- * Uses [^...] character classes instead of .+ to avoid backtracking in the detector itself. */
16
- static DANGEROUS_REGEX = /(\{[0-9]+,\}[^{]*\{[0-9]+,\})|(\+[^+]*\+)|(\*[^*]*\*)|(\.\*){3,}|\\1/;
17
- /** Forbidden paths that could lead to prototype pollution */
18
- static FORBIDDEN_PATHS = [
19
- "__proto__",
20
- "constructor",
21
- "prototype"
22
- ];
15
+ /**
16
+ * One-shot latch for the "adapter didn't supply matchesFilter, in-memory
17
+ * policy-filter re-check is skipped" warning. The primary fetch path
18
+ * (`getOne(compoundFilter)`) already applied filters at the DB layer;
19
+ * this warn only fires when `validateItemAccess` runs and the adapter
20
+ * hasn't provided a native matcher for the post-hoc re-check.
21
+ */
22
+ _warnedNoMatcher = false;
23
23
  constructor(config) {
24
24
  this.tenantField = config.tenantField;
25
25
  this.idField = config.idField;
@@ -40,17 +40,54 @@ var AccessControl = class AccessControl {
40
40
  return filter;
41
41
  }
42
42
  /**
43
- * Check if item matches policy filters (for get/update/delete operations)
44
- * Validates that fetched item satisfies all policy constraints
43
+ * Check if a post-fetch item matches the request's `_policyFilters`.
44
+ *
45
+ * **When this runs:** only on paths where the primary fetch path did NOT
46
+ * apply policy filters at the DB layer — notably `validateItemAccess`
47
+ * (used by `getBySlug` and cache revalidation). The main `fetchDetailed`
48
+ * path builds a compound filter (`buildIdFilter`) and passes it to
49
+ * `repository.getOne(compoundFilter)`, so the DB has already enforced
50
+ * the filter and an in-memory re-check would be redundant.
45
51
  *
46
- * Delegates to adapter-provided matchesFilter if available (for SQL, etc.),
47
- * otherwise falls back to built-in MongoDB-style matching.
52
+ * **Evaluation order (fail-closed):**
53
+ * 1. No `_policyFilters` set `true` (nothing to enforce).
54
+ * 2. Adapter supplied `matchesFilter` → delegate to it verbatim. Adapters
55
+ * are expected to handle every filter shape the host emits
56
+ * (mongokit/sqlitekit evaluate at the DB layer; Prisma/custom engines
57
+ * can wrap their own predicate engine).
58
+ * 3. No adapter matcher → fall back to `simpleEqualityMatcher` — arc's
59
+ * built-in flat-key equality helper. This is defense-in-depth for the
60
+ * common case: arc's own permission helpers emit flat filters
61
+ * (`{userId: …}`, `{organizationId: …}`), which this matcher evaluates
62
+ * correctly. Operator-shaped filters (`$in`, `$ne`, `$regex`, `$and`,
63
+ * `$or`) are **rejected** (the matcher returns `false`) — fail-closed
64
+ * rather than fail-open. A one-shot warn flags the gap so adapter
65
+ * authors can wire a richer matcher.
66
+ *
67
+ * Arc deliberately does NOT ship a full MongoDB-syntax matcher:
68
+ * re-implementing Mongo in JS was dead code for mongokit users (the DB
69
+ * did it) and silently wrong for non-Mongo adapters. The flat-equality
70
+ * fallback is small (~20 LOC), correct in both dialects, and closes the
71
+ * previous `getBySlug`-style policy-bypass path.
48
72
  */
49
73
  checkPolicyFilters(item, req) {
50
74
  const policyFilters = this._meta(req)?._policyFilters;
51
- if (!policyFilters) return true;
75
+ if (!policyFilters || Object.keys(policyFilters).length === 0) return true;
52
76
  if (this._adapterMatchesFilter) return this._adapterMatchesFilter(item, policyFilters);
53
- return this.defaultMatchesPolicyFilters(item, policyFilters);
77
+ if (Object.values(policyFilters).some((v) => v !== null && typeof v === "object" && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype && Object.keys(v).some((k) => k.startsWith("$")))) this._warnNoMatcher(policyFilters);
78
+ return simpleEqualityMatcher(item, policyFilters);
79
+ }
80
+ /**
81
+ * Emit a one-shot warn when policy filters contain operators (`$in`,
82
+ * `$ne`, `$regex`, etc.) and no `DataAdapter.matchesFilter` is wired —
83
+ * arc's flat-equality fallback fail-closes on operators, so the host
84
+ * sees 404s on docs that should match. Latched on `_warnedNoMatcher`
85
+ * so subsequent requests stay quiet.
86
+ */
87
+ _warnNoMatcher(policyFilters) {
88
+ if (this._warnedNoMatcher) return;
89
+ this._warnedNoMatcher = true;
90
+ log.warn("`_policyFilters` contains operator-shaped entries (e.g. `$in`, `$ne`, `$regex`) but `DataAdapter.matchesFilter` is not set. Arc's flat-equality fallback cannot evaluate operators and will reject these items on non-compound fetches (`validateItemAccess`, `getBySlug`, cache revalidation). Wire up `matchesFilter` on your adapter — use `matchFilter` from `@classytic/repo-core/filter` for IR-based adapters, or your DB's native predicate engine.", { policyFilterKeys: Object.keys(policyFilters) });
54
91
  }
55
92
  /**
56
93
  * Check org/tenant scope for a document — uses configurable tenantField.
@@ -64,7 +101,6 @@ var AccessControl = class AccessControl {
64
101
  const scope = arcContext?._scope;
65
102
  const orgId = scope ? getOrgId(scope) : void 0;
66
103
  if (!item || !orgId) return true;
67
- if (scope && isElevated(scope) && !orgId) return true;
68
104
  const itemOrgId = item[this.tenantField];
69
105
  if (!itemOrgId) return false;
70
106
  return String(itemOrgId) === String(orgId);
@@ -122,7 +158,25 @@ var AccessControl = class AccessControl {
122
158
  };
123
159
  if (hasCompoundFilters) {
124
160
  const idOnly = { [this.idField]: id };
125
- const rawDoc = await repository.getOne(idOnly);
161
+ const rawGetOne = repository.getOne.bind(repository);
162
+ let rawDoc = null;
163
+ try {
164
+ rawDoc = await rawGetOne(idOnly);
165
+ } catch (unscopedErr) {
166
+ if (translateStatus404(unscopedErr)) return {
167
+ doc: null,
168
+ reason: "NOT_FOUND"
169
+ };
170
+ try {
171
+ rawDoc = await rawGetOne(idOnly, queryOptions);
172
+ } catch (scopedErr) {
173
+ if (translateStatus404(scopedErr)) return {
174
+ doc: null,
175
+ reason: "NOT_FOUND"
176
+ };
177
+ throw scopedErr;
178
+ }
179
+ }
126
180
  if (rawDoc) {
127
181
  const arcContext = this._meta(req);
128
182
  if (!this.checkOrgScope(rawDoc, arcContext)) return {
@@ -183,101 +237,6 @@ var AccessControl = class AccessControl {
183
237
  _meta(req) {
184
238
  return req.metadata;
185
239
  }
186
- /**
187
- * Check if a value matches a MongoDB query operator
188
- */
189
- matchesOperator(itemValue, operator, filterValue) {
190
- const equalsByValue = (a, b) => String(a) === String(b);
191
- switch (operator) {
192
- case "$eq": return equalsByValue(itemValue, filterValue);
193
- case "$ne": return !equalsByValue(itemValue, filterValue);
194
- case "$gt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue > filterValue;
195
- case "$gte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue >= filterValue;
196
- case "$lt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue < filterValue;
197
- case "$lte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue <= filterValue;
198
- case "$in":
199
- if (!Array.isArray(filterValue)) return false;
200
- if (Array.isArray(itemValue)) return itemValue.some((v) => filterValue.some((fv) => equalsByValue(v, fv)));
201
- return filterValue.some((fv) => equalsByValue(itemValue, fv));
202
- case "$nin":
203
- if (!Array.isArray(filterValue)) return false;
204
- if (Array.isArray(itemValue)) return itemValue.every((v) => filterValue.every((fv) => !equalsByValue(v, fv)));
205
- return filterValue.every((fv) => !equalsByValue(itemValue, fv));
206
- case "$exists": return filterValue ? itemValue !== void 0 : itemValue === void 0;
207
- case "$regex":
208
- if (typeof itemValue === "string" && (typeof filterValue === "string" || filterValue instanceof RegExp)) return (typeof filterValue === "string" ? AccessControl.safeRegex(filterValue) : filterValue)?.test(itemValue) ?? false;
209
- return false;
210
- default: return false;
211
- }
212
- }
213
- /**
214
- * Check if item matches a single filter condition
215
- * Supports nested paths (e.g., "owner.id", "metadata.status")
216
- */
217
- matchesFilter(item, key, filterValue) {
218
- const itemValue = key.includes(".") ? this.getNestedValue(item, key) : item[key];
219
- if (filterValue && typeof filterValue === "object" && !Array.isArray(filterValue)) {
220
- if (Object.keys(filterValue).some((op) => op.startsWith("$"))) {
221
- for (const [operator, opValue] of Object.entries(filterValue)) if (!this.matchesOperator(itemValue, operator, opValue)) return false;
222
- return true;
223
- }
224
- }
225
- if (Array.isArray(itemValue)) return itemValue.some((v) => String(v) === String(filterValue));
226
- return String(itemValue) === String(filterValue);
227
- }
228
- /**
229
- * Built-in MongoDB-style policy filter matching.
230
- * Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
231
- */
232
- defaultMatchesPolicyFilters(item, policyFilters) {
233
- if (policyFilters.$and && Array.isArray(policyFilters.$and)) {
234
- if (!policyFilters.$and.every((condition) => {
235
- return Object.entries(condition).every(([key, value]) => {
236
- return this.matchesFilter(item, key, value);
237
- });
238
- })) return false;
239
- }
240
- if (policyFilters.$or && Array.isArray(policyFilters.$or)) {
241
- if (!policyFilters.$or.some((condition) => {
242
- return Object.entries(condition).every(([key, value]) => {
243
- return this.matchesFilter(item, key, value);
244
- });
245
- })) return false;
246
- }
247
- for (const [key, value] of Object.entries(policyFilters)) {
248
- if (key.startsWith("$")) continue;
249
- if (!this.matchesFilter(item, key, value)) return false;
250
- }
251
- return true;
252
- }
253
- /**
254
- * Get nested value from object using dot notation (e.g., "owner.id")
255
- * Security: Validates path against forbidden patterns to prevent prototype pollution
256
- */
257
- getNestedValue(obj, path) {
258
- if (AccessControl.FORBIDDEN_PATHS.some((p) => path.toLowerCase().includes(p))) return;
259
- const keys = path.split(".");
260
- let value = obj;
261
- for (const key of keys) {
262
- if (value == null) return void 0;
263
- if (AccessControl.FORBIDDEN_PATHS.includes(key.toLowerCase())) return;
264
- value = value[key];
265
- }
266
- return value;
267
- }
268
- /**
269
- * Create a safe RegExp from a string, guarding against ReDoS.
270
- * Returns null if the pattern is invalid or dangerous.
271
- */
272
- static safeRegex(pattern) {
273
- if (pattern.length > 200) return null;
274
- if (AccessControl.DANGEROUS_REGEX.test(pattern)) return null;
275
- try {
276
- return new RegExp(pattern);
277
- } catch {
278
- return null;
279
- }
280
- }
281
240
  };
282
241
  var BodySanitizer = class {
283
242
  schemaOptions;
@@ -296,10 +255,13 @@ var BodySanitizer = class {
296
255
  sanitize(body, _operation, req, meta) {
297
256
  let sanitized = { ...body };
298
257
  for (const field of SYSTEM_FIELDS) delete sanitized[field];
258
+ const scopeForRules = req ? (meta ?? req.metadata)?._scope ?? PUBLIC_SCOPE : void 0;
259
+ const scopeIsElevated = scopeForRules ? isElevated(scopeForRules) : false;
299
260
  const fieldRules = this.schemaOptions.fieldRules ?? {};
300
261
  for (const [field, rules] of Object.entries(fieldRules)) {
301
- if (rules.systemManaged || rules.readonly) delete sanitized[field];
302
- if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate)) delete sanitized[field];
262
+ const bypass = Boolean(rules.preserveForElevated) && scopeIsElevated;
263
+ if ((rules.systemManaged || rules.readonly) && !bypass) delete sanitized[field];
264
+ if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate) && !bypass) delete sanitized[field];
303
265
  }
304
266
  if (req) {
305
267
  const arcContext = meta ?? req.metadata;
@@ -336,6 +298,7 @@ var QueryResolver = class {
336
298
  queryParser;
337
299
  maxLimit;
338
300
  defaultLimit;
301
+ /** `undefined` means "no default sort" (caller passed `false`). */
339
302
  defaultSort;
340
303
  schemaOptions;
341
304
  tenantField;
@@ -343,7 +306,7 @@ var QueryResolver = class {
343
306
  this.queryParser = config.queryParser ?? getDefaultQueryParser();
344
307
  this.maxLimit = config.maxLimit ?? 100;
345
308
  this.defaultLimit = config.defaultLimit ?? 20;
346
- this.defaultSort = config.defaultSort ?? "-createdAt";
309
+ this.defaultSort = config.defaultSort === false ? void 0 : config.defaultSort ?? "-createdAt";
347
310
  this.schemaOptions = config.schemaOptions ?? {};
348
311
  this.tenantField = config.tenantField !== void 0 ? config.tenantField : DEFAULT_TENANT_FIELD;
349
312
  }
@@ -451,29 +414,29 @@ var QueryResolver = class {
451
414
  }
452
415
  };
453
416
  //#endregion
454
- //#region src/core/BaseController.ts
417
+ //#region src/core/BaseCrudController.ts
455
418
  /**
456
419
  * Portable "run on next tick" scheduler. `setImmediate` is Node-only — not
457
- * available in Bun workers, Deno, Cloudflare Workers, or edge runtimes. Fall
458
- * back to queueMicrotask (universal) when setImmediate is absent.
420
+ * available in Bun workers, Deno, Cloudflare Workers, or edge runtimes.
459
421
  */
460
422
  const scheduleBackground = typeof setImmediate === "function" ? (cb) => void setImmediate(cb) : (cb) => queueMicrotask(cb);
461
423
  /**
462
- * Framework-agnostic base controller implementing IController.
424
+ * Framework-agnostic CRUD controller implementing IController.
463
425
  *
464
- * Composes AccessControl, BodySanitizer, and QueryResolver for clean
465
- * separation of concerns. CRUD methods delegate directly to these
466
- * composed classes no intermediate wrapper methods.
426
+ * Composes AccessControl, BodySanitizer, and QueryResolver. All shared
427
+ * state and helpers are `protected` so the preset mixins (SoftDelete,
428
+ * Tree, Slug, Bulk) can extend cleanly.
467
429
  *
468
- * @template TDoc - The document type
469
- * @template TRepository - The repository type (defaults to RepositoryLike)
430
+ * @template TDoc - The document type.
431
+ * @template TRepository - The repository type (defaults to RepositoryLike).
470
432
  */
471
- var BaseController = class {
433
+ var BaseCrudController = class {
472
434
  repository;
473
435
  schemaOptions;
474
436
  queryParser;
475
437
  maxLimit;
476
438
  defaultLimit;
439
+ /** `undefined` means "no default sort" (caller passed `false`). */
477
440
  defaultSort;
478
441
  resourceName;
479
442
  tenantField;
@@ -482,7 +445,14 @@ var BaseController = class {
482
445
  accessControl;
483
446
  /** Composable body sanitization (field permissions, system fields) */
484
447
  bodySanitizer;
485
- /** Composable query resolution (parsing, pagination, sort, select/populate) */
448
+ /**
449
+ * Composable query resolution (parsing, pagination, sort, select/populate).
450
+ *
451
+ * Not `readonly` — `setQueryParser()` rebuilds this resolver to swap in a
452
+ * different parser (e.g. mongokit's `QueryParser`). `defineResource` calls
453
+ * it automatically when a resource supplies both `controller` and
454
+ * `queryParser`.
455
+ */
486
456
  queryResolver;
487
457
  _matchesFilter;
488
458
  _presetFields = {};
@@ -493,7 +463,7 @@ var BaseController = class {
493
463
  this.queryParser = options.queryParser ?? getDefaultQueryParser();
494
464
  this.maxLimit = options.maxLimit ?? 100;
495
465
  this.defaultLimit = options.defaultLimit ?? 20;
496
- this.defaultSort = options.defaultSort ?? "-createdAt";
466
+ this.defaultSort = options.defaultSort === false ? void 0 : options.defaultSort ?? "-createdAt";
497
467
  this.resourceName = options.resourceName;
498
468
  this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
499
469
  this.idField = options.idField ?? repository?.idField ?? "_id";
@@ -513,7 +483,7 @@ var BaseController = class {
513
483
  queryParser: this.queryParser,
514
484
  maxLimit: this.maxLimit,
515
485
  defaultLimit: this.defaultLimit,
516
- defaultSort: this.defaultSort,
486
+ defaultSort: options.defaultSort,
517
487
  schemaOptions: this.schemaOptions,
518
488
  tenantField: this.tenantField
519
489
  });
@@ -524,15 +494,63 @@ var BaseController = class {
524
494
  this.delete = this.delete.bind(this);
525
495
  }
526
496
  /**
527
- * Get the tenant field name if multi-tenant scoping is enabled.
528
- * Returns `undefined` when `tenantField` is `false` (platform-universal mode).
497
+ * Swap the controller's query parser. Rebuilds the internal `QueryResolver`
498
+ * with the new parser while preserving every other config.
529
499
  *
530
- * Use this in subclass overrides instead of accessing `this.tenantField` directly
531
- * to avoid TypeScript indexing errors with `string | false`.
500
+ * Closes the v2.10.9 gap where `defineResource({ controller, queryParser })`
501
+ * forwarded the parser only to auto-constructed controllers; user-supplied
502
+ * controllers kept their default `ArcQueryParser`. `defineResource` calls
503
+ * this via duck-typing when both `controller` and `queryParser` are
504
+ * supplied — controllers that don't implement `setQueryParser` are left
505
+ * untouched.
506
+ *
507
+ * Idempotent + safe to call repeatedly. Does NOT touch `maxLimit` or
508
+ * `defaultLimit` — those are construction-time decisions.
509
+ */
510
+ setQueryParser(queryParser) {
511
+ this.queryParser = queryParser;
512
+ this.queryResolver = new QueryResolver({
513
+ queryParser: this.queryParser,
514
+ maxLimit: this.maxLimit,
515
+ defaultLimit: this.defaultLimit,
516
+ defaultSort: this.defaultSort,
517
+ schemaOptions: this.schemaOptions,
518
+ tenantField: this.tenantField
519
+ });
520
+ }
521
+ /**
522
+ * Get the tenant field name if multi-tenant scoping is enabled.
523
+ * Returns `undefined` when `tenantField` is `false`.
532
524
  */
533
525
  getTenantField() {
534
526
  return this.tenantField || void 0;
535
527
  }
528
+ /**
529
+ * Build top-level tenant options to thread into the repository call.
530
+ *
531
+ * Plugin-scoped repos (mongokit's `multiTenantPlugin`) read tenant scope
532
+ * from the TOP of the operation context — `context.organizationId`, not
533
+ * `context.data.organizationId`. Without this stamping, a tenant-scoped
534
+ * repo throws "Missing 'organizationId' in context" even when arc has
535
+ * injected the tenant into the request body.
536
+ *
537
+ * Returns `{ [tenantField]: orgId }` for tenant-scoped + org-carrying
538
+ * requests, `{}` otherwise. Merges multi-field tenancy from
539
+ * `_tenantFields` (populated by `multiTenantPreset`).
540
+ */
541
+ tenantRepoOptions(req) {
542
+ const out = {};
543
+ if (this.tenantField) {
544
+ const scope = this.meta(req)?._scope;
545
+ const orgId = scope ? getOrgId(scope) : void 0;
546
+ if (orgId) out[this.tenantField] = orgId;
547
+ }
548
+ const presetFields = req._tenantFields;
549
+ if (presetFields && typeof presetFields === "object") {
550
+ for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
551
+ }
552
+ return out;
553
+ }
536
554
  /** Extract typed Arc internal metadata from request */
537
555
  meta(req) {
538
556
  return req.metadata;
@@ -542,20 +560,15 @@ var BaseController = class {
542
560
  return this.meta(req)?.arc?.hooks ?? null;
543
561
  }
544
562
  /**
545
- * Resolve the repository primary key for mutation calls (update/delete/restore).
546
- *
547
- * When the resource declares a custom `idField` (e.g. `slug`, `jobId`, UUID),
548
- * the default behavior is to translate the route id → the fetched doc's `_id`
549
- * because most Mongo repositories key their mutation methods off `_id`.
563
+ * Resolve the repository primary key for mutation calls.
550
564
  *
551
- * Exception: if the repository itself exposes a matching `idField` property
552
- * (e.g. MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
553
- * repository already knows how to look up by that field — so we pass the
554
- * route id through unchanged and skip the translation.
565
+ * When the resource declares a custom `idField` (slug, jobId, UUID), the
566
+ * default behavior is to translate the route id → the fetched doc's `_id`
567
+ * because most Mongo repositories key mutation methods off `_id`.
555
568
  *
556
- * This makes `defineResource({ idField: 'id' })` work end-to-end with repos
557
- * that natively support custom primary keys, without breaking the slug-style
558
- * aliasing that Arc 2.6.3 introduced for repos keyed on `_id`.
569
+ * Exception: if the repo exposes a matching `idField` property (e.g.
570
+ * MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
571
+ * repo handles lookup itself pass the route id through unchanged.
559
572
  */
560
573
  resolveRepoId(id, existing) {
561
574
  if (this.idField === "_id") return id;
@@ -567,11 +580,8 @@ var BaseController = class {
567
580
  /**
568
581
  * Centralized 404 response builder. Maps the denial reason from
569
582
  * `fetchDetailed()` into a structured `details.code` so consumers can
570
- * programmatically distinguish "doc doesn't exist" from "doc filtered
571
- * by policy/org scope" without parsing error strings.
572
- *
573
- * Error messages are intentionally vague in the `error` field (don't
574
- * leak whether the doc exists) — the detail is in `details.code` only.
583
+ * distinguish "doc doesn't exist" from "doc filtered by policy/org scope"
584
+ * without parsing error strings.
575
585
  */
576
586
  notFoundResponse(reason = "NOT_FOUND") {
577
587
  const code = reason ?? "NOT_FOUND";
@@ -599,8 +609,8 @@ var BaseController = class {
599
609
  }
600
610
  /**
601
611
  * Extract user/org IDs from request for cache key scoping.
602
- * Only includes orgId when this resource uses tenant-scoped data (tenantField is set).
603
- * Universal resources (tenantField: false) get shared cache keys to avoid fragmentation.
612
+ * Only includes orgId when the resource uses tenant-scoped data (tenantField is set).
613
+ * Universal resources (tenantField: false) get shared cache keys.
604
614
  */
605
615
  cacheScope(req) {
606
616
  return {
@@ -655,7 +665,11 @@ var BaseController = class {
655
665
  /** Execute list query through hooks (extracted for cache revalidation) */
656
666
  async executeListQuery(options, req) {
657
667
  const hooks = this.getHooks(req);
658
- const repoGetAll = async () => this.repository.getAll(options);
668
+ const getAllParams = {
669
+ ...options,
670
+ ...this.tenantRepoOptions(req)
671
+ };
672
+ const repoGetAll = async () => this.repository.getAll(getAllParams);
659
673
  return hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "list", options, repoGetAll, {
660
674
  user: req.user,
661
675
  context: this.meta(req)
@@ -668,7 +682,10 @@ var BaseController = class {
668
682
  error: "ID parameter is required",
669
683
  status: 400
670
684
  };
671
- const options = this.queryResolver.resolve(req, this.meta(req));
685
+ const options = {
686
+ ...this.queryResolver.resolve(req, this.meta(req)),
687
+ ...this.tenantRepoOptions(req)
688
+ };
672
689
  const cacheConfig = this.resolveCacheConfig("byId");
673
690
  const qc = req.server?.queryCache;
674
691
  if (cacheConfig && qc) {
@@ -764,7 +781,8 @@ var BaseController = class {
764
781
  }
765
782
  const repoCreate = async () => this.repository.create(processedData, {
766
783
  user,
767
- context: arcContext
784
+ context: arcContext,
785
+ ...this.tenantRepoOptions(req)
768
786
  });
769
787
  let item;
770
788
  if (hooks && this.resourceName) {
@@ -796,7 +814,7 @@ var BaseController = class {
796
814
  const user = req.user;
797
815
  const userId = getUserId(user);
798
816
  if (userId) data.updatedBy = userId;
799
- const { doc: existing, reason: updateReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
817
+ const { doc: existing, reason: updateReason } = await this.accessControl.fetchDetailed(id, req, this.repository, this.tenantRepoOptions(req));
800
818
  if (!existing) return this.notFoundResponse(updateReason);
801
819
  if (!this.accessControl.checkOwnership(existing, req)) return {
802
820
  success: false,
@@ -829,7 +847,8 @@ var BaseController = class {
829
847
  }
830
848
  const repoUpdate = async () => this.repository.update(repoId, processedData, {
831
849
  user,
832
- context: arcContext
850
+ context: arcContext,
851
+ ...this.tenantRepoOptions(req)
833
852
  });
834
853
  let item;
835
854
  if (hooks && this.resourceName) {
@@ -867,7 +886,7 @@ var BaseController = class {
867
886
  };
868
887
  const arcContext = this.meta(req);
869
888
  const user = req.user;
870
- const { doc: existing, reason: deleteReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
889
+ const { doc: existing, reason: deleteReason } = await this.accessControl.fetchDetailed(id, req, this.repository, this.tenantRepoOptions(req));
871
890
  if (!existing) return this.notFoundResponse(deleteReason);
872
891
  if (!this.accessControl.checkOwnership(existing, req)) return {
873
892
  success: false,
@@ -898,6 +917,7 @@ var BaseController = class {
898
917
  const repoDelete = async () => this.repository.delete(repoId, {
899
918
  user,
900
919
  context: arcContext,
920
+ ...this.tenantRepoOptions(req),
901
921
  ...deleteMode ? { mode: deleteMode } : {}
902
922
  });
903
923
  let result;
@@ -930,404 +950,442 @@ var BaseController = class {
930
950
  status: 200
931
951
  };
932
952
  }
933
- async getBySlug(req) {
934
- const slugField = this._presetFields.slugField ?? "slug";
935
- const slug = req.params[slugField] ?? req.params.slug;
936
- const options = this.queryResolver.resolve(req, this.meta(req));
937
- const repo = this.repository;
938
- let item = null;
939
- if (repo.getBySlug) item = await repo.getBySlug(slug, options);
940
- else if (repo.getOne) {
941
- const filter = {
942
- [slugField]: slug,
943
- ...options?.filter ?? {}
953
+ };
954
+ //#endregion
955
+ //#region src/core/mixins/bulk.ts
956
+ /**
957
+ * BulkMixin `bulkCreate` / `bulkUpdate` / `bulkDelete` endpoints.
958
+ *
959
+ * Security-critical: every bulk operation routes through the same write
960
+ * permissions, tenant scope, and policy filters as the single-doc paths.
961
+ * Cross-tenant writes are blocked at the controller layer regardless of
962
+ * what middleware the host has wired up.
963
+ *
964
+ * Per-doc lifecycle hooks (`before:create` / `after:create` / etc.) do
965
+ * NOT fire for bulk operations — use the single-doc path if you need
966
+ * them, or subscribe to the bulk lifecycle event from the events plugin.
967
+ *
968
+ * @example
969
+ * ```ts
970
+ * class OrderController extends BulkMixin(BaseCrudController<Order>) {}
971
+ * ```
972
+ */
973
+ function BulkMixin(Base) {
974
+ return class BulkController extends Base {
975
+ async bulkCreate(req) {
976
+ const repo = this.repository;
977
+ if (!repo.createMany) return {
978
+ success: false,
979
+ error: "Repository does not support createMany",
980
+ status: 501
944
981
  };
945
- item = await repo.getOne(filter, options);
946
- } else return {
947
- success: false,
948
- error: "Slug lookup not implemented — repository needs getBySlug() or getOne()",
949
- status: 501
950
- };
951
- if (!this.accessControl.validateItemAccess(item, req)) return this.notFoundResponse(item ? "POLICY_FILTERED" : "NOT_FOUND");
952
- return {
953
- success: true,
954
- data: item,
955
- status: 200
956
- };
957
- }
958
- async getDeleted(req) {
959
- const repo = this.repository;
960
- if (!repo.getDeleted) return {
961
- success: false,
962
- error: "Soft delete not implemented",
963
- status: 501
964
- };
965
- const parsed = this.queryResolver.resolve(req, this.meta(req));
966
- return {
967
- success: true,
968
- data: await repo.getDeleted(parsed, parsed),
969
- status: 200
970
- };
971
- }
972
- async restore(req) {
973
- const repo = this.repository;
974
- if (!repo.restore) return {
975
- success: false,
976
- error: "Restore not implemented",
977
- status: 501
978
- };
979
- const id = req.params.id;
980
- if (!id) return {
981
- success: false,
982
- error: "ID parameter is required",
983
- status: 400
984
- };
985
- const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
986
- if (!existing) return this.notFoundResponse("NOT_FOUND");
987
- if (!this.accessControl.checkOwnership(existing, req)) return {
988
- success: false,
989
- error: "You do not have permission to restore this resource",
990
- details: { code: "OWNERSHIP_DENIED" },
991
- status: 403
992
- };
993
- const arcContext = this.meta(req);
994
- const user = req.user;
995
- const repoId = this.resolveRepoId(id, existing);
996
- const hooks = this.getHooks(req);
997
- if (hooks && this.resourceName) try {
998
- await hooks.executeBefore(this.resourceName, "restore", existing, {
999
- user,
1000
- context: arcContext,
1001
- meta: { id }
1002
- });
1003
- } catch (err) {
1004
- return {
982
+ const rawItems = req.body?.items;
983
+ if (!Array.isArray(rawItems) || rawItems.length === 0) return {
1005
984
  success: false,
1006
- error: "Hook execution failed",
1007
- details: {
1008
- code: "BEFORE_RESTORE_HOOK_ERROR",
1009
- message: err.message
1010
- },
985
+ error: "Bulk create requires a non-empty items array",
1011
986
  status: 400
1012
987
  };
1013
- }
1014
- const repoRestore = () => repo.restore(repoId);
1015
- let item;
1016
- if (hooks && this.resourceName) item = await hooks.executeAround(this.resourceName, "restore", existing, repoRestore, {
1017
- user,
1018
- context: arcContext,
1019
- meta: { id }
1020
- });
1021
- else item = await repoRestore();
1022
- if (!item) return this.notFoundResponse("NOT_FOUND");
1023
- if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "restore", item, {
1024
- user,
1025
- context: arcContext,
1026
- meta: { id }
1027
- });
1028
- return {
1029
- success: true,
1030
- data: item,
1031
- status: 200,
1032
- meta: { message: "Restored successfully" }
1033
- };
1034
- }
1035
- async getTree(req) {
1036
- const repo = this.repository;
1037
- if (!repo.getTree) return {
1038
- success: false,
1039
- error: "Tree structure not implemented",
1040
- status: 501
1041
- };
1042
- const options = this.queryResolver.resolve(req, this.meta(req));
1043
- return {
1044
- success: true,
1045
- data: await repo.getTree(options),
1046
- status: 200
1047
- };
1048
- }
1049
- async getChildren(req) {
1050
- const repo = this.repository;
1051
- if (!repo.getChildren) return {
1052
- success: false,
1053
- error: "Tree structure not implemented",
1054
- status: 501
1055
- };
1056
- const parentField = this._presetFields.parentField ?? "parent";
1057
- const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
1058
- const options = this.queryResolver.resolve(req, this.meta(req));
1059
- return {
1060
- success: true,
1061
- data: await repo.getChildren(parentId, options),
1062
- status: 200
1063
- };
1064
- }
1065
- async bulkCreate(req) {
1066
- const repo = this.repository;
1067
- if (!repo.createMany) return {
1068
- success: false,
1069
- error: "Repository does not support createMany",
1070
- status: 501
1071
- };
1072
- const rawItems = req.body?.items;
1073
- if (!Array.isArray(rawItems) || rawItems.length === 0) return {
1074
- success: false,
1075
- error: "Bulk create requires a non-empty items array",
1076
- status: 400
1077
- };
1078
- const items = rawItems;
1079
- const arcContext = this.meta(req);
1080
- const user = req.user;
1081
- const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
1082
- let scopedItems = sanitizedItems;
1083
- if (this.tenantField) {
1084
- const scope = arcContext?._scope;
1085
- if (scope) {
1086
- if (scope.kind === "public") return {
1087
- success: false,
1088
- error: "Organization context required to bulk-create resources",
1089
- details: { code: "ORG_CONTEXT_REQUIRED" },
1090
- status: 403
1091
- };
1092
- if (!isElevated(scope)) {
1093
- const orgId = getOrgId(scope);
1094
- if (!orgId) return {
988
+ const items = rawItems;
989
+ const arcContext = this.meta(req);
990
+ const user = req.user;
991
+ const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
992
+ let scopedItems = sanitizedItems;
993
+ if (this.tenantField) {
994
+ const scope = arcContext?._scope;
995
+ if (scope) {
996
+ if (scope.kind === "public") return {
1095
997
  success: false,
1096
998
  error: "Organization context required to bulk-create resources",
1097
999
  details: { code: "ORG_CONTEXT_REQUIRED" },
1098
1000
  status: 403
1099
1001
  };
1100
- const tenantField = this.tenantField;
1101
- scopedItems = sanitizedItems.map((item) => ({
1102
- ...item,
1103
- [tenantField]: orgId
1104
- }));
1002
+ if (!isElevated(scope)) {
1003
+ const orgId = getOrgId(scope);
1004
+ if (!orgId) return {
1005
+ success: false,
1006
+ error: "Organization context required to bulk-create resources",
1007
+ details: { code: "ORG_CONTEXT_REQUIRED" },
1008
+ status: 403
1009
+ };
1010
+ const tenantField = this.tenantField;
1011
+ scopedItems = sanitizedItems.map((item) => ({
1012
+ ...item,
1013
+ [tenantField]: orgId
1014
+ }));
1015
+ }
1105
1016
  }
1106
1017
  }
1107
- }
1108
- const created = await repo.createMany(scopedItems, {
1109
- user,
1110
- context: arcContext
1111
- });
1112
- const requested = items.length;
1113
- const inserted = created.length;
1114
- const skipped = requested - inserted;
1115
- return {
1116
- success: true,
1117
- data: created,
1118
- status: skipped === 0 ? 201 : inserted === 0 ? 422 : 207,
1119
- meta: {
1120
- count: inserted,
1121
- requested,
1122
- inserted,
1123
- skipped,
1124
- ...skipped > 0 && {
1125
- partial: true,
1126
- reason: inserted === 0 ? "all_invalid" : "some_invalid"
1018
+ const created = await repo.createMany(scopedItems, {
1019
+ user,
1020
+ context: arcContext
1021
+ });
1022
+ const requested = items.length;
1023
+ const inserted = created.length;
1024
+ const skipped = requested - inserted;
1025
+ return {
1026
+ success: true,
1027
+ data: created,
1028
+ status: skipped === 0 ? 201 : inserted === 0 ? 422 : 207,
1029
+ meta: {
1030
+ count: inserted,
1031
+ requested,
1032
+ inserted,
1033
+ skipped,
1034
+ ...skipped > 0 && {
1035
+ partial: true,
1036
+ reason: inserted === 0 ? "all_invalid" : "some_invalid"
1037
+ }
1127
1038
  }
1039
+ };
1040
+ }
1041
+ /**
1042
+ * Build a tenant-scoped filter for bulk update/delete.
1043
+ *
1044
+ * Mirrors `AccessControl.buildIdFilter` semantics:
1045
+ * - Always merge `_policyFilters` (from permission middleware)
1046
+ * - `member` scope on a tenant resource → add org filter
1047
+ * - `elevated` scope → no org filter (admin cross-org operation)
1048
+ * - `public` scope on a tenant resource → deny
1049
+ * - No scope at all (unit tests) → leave filter unchanged
1050
+ *
1051
+ * Returns the merged filter, or `null` when access must be denied.
1052
+ */
1053
+ buildBulkFilter(userFilter, req) {
1054
+ const filter = { ...userFilter };
1055
+ const arcContext = this.meta(req);
1056
+ const policyFilters = arcContext?._policyFilters;
1057
+ if (policyFilters) Object.assign(filter, policyFilters);
1058
+ if (this.tenantField) {
1059
+ const scope = arcContext?._scope;
1060
+ if (!scope) return filter;
1061
+ if (scope.kind === "public") return null;
1062
+ if (isElevated(scope)) return filter;
1063
+ const orgId = getOrgId(scope);
1064
+ if (!orgId) return null;
1065
+ filter[this.tenantField] = orgId;
1128
1066
  }
1129
- };
1130
- }
1131
- /**
1132
- * Build a tenant-scoped filter for bulk update/delete.
1133
- *
1134
- * Mirrors `AccessControl.buildIdFilter` semantics for single-doc ops:
1135
- * - Always merge `_policyFilters` (from permission middleware)
1136
- * - When `tenantField` is set AND a `member` scope is present, add the
1137
- * org filter so cross-tenant data can't be touched.
1138
- * - When the scope is `elevated` (platform admin), no org filter is
1139
- * applied — admins can bulk-update across orgs intentionally.
1140
- * - When the scope is `public` on a tenant-scoped resource, deny.
1141
- * - When NO scope is present at all (e.g., direct controller calls in
1142
- * unit tests, or app routes without auth middleware), the controller
1143
- * stays lenient — it's the middleware layer's job to fail-close.
1144
- * Apps that want fail-close on bulk routes should run the multi-tenant
1145
- * preset middleware (or equivalent) ahead of these handlers.
1146
- *
1147
- * Returns the merged filter, or `null` when access must be denied.
1148
- */
1149
- buildBulkFilter(userFilter, req) {
1150
- const filter = { ...userFilter };
1151
- const arcContext = this.meta(req);
1152
- const policyFilters = arcContext?._policyFilters;
1153
- if (policyFilters) Object.assign(filter, policyFilters);
1154
- if (this.tenantField) {
1155
- const scope = arcContext?._scope;
1156
- if (!scope) return filter;
1157
- if (scope.kind === "public") return null;
1158
- if (isElevated(scope)) return filter;
1159
- const orgId = getOrgId(scope);
1160
- if (!orgId) return null;
1161
- filter[this.tenantField] = orgId;
1067
+ return filter;
1162
1068
  }
1163
- return filter;
1164
- }
1165
- /**
1166
- * Sanitize a bulk update data payload through the same write-permission
1167
- * pipeline as single-doc update(). Handles both shapes:
1168
- *
1169
- * - Flat: `{ name: 'x', status: 'y' }`
1170
- * - Mongo operator: `{ $set: { name: 'x' }, $inc: { views: 1 }, $unset: { tag: '' } }`
1171
- *
1172
- * For each operand, runs `bodySanitizer.sanitize('update', ...)` so that
1173
- * system fields, systemManaged/readonly/immutable rules, AND field-level
1174
- * write permissions are enforced. Without this, a tenant-scoped user could
1175
- * pass `{ $set: { organizationId: 'org-b' } }` to move records across orgs.
1176
- *
1177
- * Returns the sanitized payload along with the list of stripped fields for
1178
- * audit/error reporting.
1179
- */
1180
- sanitizeBulkUpdateData(data, req, arcContext) {
1181
- const stripped = /* @__PURE__ */ new Set();
1182
- const keys = Object.keys(data);
1183
- const operatorKeys = keys.filter((k) => k.startsWith("$"));
1184
- const flatKeys = keys.filter((k) => !k.startsWith("$"));
1185
- const isOperatorShape = operatorKeys.length > 0;
1186
- if (isOperatorShape && flatKeys.length > 0) return {
1187
- sanitized: {},
1188
- stripped: [],
1189
- mixedShape: true
1190
- };
1191
- if (!isOperatorShape) {
1192
- const before = new Set(Object.keys(data));
1193
- const sanitized = this.bodySanitizer.sanitize(data, "update", req, arcContext);
1194
- for (const key of before) if (!(key in sanitized)) stripped.add(key);
1069
+ /**
1070
+ * Sanitize a bulk update data payload through the same write-permission
1071
+ * pipeline as single-doc update. Handles both shapes:
1072
+ * - Flat: `{ name: 'x', status: 'y' }`
1073
+ * - Mongo operator: `{ $set: { name: 'x' }, $inc: { views: 1 } }`
1074
+ *
1075
+ * Mixed shapes (operator + flat keys) are rejected — mongo silently
1076
+ * drops the flat keys in operator mode, which is a footgun.
1077
+ */
1078
+ sanitizeBulkUpdateData(data, req, arcContext) {
1079
+ const stripped = /* @__PURE__ */ new Set();
1080
+ const keys = Object.keys(data);
1081
+ const operatorKeys = keys.filter((k) => k.startsWith("$"));
1082
+ const flatKeys = keys.filter((k) => !k.startsWith("$"));
1083
+ const isOperatorShape = operatorKeys.length > 0;
1084
+ if (isOperatorShape && flatKeys.length > 0) return {
1085
+ sanitized: {},
1086
+ stripped: [],
1087
+ mixedShape: true
1088
+ };
1089
+ if (!isOperatorShape) {
1090
+ const before = new Set(Object.keys(data));
1091
+ const sanitized = this.bodySanitizer.sanitize(data, "update", req, arcContext);
1092
+ for (const key of before) if (!(key in sanitized)) stripped.add(key);
1093
+ return {
1094
+ sanitized,
1095
+ stripped: [...stripped],
1096
+ mixedShape: false
1097
+ };
1098
+ }
1099
+ const sanitized = {};
1100
+ for (const [op, operand] of Object.entries(data)) {
1101
+ if (!op.startsWith("$") || operand === null || typeof operand !== "object") {
1102
+ sanitized[op] = operand;
1103
+ continue;
1104
+ }
1105
+ const operandObj = operand;
1106
+ const before = new Set(Object.keys(operandObj));
1107
+ const sanitizedOperand = this.bodySanitizer.sanitize(operandObj, "update", req, arcContext);
1108
+ for (const key of before) if (!(key in sanitizedOperand)) stripped.add(key);
1109
+ if (Object.keys(sanitizedOperand).length > 0) sanitized[op] = sanitizedOperand;
1110
+ }
1195
1111
  return {
1196
1112
  sanitized,
1197
1113
  stripped: [...stripped],
1198
1114
  mixedShape: false
1199
1115
  };
1200
1116
  }
1201
- const sanitized = {};
1202
- for (const [op, operand] of Object.entries(data)) {
1203
- if (!op.startsWith("$") || operand === null || typeof operand !== "object") {
1204
- sanitized[op] = operand;
1205
- continue;
1206
- }
1207
- const operandObj = operand;
1208
- const before = new Set(Object.keys(operandObj));
1209
- const sanitizedOperand = this.bodySanitizer.sanitize(operandObj, "update", req, arcContext);
1210
- for (const key of before) if (!(key in sanitizedOperand)) stripped.add(key);
1211
- if (Object.keys(sanitizedOperand).length > 0) sanitized[op] = sanitizedOperand;
1117
+ async bulkUpdate(req) {
1118
+ const repo = this.repository;
1119
+ if (!repo.updateMany) return {
1120
+ success: false,
1121
+ error: "Repository does not support updateMany",
1122
+ status: 501
1123
+ };
1124
+ const body = req.body;
1125
+ if (!body.filter || Object.keys(body.filter).length === 0) return {
1126
+ success: false,
1127
+ error: "Bulk update requires a non-empty filter",
1128
+ status: 400
1129
+ };
1130
+ if (!body.data || Object.keys(body.data).length === 0) return {
1131
+ success: false,
1132
+ error: "Bulk update requires non-empty data",
1133
+ status: 400
1134
+ };
1135
+ const scopedFilter = this.buildBulkFilter(body.filter, req);
1136
+ if (scopedFilter === null) return {
1137
+ success: false,
1138
+ error: "Organization context required for bulk update",
1139
+ details: { code: "ORG_CONTEXT_REQUIRED" },
1140
+ status: 403
1141
+ };
1142
+ const arcContext = this.meta(req);
1143
+ const user = req.user;
1144
+ const { sanitized, stripped, mixedShape } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
1145
+ if (mixedShape) return {
1146
+ success: false,
1147
+ error: "Bulk update payload cannot mix operator keys ($set, $inc, ...) with flat fields. Pick one shape.",
1148
+ details: { code: "MIXED_UPDATE_SHAPE" },
1149
+ status: 400
1150
+ };
1151
+ if (Object.keys(sanitized).length === 0) return {
1152
+ success: false,
1153
+ error: "Bulk update payload contained only protected fields",
1154
+ details: {
1155
+ code: "ALL_FIELDS_STRIPPED",
1156
+ stripped
1157
+ },
1158
+ status: 400
1159
+ };
1160
+ return {
1161
+ success: true,
1162
+ data: await repo.updateMany(scopedFilter, sanitized, {
1163
+ user,
1164
+ context: arcContext
1165
+ }),
1166
+ status: 200,
1167
+ ...stripped.length > 0 && { meta: { stripped } }
1168
+ };
1212
1169
  }
1213
- return {
1214
- sanitized,
1215
- stripped: [...stripped],
1216
- mixedShape: false
1217
- };
1218
- }
1219
- async bulkUpdate(req) {
1220
- const repo = this.repository;
1221
- if (!repo.updateMany) return {
1222
- success: false,
1223
- error: "Repository does not support updateMany",
1224
- status: 501
1225
- };
1226
- const body = req.body;
1227
- if (!body.filter || Object.keys(body.filter).length === 0) return {
1228
- success: false,
1229
- error: "Bulk update requires a non-empty filter",
1230
- status: 400
1231
- };
1232
- if (!body.data || Object.keys(body.data).length === 0) return {
1233
- success: false,
1234
- error: "Bulk update requires non-empty data",
1235
- status: 400
1236
- };
1237
- const scopedFilter = this.buildBulkFilter(body.filter, req);
1238
- if (scopedFilter === null) return {
1239
- success: false,
1240
- error: "Organization context required for bulk update",
1241
- details: { code: "ORG_CONTEXT_REQUIRED" },
1242
- status: 403
1243
- };
1244
- const arcContext = this.meta(req);
1245
- const user = req.user;
1246
- const { sanitized, stripped, mixedShape } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
1247
- if (mixedShape) return {
1248
- success: false,
1249
- error: "Bulk update payload cannot mix operator keys ($set, $inc, ...) with flat fields. Pick one shape.",
1250
- details: { code: "MIXED_UPDATE_SHAPE" },
1251
- status: 400
1252
- };
1253
- if (Object.keys(sanitized).length === 0) return {
1254
- success: false,
1255
- error: "Bulk update payload contained only protected fields",
1256
- details: {
1257
- code: "ALL_FIELDS_STRIPPED",
1258
- stripped
1259
- },
1260
- status: 400
1261
- };
1262
- return {
1263
- success: true,
1264
- data: await repo.updateMany(scopedFilter, sanitized, {
1265
- user,
1170
+ /**
1171
+ * Bulk delete by `filter` or `ids`.
1172
+ *
1173
+ * Body shape (one of):
1174
+ * - `{ filter: { status: 'archived' } }` — delete by query filter
1175
+ * - `{ ids: ['id1', 'id2', 'id3'] }` — delete specific docs by id
1176
+ *
1177
+ * The `ids` form translates to `{ [idField]: { $in: ids } }` using the
1178
+ * resource's `idField` (so it works with custom PKs like `slug`, `jobId`,
1179
+ * UUID). Tenant scope and policy filters are merged in either way.
1180
+ *
1181
+ * Both forms perform a single `repo.deleteMany()` DB call — no per-doc
1182
+ * fetch loop. Per-doc lifecycle hooks do NOT fire.
1183
+ */
1184
+ async bulkDelete(req) {
1185
+ const repo = this.repository;
1186
+ if (!repo.deleteMany) return {
1187
+ success: false,
1188
+ error: "Repository does not support deleteMany",
1189
+ status: 501
1190
+ };
1191
+ const body = req.body;
1192
+ let userFilter;
1193
+ if (body.ids && body.ids.length > 0) {
1194
+ if (body.filter && Object.keys(body.filter).length > 0) return {
1195
+ success: false,
1196
+ error: "Bulk delete accepts either `ids` or `filter`, not both",
1197
+ status: 400
1198
+ };
1199
+ userFilter = { [this.idField]: { $in: body.ids } };
1200
+ } else if (body.filter && Object.keys(body.filter).length > 0) userFilter = body.filter;
1201
+ else return {
1202
+ success: false,
1203
+ error: "Bulk delete requires a non-empty `filter` or `ids` array",
1204
+ status: 400
1205
+ };
1206
+ const scopedFilter = this.buildBulkFilter(userFilter, req);
1207
+ if (scopedFilter === null) return {
1208
+ success: false,
1209
+ error: "Organization context required for bulk delete",
1210
+ details: { code: "ORG_CONTEXT_REQUIRED" },
1211
+ status: 403
1212
+ };
1213
+ const hardHint = req.query?.hard === "true" || req.query?.hard === true || body.mode === "hard";
1214
+ const arcContext = this.meta(req);
1215
+ const options = {
1216
+ user: req.user,
1266
1217
  context: arcContext
1267
- }),
1268
- status: 200,
1269
- ...stripped.length > 0 && { meta: { stripped } }
1270
- };
1271
- }
1272
- /**
1273
- * Bulk delete by `filter` or `ids`.
1274
- *
1275
- * Body shape (one of):
1276
- * - `{ filter: { status: 'archived' } }` — delete by query filter
1277
- * - `{ ids: ['id1', 'id2', 'id3'] }` — delete specific docs by id
1278
- *
1279
- * The `ids` form translates to `{ [idField]: { $in: ids } }` using the
1280
- * resource's `idField` (so it works with custom PKs like `slug`, `jobId`,
1281
- * UUID, etc.). Tenant scope and policy filters are merged in either way,
1282
- * so cross-tenant deletes are blocked at the controller layer.
1283
- *
1284
- * Both forms perform a single `repo.deleteMany()` DB call — no per-doc
1285
- * fetch loop. Per-doc lifecycle hooks (`before:delete`/`after:delete`) do
1286
- * NOT fire for bulk operations; use the single-doc `delete()` if you need
1287
- * them, or subscribe to the bulk lifecycle event from the events plugin.
1288
- */
1289
- async bulkDelete(req) {
1290
- const repo = this.repository;
1291
- if (!repo.deleteMany) return {
1292
- success: false,
1293
- error: "Repository does not support deleteMany",
1294
- status: 501
1295
- };
1296
- const body = req.body;
1297
- let userFilter;
1298
- if (body.ids && body.ids.length > 0) {
1299
- if (body.filter && Object.keys(body.filter).length > 0) return {
1218
+ };
1219
+ if (hardHint) options.mode = "hard";
1220
+ return {
1221
+ success: true,
1222
+ data: await repo.deleteMany(scopedFilter, options),
1223
+ status: 200
1224
+ };
1225
+ }
1226
+ };
1227
+ }
1228
+ //#endregion
1229
+ //#region src/core/mixins/slug.ts
1230
+ function SlugMixin(Base) {
1231
+ return class SlugController extends Base {
1232
+ async getBySlug(req) {
1233
+ const slugField = this._presetFields.slugField ?? "slug";
1234
+ const slug = req.params[slugField] ?? req.params.slug;
1235
+ const options = {
1236
+ ...this.queryResolver.resolve(req, this.meta(req)),
1237
+ ...this.tenantRepoOptions(req)
1238
+ };
1239
+ const repo = this.repository;
1240
+ let item = null;
1241
+ if (repo.getBySlug) item = await repo.getBySlug(slug, options);
1242
+ else if (repo.getOne) {
1243
+ const filter = {
1244
+ [slugField]: slug,
1245
+ ...options?.filter ?? {}
1246
+ };
1247
+ item = await repo.getOne(filter, options);
1248
+ } else return {
1300
1249
  success: false,
1301
- error: "Bulk delete accepts either `ids` or `filter`, not both",
1250
+ error: "Slug lookup not implemented repository needs getBySlug() or getOne()",
1251
+ status: 501
1252
+ };
1253
+ if (!this.accessControl.validateItemAccess(item, req)) return this.notFoundResponse(item ? "POLICY_FILTERED" : "NOT_FOUND");
1254
+ return {
1255
+ success: true,
1256
+ data: item,
1257
+ status: 200
1258
+ };
1259
+ }
1260
+ };
1261
+ }
1262
+ //#endregion
1263
+ //#region src/core/mixins/softDelete.ts
1264
+ function SoftDeleteMixin(Base) {
1265
+ return class SoftDeleteController extends Base {
1266
+ async getDeleted(req) {
1267
+ const repo = this.repository;
1268
+ if (!repo.getDeleted) return {
1269
+ success: false,
1270
+ error: "Soft delete not implemented",
1271
+ status: 501
1272
+ };
1273
+ const parsed = this.queryResolver.resolve(req, this.meta(req));
1274
+ return {
1275
+ success: true,
1276
+ data: await repo.getDeleted(parsed, parsed),
1277
+ status: 200
1278
+ };
1279
+ }
1280
+ async restore(req) {
1281
+ const repo = this.repository;
1282
+ if (!repo.restore) return {
1283
+ success: false,
1284
+ error: "Restore not implemented",
1285
+ status: 501
1286
+ };
1287
+ const id = req.params.id;
1288
+ if (!id) return {
1289
+ success: false,
1290
+ error: "ID parameter is required",
1302
1291
  status: 400
1303
1292
  };
1304
- userFilter = { [this.idField]: { $in: body.ids } };
1305
- } else if (body.filter && Object.keys(body.filter).length > 0) userFilter = body.filter;
1306
- else return {
1307
- success: false,
1308
- error: "Bulk delete requires a non-empty `filter` or `ids` array",
1309
- status: 400
1310
- };
1311
- const scopedFilter = this.buildBulkFilter(userFilter, req);
1312
- if (scopedFilter === null) return {
1313
- success: false,
1314
- error: "Organization context required for bulk delete",
1315
- details: { code: "ORG_CONTEXT_REQUIRED" },
1316
- status: 403
1317
- };
1318
- const hardHint = req.query?.hard === "true" || req.query?.hard === true || body.mode === "hard";
1319
- const arcContext = this.meta(req);
1320
- const options = {
1321
- user: req.user,
1322
- context: arcContext
1323
- };
1324
- if (hardHint) options.mode = "hard";
1325
- return {
1326
- success: true,
1327
- data: await repo.deleteMany(scopedFilter, options),
1328
- status: 200
1329
- };
1330
- }
1331
- };
1293
+ const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
1294
+ if (!existing) return this.notFoundResponse("NOT_FOUND");
1295
+ if (!this.accessControl.checkOwnership(existing, req)) return {
1296
+ success: false,
1297
+ error: "You do not have permission to restore this resource",
1298
+ details: { code: "OWNERSHIP_DENIED" },
1299
+ status: 403
1300
+ };
1301
+ const arcContext = this.meta(req);
1302
+ const user = req.user;
1303
+ const repoId = this.resolveRepoId(id, existing);
1304
+ const hooks = this.getHooks(req);
1305
+ if (hooks && this.resourceName) try {
1306
+ await hooks.executeBefore(this.resourceName, "restore", existing, {
1307
+ user,
1308
+ context: arcContext,
1309
+ meta: { id }
1310
+ });
1311
+ } catch (err) {
1312
+ return {
1313
+ success: false,
1314
+ error: "Hook execution failed",
1315
+ details: {
1316
+ code: "BEFORE_RESTORE_HOOK_ERROR",
1317
+ message: err.message
1318
+ },
1319
+ status: 400
1320
+ };
1321
+ }
1322
+ const repoRestore = () => repo.restore(repoId);
1323
+ let item;
1324
+ if (hooks && this.resourceName) item = await hooks.executeAround(this.resourceName, "restore", existing, repoRestore, {
1325
+ user,
1326
+ context: arcContext,
1327
+ meta: { id }
1328
+ });
1329
+ else item = await repoRestore();
1330
+ if (!item) return this.notFoundResponse("NOT_FOUND");
1331
+ if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "restore", item, {
1332
+ user,
1333
+ context: arcContext,
1334
+ meta: { id }
1335
+ });
1336
+ return {
1337
+ success: true,
1338
+ data: item,
1339
+ status: 200,
1340
+ meta: { message: "Restored successfully" }
1341
+ };
1342
+ }
1343
+ };
1344
+ }
1345
+ //#endregion
1346
+ //#region src/core/mixins/tree.ts
1347
+ function TreeMixin(Base) {
1348
+ return class TreeController extends Base {
1349
+ async getTree(req) {
1350
+ const repo = this.repository;
1351
+ if (!repo.getTree) return {
1352
+ success: false,
1353
+ error: "Tree structure not implemented",
1354
+ status: 501
1355
+ };
1356
+ const options = this.queryResolver.resolve(req, this.meta(req));
1357
+ return {
1358
+ success: true,
1359
+ data: await repo.getTree(options),
1360
+ status: 200
1361
+ };
1362
+ }
1363
+ async getChildren(req) {
1364
+ const repo = this.repository;
1365
+ if (!repo.getChildren) return {
1366
+ success: false,
1367
+ error: "Tree structure not implemented",
1368
+ status: 501
1369
+ };
1370
+ const parentField = this._presetFields.parentField ?? "parent";
1371
+ const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
1372
+ const options = this.queryResolver.resolve(req, this.meta(req));
1373
+ return {
1374
+ success: true,
1375
+ data: await repo.getChildren(parentId, options),
1376
+ status: 200
1377
+ };
1378
+ }
1379
+ };
1380
+ }
1381
+ //#endregion
1382
+ //#region src/core/BaseController.ts
1383
+ /**
1384
+ * Fully-composed controller: `BaseCrudController` + SoftDelete + Tree +
1385
+ * Slug + Bulk. Drop-in replacement for the pre-2.11 god class. The
1386
+ * companion interface above gives every method full generic precision
1387
+ * on `TDoc` via declaration merging.
1388
+ */
1389
+ var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
1332
1390
  //#endregion
1333
- export { AccessControl as i, QueryResolver as n, BodySanitizer as r, BaseController as t };
1391
+ export { BulkMixin as a, BodySanitizer as c, SlugMixin as i, AccessControl as l, TreeMixin as n, BaseCrudController as o, SoftDeleteMixin as r, QueryResolver as s, BaseController as t };