@classytic/arc 2.8.5 → 2.10.3

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 (155) hide show
  1. package/README.md +50 -38
  2. package/dist/{BaseController-DAGGc5Xn.mjs → BaseController-CbKKIflT.mjs} +193 -143
  3. package/dist/EventTransport-CUw5NNWe.d.mts +293 -0
  4. package/dist/{ResourceRegistry-C6uXlWe3.mjs → ResourceRegistry-BPd6NQDm.mjs} +1 -1
  5. package/dist/adapters/index.d.mts +3 -3
  6. package/dist/adapters/index.mjs +2 -2
  7. package/dist/{adapters-BBqAVvPK.mjs → adapters-BXY4i-hw.mjs} +210 -41
  8. package/dist/audit/index.d.mts +135 -11
  9. package/dist/audit/index.mjs +107 -20
  10. package/dist/auth/index.d.mts +17 -9
  11. package/dist/auth/index.mjs +14 -7
  12. package/dist/auth/redis-session.d.mts +1 -1
  13. package/dist/{betterAuthOpenApi-BuUcUEJq.mjs → betterAuthOpenApi-BBRVhjQN.mjs} +1 -1
  14. package/dist/cache/index.d.mts +17 -15
  15. package/dist/cache/index.mjs +15 -14
  16. package/dist/{caching-IMuYVjTL.mjs → caching-CBpK_SCM.mjs} +8 -3
  17. package/dist/cli/commands/describe.mjs +1 -1
  18. package/dist/cli/commands/docs.mjs +2 -2
  19. package/dist/cli/commands/generate.mjs +1 -1
  20. package/dist/cli/commands/init.mjs +1 -1
  21. package/dist/cli/commands/introspect.mjs +1 -1
  22. package/dist/core/index.d.mts +3 -3
  23. package/dist/core/index.mjs +4 -6
  24. package/dist/{defineResource-tcgySDo1.mjs → core-CcR01lup.mjs} +58 -61
  25. package/dist/{createActionRouter-BORM8f17.mjs → createActionRouter-Bp_5c_2b.mjs} +3 -3
  26. package/dist/{createApp-B1EY8zxa.mjs → createApp-BuvPma24.mjs} +15 -14
  27. package/dist/docs/index.d.mts +2 -2
  28. package/dist/docs/index.mjs +2 -2
  29. package/dist/{elevation-DtFxrG0s.mjs → elevation-C7hgL_aI.mjs} +22 -8
  30. package/dist/{errorHandler-f869_8PQ.mjs → errorHandler-Bb49BvPD.mjs} +59 -7
  31. package/dist/{errorHandler-Bah5JhBd.d.mts → errorHandler-DRQ3EqfL.d.mts} +37 -2
  32. package/dist/{eventPlugin-D9DKB2zM.d.mts → eventPlugin-CxWgpd6K.d.mts} +14 -2
  33. package/dist/{eventPlugin-CDjVTM82.mjs → eventPlugin-DCUjuiQT.mjs} +83 -5
  34. package/dist/events/index.d.mts +150 -36
  35. package/dist/events/index.mjs +355 -101
  36. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  37. package/dist/events/transports/redis.d.mts +1 -1
  38. package/dist/factory/index.d.mts +1 -1
  39. package/dist/factory/index.mjs +2 -2
  40. package/dist/{types-DZi1aYhm.d.mts → fields-Lo1VUDpt.d.mts} +121 -1
  41. package/dist/{fields-ipsbIRPK.mjs → fields-bxkeltzz.mjs} +18 -5
  42. package/dist/{filesUpload-C7r7HIeA.mjs → filesUpload-t21LS-py.mjs} +65 -7
  43. package/dist/hooks/index.d.mts +1 -1
  44. package/dist/hooks/index.mjs +1 -1
  45. package/dist/idempotency/index.d.mts +32 -5
  46. package/dist/idempotency/index.mjs +119 -12
  47. package/dist/idempotency/redis.d.mts +1 -1
  48. package/dist/{index-DtDzOBn8.d.mts → index-8qw4y6ff.d.mts} +4 -135
  49. package/dist/{index-BLXBmWud.d.mts → index-ChIw3776.d.mts} +283 -408
  50. package/dist/{interface-CMRutPfe.d.mts → index-Cl0uoKd5.d.mts} +1758 -2506
  51. package/dist/{index-C1meYuDn.d.mts → index-DStwgFUK.d.mts} +81 -7
  52. package/dist/index.d.mts +7 -8
  53. package/dist/index.mjs +11 -12
  54. package/dist/integrations/event-gateway.d.mts +1 -1
  55. package/dist/integrations/event-gateway.mjs +1 -1
  56. package/dist/integrations/index.d.mts +1 -1
  57. package/dist/integrations/mcp/index.d.mts +26 -8
  58. package/dist/integrations/mcp/index.mjs +96 -17
  59. package/dist/integrations/mcp/testing.d.mts +1 -1
  60. package/dist/integrations/mcp/testing.mjs +1 -1
  61. package/dist/integrations/webhooks.d.mts +5 -0
  62. package/dist/integrations/webhooks.mjs +6 -0
  63. package/dist/interface-D218ikEo.d.mts +77 -0
  64. package/dist/{memory-Cp7_cAko.mjs → memory-B5Amv9A1.mjs} +23 -8
  65. package/dist/{openapi-CbKUJY_m.mjs → openapi-B5F8AddX.mjs} +3 -3
  66. package/dist/org/index.d.mts +2 -2
  67. package/dist/permissions/index.d.mts +3 -4
  68. package/dist/permissions/index.mjs +5 -5
  69. package/dist/{permissions-CH4cNwJi.mjs → permissions-Dk6mshja.mjs} +315 -397
  70. package/dist/plugins/index.d.mts +7 -7
  71. package/dist/plugins/index.mjs +14 -16
  72. package/dist/plugins/response-cache.mjs +2 -2
  73. package/dist/plugins/tracing-entry.d.mts +1 -1
  74. package/dist/plugins/tracing-entry.mjs +1 -1
  75. package/dist/presets/filesUpload.d.mts +27 -5
  76. package/dist/presets/filesUpload.mjs +1 -1
  77. package/dist/presets/index.d.mts +3 -2
  78. package/dist/presets/index.mjs +4 -3
  79. package/dist/presets/multiTenant.d.mts +1 -1
  80. package/dist/presets/multiTenant.mjs +2 -2
  81. package/dist/presets/search.d.mts +178 -0
  82. package/dist/presets/search.mjs +150 -0
  83. package/dist/{presets-C2xgzW6x.mjs → presets-fLJVXdVn.mjs} +1 -1
  84. package/dist/{queryCachePlugin-BJJGBTlu.d.mts → queryCachePlugin-BKbWjgDG.d.mts} +1 -1
  85. package/dist/{queryCachePlugin-BH-fidlv.mjs → queryCachePlugin-DQCEfJis.mjs} +9 -9
  86. package/dist/{queryParser-CgCtsjti.mjs → queryParser-DBqBB6AC.mjs} +1 -1
  87. package/dist/{redis-BM00zaPB.d.mts → redis-DqyeggCa.d.mts} +1 -1
  88. package/dist/{redis-stream-CrsfUmPt.d.mts → redis-stream-CakIQmwR.d.mts} +1 -1
  89. package/dist/registry/index.d.mts +1 -1
  90. package/dist/registry/index.mjs +2 -2
  91. package/dist/{resourceToTools-8s-EsCCe.mjs → resourceToTools-BElv3xPT.mjs} +65 -48
  92. package/dist/{schemaConverter-Y7nCYaLJ.mjs → schemaConverter-BxFDdtXu.mjs} +1 -1
  93. package/dist/scope/index.d.mts +1 -1
  94. package/dist/scope/index.mjs +2 -2
  95. package/dist/{sse-Ad7ypl9e.mjs → sse-yBCgOLGu.mjs} +1 -1
  96. package/dist/store-helpers-ZCSMJJAX.mjs +57 -0
  97. package/dist/testing/index.d.mts +9 -17
  98. package/dist/testing/index.mjs +27 -83
  99. package/dist/testing/storageContract.d.mts +1 -1
  100. package/dist/types/index.d.mts +4 -4
  101. package/dist/types/index.mjs +1 -31
  102. package/dist/types/storage.d.mts +1 -1
  103. package/dist/{types-BsbNMEDR.d.mts → types-Btdda02s.d.mts} +1 -1
  104. package/dist/{types-Ch9pTQbf.d.mts → types-Co8k3NyS.d.mts} +11 -9
  105. package/dist/types-Csi3FLfq.mjs +27 -0
  106. package/dist/utils/index.d.mts +208 -4
  107. package/dist/utils/index.mjs +5 -6
  108. package/dist/{utils-yYT3HDXt.mjs → utils-B2fNOD_i.mjs} +285 -2
  109. package/dist/{versioning-CDugduqI.mjs → versioning-C2U_bLY0.mjs} +3 -5
  110. package/package.json +20 -26
  111. package/skills/arc/SKILL.md +97 -23
  112. package/skills/arc/references/auth.md +94 -0
  113. package/skills/arc/references/events.md +200 -12
  114. package/skills/arc/references/mcp.md +4 -17
  115. package/skills/arc/references/multi-tenancy.md +43 -0
  116. package/skills/arc/references/production.md +34 -60
  117. package/dist/EventTransport-BXja8NOc.d.mts +0 -135
  118. package/dist/audit/mongodb.d.mts +0 -2
  119. package/dist/audit/mongodb.mjs +0 -2
  120. package/dist/circuitBreaker-cmi5XDv5.mjs +0 -284
  121. package/dist/circuitBreaker-dTtG-UyS.d.mts +0 -206
  122. package/dist/core-F0QoWBt2.mjs +0 -34
  123. package/dist/dynamic/index.d.mts +0 -93
  124. package/dist/dynamic/index.mjs +0 -122
  125. package/dist/fields-DpZQa_Q3.d.mts +0 -109
  126. package/dist/idempotency/mongodb.d.mts +0 -2
  127. package/dist/idempotency/mongodb.mjs +0 -123
  128. package/dist/interface-4y979v99.d.mts +0 -54
  129. package/dist/mongodb-BsP-WbhN.d.mts +0 -127
  130. package/dist/mongodb-CTcp0hQZ.d.mts +0 -80
  131. package/dist/mongodb-Utc5k_-0.mjs +0 -90
  132. package/dist/policies/index.d.mts +0 -432
  133. package/dist/policies/index.mjs +0 -318
  134. package/dist/rpc/index.d.mts +0 -90
  135. package/dist/rpc/index.mjs +0 -248
  136. /package/dist/{HookSystem-HprTmvVY.mjs → HookSystem-BNYKnrXF.mjs} +0 -0
  137. /package/dist/{applyPermissionResult-D6GPMsvh.mjs → applyPermissionResult-QhV1Pa-g.mjs} +0 -0
  138. /package/dist/{constants-Cxde4rpC.mjs → constants-BhY1OHoH.mjs} +0 -0
  139. /package/dist/{elevation-B6S5csVA.d.mts → elevation-C5SwtkAn.d.mts} +0 -0
  140. /package/dist/{errors-Ck2h67pm.d.mts → errors-CCSsMpXE.d.mts} +0 -0
  141. /package/dist/{errors-BF2bIOIS.mjs → errors-D5c-5BJL.mjs} +0 -0
  142. /package/dist/{externalPaths-BnkYrNzp.d.mts → externalPaths-BQ8QijNH.d.mts} +0 -0
  143. /package/dist/{interface-DfLGcus7.d.mts → interface-CSbZdv_3.d.mts} +0 -0
  144. /package/dist/{loadResources-PWd0OCpV.mjs → loadResources-BAzJItAJ.mjs} +0 -0
  145. /package/dist/{logger-D1YrIImS.mjs → logger-DLg8-Ueg.mjs} +0 -0
  146. /package/dist/{metrics-B-PU4-Yu.mjs → metrics-DuhiSEZI.mjs} +0 -0
  147. /package/dist/{pluralize-CWP6MB39.mjs → pluralize-A0tWEl1K.mjs} +0 -0
  148. /package/dist/{registry-BiTKT1Dg.mjs → registry-B3lRFBWo.mjs} +0 -0
  149. /package/dist/{replyHelpers-CxkYGT81.mjs → replyHelpers-CXtJDAZ0.mjs} +0 -0
  150. /package/dist/{requestContext-DYvHl113.mjs → requestContext-xHIKedG6.mjs} +0 -0
  151. /package/dist/{sessionManager-DDCmiNIo.d.mts → sessionManager-BkzVU8h2.d.mts} +0 -0
  152. /package/dist/{storage-Dfzt4VTl.d.mts → storage-CVk_SEn2.d.mts} +0 -0
  153. /package/dist/{tracing-DdN2-wHJ.d.mts → tracing-65B51Dw3.d.mts} +0 -0
  154. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-Cj5Rgvlg.mjs} +0 -0
  155. /package/dist/{types-ZUu_h0jp.mjs → types-DV9WDfeg.mjs} +0 -0
@@ -1,19 +1,12 @@
1
- import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-Cxde4rpC.mjs";
1
+ import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-BhY1OHoH.mjs";
2
2
  import { _ as isElevated, n as PUBLIC_SCOPE, o as getOrgId, v as isMember } from "./types-AOD8fxIw.mjs";
3
3
  import { t as buildQueryKey } from "./keys-qcD-TVJl.mjs";
4
- import { getUserId } from "./types/index.mjs";
5
- import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-ipsbIRPK.mjs";
6
- import { t as getUserRoles } from "./types-ZUu_h0jp.mjs";
7
- import { t as ArcQueryParser } from "./queryParser-CgCtsjti.mjs";
4
+ import { n as getUserId } from "./types-Csi3FLfq.mjs";
5
+ import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-bxkeltzz.mjs";
6
+ import { t as getUserRoles } from "./types-DV9WDfeg.mjs";
7
+ import { r as ForbiddenError } from "./errors-D5c-5BJL.mjs";
8
+ import { t as ArcQueryParser } from "./queryParser-DBqBB6AC.mjs";
8
9
  //#region src/core/AccessControl.ts
9
- /**
10
- * AccessControl - Composable access control logic extracted from BaseController.
11
- *
12
- * Handles ID filtering, policy filter checking, org/tenant scope validation,
13
- * ownership verification, and fetch-with-access-control patterns.
14
- *
15
- * Designed to be used standalone or composed into controllers.
16
- */
17
10
  var AccessControl = class AccessControl {
18
11
  tenantField;
19
12
  idField;
@@ -95,20 +88,82 @@ var AccessControl = class AccessControl {
95
88
  * buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
96
89
  */
97
90
  async fetchWithAccessControl(id, req, repository, queryOptions) {
91
+ return (await this.fetchDetailed(id, req, repository, queryOptions)).doc;
92
+ }
93
+ /**
94
+ * Same as `fetchWithAccessControl` but returns a structured result with
95
+ * a denial reason so callers can distinguish "doc doesn't exist" from
96
+ * "doc exists but was filtered by policy/org scope" from "repo threw".
97
+ *
98
+ * Codes:
99
+ * - `null` — doc was found, no denial
100
+ * - `'NOT_FOUND'` — doc genuinely doesn't exist in the DB
101
+ * - `'POLICY_FILTERED'` — doc exists but the request's policy filters exclude it
102
+ * - `'ORG_SCOPE_DENIED'` — doc exists but the caller's org context doesn't match
103
+ * - `'REPO_ERROR'` — the repository threw a "not found" error (mongokit style)
104
+ */
105
+ async fetchDetailed(id, req, repository, queryOptions) {
98
106
  const compoundFilter = this.buildIdFilter(id, req);
99
- const needsCompoundLookup = Object.keys(compoundFilter).length > 1 || this.idField !== "_id";
107
+ const hasCompoundFilters = Object.keys(compoundFilter).length > 1;
108
+ const needsCompoundLookup = hasCompoundFilters || this.idField !== "_id";
109
+ const translateStatus404 = (error) => {
110
+ if (error && typeof error === "object" && error.status === 404) return {
111
+ doc: null,
112
+ reason: "NOT_FOUND"
113
+ };
114
+ return null;
115
+ };
100
116
  try {
101
- if (needsCompoundLookup && typeof repository.getOne === "function") return await repository.getOne(compoundFilter, queryOptions);
117
+ if (needsCompoundLookup && typeof repository.getOne === "function") {
118
+ const doc = await repository.getOne(compoundFilter, queryOptions);
119
+ if (doc) return {
120
+ doc,
121
+ reason: null
122
+ };
123
+ if (hasCompoundFilters) {
124
+ const idOnly = { [this.idField]: id };
125
+ const rawDoc = await repository.getOne(idOnly);
126
+ if (rawDoc) {
127
+ const arcContext = this._meta(req);
128
+ if (!this.checkOrgScope(rawDoc, arcContext)) return {
129
+ doc: null,
130
+ reason: "ORG_SCOPE_DENIED"
131
+ };
132
+ return {
133
+ doc: null,
134
+ reason: "POLICY_FILTERED"
135
+ };
136
+ }
137
+ }
138
+ return {
139
+ doc: null,
140
+ reason: "NOT_FOUND"
141
+ };
142
+ }
102
143
  if (this.idField !== "_id") {
103
144
  if (typeof repository.getOne !== "function") throw new Error(`Resource with idField="${this.idField}" requires repository.getOne() to look up by custom field. Arc's BaseController cannot fall back to getById() because it would query by _id.`);
104
145
  }
105
146
  const item = await repository.getById(id, queryOptions);
106
- if (!item) return null;
147
+ if (!item) return {
148
+ doc: null,
149
+ reason: "NOT_FOUND"
150
+ };
107
151
  const arcContext = this._meta(req);
108
- if (!this.checkOrgScope(item, arcContext) || !this.checkPolicyFilters(item, req)) return null;
109
- return item;
152
+ if (!this.checkOrgScope(item, arcContext)) return {
153
+ doc: null,
154
+ reason: "ORG_SCOPE_DENIED"
155
+ };
156
+ if (!this.checkPolicyFilters(item, req)) return {
157
+ doc: null,
158
+ reason: "POLICY_FILTERED"
159
+ };
160
+ return {
161
+ doc: item,
162
+ reason: null
163
+ };
110
164
  } catch (error) {
111
- if (error instanceof Error && error.message?.includes("not found")) return null;
165
+ const translated = translateStatus404(error);
166
+ if (translated) return translated;
112
167
  throw error;
113
168
  }
114
169
  }
@@ -224,20 +279,12 @@ var AccessControl = class AccessControl {
224
279
  }
225
280
  }
226
281
  };
227
- //#endregion
228
- //#region src/core/BodySanitizer.ts
229
- /**
230
- * BodySanitizer - Composable body sanitization logic extracted from BaseController.
231
- *
232
- * Strips readonly fields, system-managed fields, and applies field-level
233
- * write permissions from request bodies before create/update operations.
234
- *
235
- * Designed to be used standalone or composed into controllers.
236
- */
237
282
  var BodySanitizer = class {
238
283
  schemaOptions;
284
+ onFieldWriteDenied;
239
285
  constructor(config) {
240
286
  this.schemaOptions = config.schemaOptions;
287
+ this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
241
288
  }
242
289
  /**
243
290
  * Strip readonly and system-managed fields from request body.
@@ -261,7 +308,9 @@ var BodySanitizer = class {
261
308
  const fieldPerms = arcContext?.arc?.fields;
262
309
  if (fieldPerms) {
263
310
  const effectiveRoles = resolveEffectiveRoles(getUserRoles(req.user), isMember(scope) ? scope.orgRoles : []);
264
- sanitized = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
311
+ const { body: filtered, deniedFields } = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
312
+ if (deniedFields.length > 0 && this.onFieldWriteDenied === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
313
+ sanitized = filtered;
265
314
  }
266
315
  }
267
316
  }
@@ -404,6 +453,12 @@ var QueryResolver = class {
404
453
  //#endregion
405
454
  //#region src/core/BaseController.ts
406
455
  /**
456
+ * 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.
459
+ */
460
+ const scheduleBackground = typeof setImmediate === "function" ? (cb) => void setImmediate(cb) : (cb) => queueMicrotask(cb);
461
+ /**
407
462
  * Framework-agnostic base controller implementing IController.
408
463
  *
409
464
  * Composes AccessControl, BodySanitizer, and QueryResolver for clean
@@ -450,7 +505,10 @@ var BaseController = class {
450
505
  idField: this.idField,
451
506
  matchesFilter: this._matchesFilter
452
507
  });
453
- this.bodySanitizer = new BodySanitizer({ schemaOptions: this.schemaOptions });
508
+ this.bodySanitizer = new BodySanitizer({
509
+ schemaOptions: this.schemaOptions,
510
+ onFieldWriteDenied: options.onFieldWriteDenied
511
+ });
454
512
  this.queryResolver = new QueryResolver({
455
513
  queryParser: this.queryParser,
456
514
  maxLimit: this.maxLimit,
@@ -506,6 +564,28 @@ var BaseController = class {
506
564
  if (repoIdField && repoIdField === this.idField) return id;
507
565
  return String(existing["_id"] ?? id);
508
566
  }
567
+ /**
568
+ * Centralized 404 response builder. Maps the denial reason from
569
+ * `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.
575
+ */
576
+ notFoundResponse(reason = "NOT_FOUND") {
577
+ const code = reason ?? "NOT_FOUND";
578
+ return {
579
+ success: false,
580
+ error: {
581
+ NOT_FOUND: "Resource not found",
582
+ POLICY_FILTERED: "Resource not found",
583
+ ORG_SCOPE_DENIED: "Resource not found"
584
+ }[code] ?? "Resource not found",
585
+ status: 404,
586
+ details: { code }
587
+ };
588
+ }
509
589
  /** Resolve cache config for a specific operation, merging per-op overrides */
510
590
  resolveCacheConfig(operation) {
511
591
  const cfg = this._cacheConfig;
@@ -547,7 +627,7 @@ var BaseController = class {
547
627
  headers: { "x-cache": "HIT" }
548
628
  };
549
629
  if (status === "stale") {
550
- setImmediate(() => {
630
+ scheduleBackground(() => {
551
631
  this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
552
632
  });
553
633
  return {
@@ -576,20 +656,10 @@ var BaseController = class {
576
656
  async executeListQuery(options, req) {
577
657
  const hooks = this.getHooks(req);
578
658
  const repoGetAll = async () => this.repository.getAll(options);
579
- const result = hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "list", options, repoGetAll, {
659
+ return hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "list", options, repoGetAll, {
580
660
  user: req.user,
581
661
  context: this.meta(req)
582
662
  }) : await repoGetAll();
583
- if (Array.isArray(result)) return {
584
- docs: result,
585
- page: 1,
586
- limit: result.length,
587
- total: result.length,
588
- pages: 1,
589
- hasNext: false,
590
- hasPrev: false
591
- };
592
- return result;
593
663
  }
594
664
  async get(req) {
595
665
  const id = req.params.id;
@@ -616,8 +686,8 @@ var BaseController = class {
616
686
  headers: { "x-cache": "HIT" }
617
687
  };
618
688
  if (status === "stale") {
619
- setImmediate(() => {
620
- this.executeGetQuery(id, options, req).then((fresh) => {
689
+ scheduleBackground(() => {
690
+ this.executeGetQuery(id, options, req).then(({ doc: fresh }) => {
621
691
  if (fresh) qc.set(key, fresh, cacheConfig);
622
692
  }).catch(() => {});
623
693
  });
@@ -628,49 +698,42 @@ var BaseController = class {
628
698
  headers: { "x-cache": "STALE" }
629
699
  };
630
700
  }
631
- const item = await this.executeGetQuery(id, options, req);
632
- if (!item) return {
633
- success: false,
634
- error: "Resource not found",
635
- status: 404
636
- };
637
- await qc.set(key, item, cacheConfig);
701
+ const { doc: cached, reason: cacheReason } = await this.executeGetQuery(id, options, req);
702
+ if (!cached) return this.notFoundResponse(cacheReason);
703
+ await qc.set(key, cached, cacheConfig);
638
704
  return {
639
705
  success: true,
640
- data: item,
706
+ data: cached,
641
707
  status: 200,
642
708
  headers: { "x-cache": "MISS" }
643
709
  };
644
710
  }
645
- try {
646
- const item = await this.executeGetQuery(id, options, req);
647
- if (!item) return {
648
- success: false,
649
- error: "Resource not found",
650
- status: 404
651
- };
652
- return {
653
- success: true,
654
- data: item,
655
- status: 200
656
- };
657
- } catch (error) {
658
- if (error instanceof Error && error.message?.includes("not found")) return {
659
- success: false,
660
- error: "Resource not found",
661
- status: 404
662
- };
663
- throw error;
664
- }
711
+ const { doc, reason } = await this.executeGetQuery(id, options, req);
712
+ if (!doc) return this.notFoundResponse(reason);
713
+ return {
714
+ success: true,
715
+ data: doc,
716
+ status: 200
717
+ };
665
718
  }
666
719
  /** Execute get query through hooks (extracted for cache revalidation) */
667
720
  async executeGetQuery(id, options, req) {
668
721
  const hooks = this.getHooks(req);
669
- const fetchItem = async () => this.accessControl.fetchWithAccessControl(id, req, this.repository, options);
670
- return (hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "read", null, fetchItem, {
671
- user: req.user,
672
- context: this.meta(req)
673
- }) : await fetchItem()) ?? null;
722
+ const fetchItem = async () => {
723
+ return await this.accessControl.fetchDetailed(id, req, this.repository, options);
724
+ };
725
+ if (hooks && this.resourceName) {
726
+ const result = await fetchItem();
727
+ if (!result.doc) return result;
728
+ return {
729
+ doc: await hooks.executeAround(this.resourceName, "read", null, async () => result.doc, {
730
+ user: req.user,
731
+ context: this.meta(req)
732
+ }) ?? null,
733
+ reason: null
734
+ };
735
+ }
736
+ return fetchItem();
674
737
  }
675
738
  async create(req) {
676
739
  const arcContext = this.meta(req);
@@ -733,12 +796,8 @@ var BaseController = class {
733
796
  const user = req.user;
734
797
  const userId = getUserId(user);
735
798
  if (userId) data.updatedBy = userId;
736
- const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
737
- if (!existing) return {
738
- success: false,
739
- error: "Resource not found",
740
- status: 404
741
- };
799
+ const { doc: existing, reason: updateReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
800
+ if (!existing) return this.notFoundResponse(updateReason);
742
801
  if (!this.accessControl.checkOwnership(existing, req)) return {
743
802
  success: false,
744
803
  error: "You do not have permission to modify this resource",
@@ -791,11 +850,7 @@ var BaseController = class {
791
850
  }
792
851
  });
793
852
  } else item = await repoUpdate();
794
- if (!item) return {
795
- success: false,
796
- error: "Resource not found",
797
- status: 404
798
- };
853
+ if (!item) return this.notFoundResponse("NOT_FOUND");
799
854
  return {
800
855
  success: true,
801
856
  data: item,
@@ -812,12 +867,8 @@ var BaseController = class {
812
867
  };
813
868
  const arcContext = this.meta(req);
814
869
  const user = req.user;
815
- const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
816
- if (!existing) return {
817
- success: false,
818
- error: "Resource not found",
819
- status: 404
820
- };
870
+ const { doc: existing, reason: deleteReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
871
+ if (!existing) return this.notFoundResponse(deleteReason);
821
872
  if (!this.accessControl.checkOwnership(existing, req)) return {
822
873
  success: false,
823
874
  error: "You do not have permission to delete this resource",
@@ -862,11 +913,7 @@ var BaseController = class {
862
913
  if (typeof r.success === "boolean") return r.success;
863
914
  if (typeof r.deletedCount === "number") return r.deletedCount > 0;
864
915
  return true;
865
- })()) return {
866
- success: false,
867
- error: "Resource not found",
868
- status: 404
869
- };
916
+ })()) return this.notFoundResponse("NOT_FOUND");
870
917
  if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "delete", existing, {
871
918
  user,
872
919
  context: arcContext,
@@ -901,11 +948,7 @@ var BaseController = class {
901
948
  error: "Slug lookup not implemented — repository needs getBySlug() or getOne()",
902
949
  status: 501
903
950
  };
904
- if (!this.accessControl.validateItemAccess(item, req)) return {
905
- success: false,
906
- error: "Resource not found",
907
- status: 404
908
- };
951
+ if (!this.accessControl.validateItemAccess(item, req)) return this.notFoundResponse(item ? "POLICY_FILTERED" : "NOT_FOUND");
909
952
  return {
910
953
  success: true,
911
954
  data: item,
@@ -920,27 +963,9 @@ var BaseController = class {
920
963
  status: 501
921
964
  };
922
965
  const parsed = this.queryResolver.resolve(req, this.meta(req));
923
- const result = await repo.getDeleted(parsed, parsed);
924
- if (Array.isArray(result)) {
925
- const docs = result;
926
- return {
927
- success: true,
928
- data: {
929
- method: "offset",
930
- docs,
931
- page: 1,
932
- limit: docs.length,
933
- total: docs.length,
934
- pages: 1,
935
- hasNext: false,
936
- hasPrev: false
937
- },
938
- status: 200
939
- };
940
- }
941
966
  return {
942
967
  success: true,
943
- data: result,
968
+ data: await repo.getDeleted(parsed, parsed),
944
969
  status: 200
945
970
  };
946
971
  }
@@ -958,11 +983,7 @@ var BaseController = class {
958
983
  status: 400
959
984
  };
960
985
  const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
961
- if (!existing) return {
962
- success: false,
963
- error: "Resource not found",
964
- status: 404
965
- };
986
+ if (!existing) return this.notFoundResponse("NOT_FOUND");
966
987
  if (!this.accessControl.checkOwnership(existing, req)) return {
967
988
  success: false,
968
989
  error: "You do not have permission to restore this resource",
@@ -998,11 +1019,7 @@ var BaseController = class {
998
1019
  meta: { id }
999
1020
  });
1000
1021
  else item = await repoRestore();
1001
- if (!item) return {
1002
- success: false,
1003
- error: "Resource not found",
1004
- status: 404
1005
- };
1022
+ if (!item) return this.notFoundResponse("NOT_FOUND");
1006
1023
  if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "restore", item, {
1007
1024
  user,
1008
1025
  context: arcContext,
@@ -1052,13 +1069,15 @@ var BaseController = class {
1052
1069
  error: "Repository does not support createMany",
1053
1070
  status: 501
1054
1071
  };
1055
- const items = req.body?.items;
1056
- if (!items || items.length === 0) return {
1072
+ const rawItems = req.body?.items;
1073
+ if (!Array.isArray(rawItems) || rawItems.length === 0) return {
1057
1074
  success: false,
1058
1075
  error: "Bulk create requires a non-empty items array",
1059
1076
  status: 400
1060
1077
  };
1078
+ const items = rawItems;
1061
1079
  const arcContext = this.meta(req);
1080
+ const user = req.user;
1062
1081
  const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
1063
1082
  let scopedItems = sanitizedItems;
1064
1083
  if (this.tenantField) {
@@ -1086,7 +1105,10 @@ var BaseController = class {
1086
1105
  }
1087
1106
  }
1088
1107
  }
1089
- const created = await repo.createMany(scopedItems);
1108
+ const created = await repo.createMany(scopedItems, {
1109
+ user,
1110
+ context: arcContext
1111
+ });
1090
1112
  const requested = items.length;
1091
1113
  const inserted = created.length;
1092
1114
  const skipped = requested - inserted;
@@ -1157,13 +1179,23 @@ var BaseController = class {
1157
1179
  */
1158
1180
  sanitizeBulkUpdateData(data, req, arcContext) {
1159
1181
  const stripped = /* @__PURE__ */ new Set();
1160
- if (!Object.keys(data).some((k) => k.startsWith("$"))) {
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) {
1161
1192
  const before = new Set(Object.keys(data));
1162
1193
  const sanitized = this.bodySanitizer.sanitize(data, "update", req, arcContext);
1163
1194
  for (const key of before) if (!(key in sanitized)) stripped.add(key);
1164
1195
  return {
1165
1196
  sanitized,
1166
- stripped: [...stripped]
1197
+ stripped: [...stripped],
1198
+ mixedShape: false
1167
1199
  };
1168
1200
  }
1169
1201
  const sanitized = {};
@@ -1180,7 +1212,8 @@ var BaseController = class {
1180
1212
  }
1181
1213
  return {
1182
1214
  sanitized,
1183
- stripped: [...stripped]
1215
+ stripped: [...stripped],
1216
+ mixedShape: false
1184
1217
  };
1185
1218
  }
1186
1219
  async bulkUpdate(req) {
@@ -1209,7 +1242,14 @@ var BaseController = class {
1209
1242
  status: 403
1210
1243
  };
1211
1244
  const arcContext = this.meta(req);
1212
- const { sanitized, stripped } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
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
+ };
1213
1253
  if (Object.keys(sanitized).length === 0) return {
1214
1254
  success: false,
1215
1255
  error: "Bulk update payload contained only protected fields",
@@ -1221,7 +1261,10 @@ var BaseController = class {
1221
1261
  };
1222
1262
  return {
1223
1263
  success: true,
1224
- data: await repo.updateMany(scopedFilter, sanitized),
1264
+ data: await repo.updateMany(scopedFilter, sanitized, {
1265
+ user,
1266
+ context: arcContext
1267
+ }),
1225
1268
  status: 200,
1226
1269
  ...stripped.length > 0 && { meta: { stripped } }
1227
1270
  };
@@ -1272,9 +1315,16 @@ var BaseController = class {
1272
1315
  details: { code: "ORG_CONTEXT_REQUIRED" },
1273
1316
  status: 403
1274
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";
1275
1325
  return {
1276
1326
  success: true,
1277
- data: req.query?.hard === "true" || req.query?.hard === true || body.mode === "hard" ? await repo.deleteMany(scopedFilter, { mode: "hard" }) : await repo.deleteMany(scopedFilter),
1327
+ data: await repo.deleteMany(scopedFilter, options),
1278
1328
  status: 200
1279
1329
  };
1280
1330
  }