@classytic/arc 2.8.5 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +88 -5
  2. package/dist/{BaseController-DAGGc5Xn.mjs → BaseController-Vu2yc56T.mjs} +188 -102
  3. package/dist/EventTransport-CqZ8FyM_.d.mts +293 -0
  4. package/dist/adapters/index.d.mts +2 -2
  5. package/dist/audit/index.d.mts +100 -11
  6. package/dist/audit/index.mjs +71 -18
  7. package/dist/auth/index.d.mts +16 -8
  8. package/dist/auth/index.mjs +13 -6
  9. package/dist/auth/redis-session.d.mts +1 -1
  10. package/dist/{betterAuthOpenApi-BuUcUEJq.mjs → betterAuthOpenApi--rdY15Ld.mjs} +1 -1
  11. package/dist/cache/index.d.mts +2 -2
  12. package/dist/cache/index.mjs +2 -2
  13. package/dist/cli/commands/docs.mjs +2 -2
  14. package/dist/cli/commands/introspect.mjs +1 -1
  15. package/dist/core/index.d.mts +3 -3
  16. package/dist/core/index.mjs +4 -5
  17. package/dist/{core-F0QoWBt2.mjs → core-DNncu0xF.mjs} +1 -1
  18. package/dist/{createActionRouter-BORM8f17.mjs → createActionRouter-DH1YFL9m.mjs} +3 -3
  19. package/dist/{createApp-B1EY8zxa.mjs → createApp-CBJUJKGP.mjs} +13 -12
  20. package/dist/{defineResource-tcgySDo1.mjs → defineResource-C__jkwvs.mjs} +22 -57
  21. package/dist/docs/index.d.mts +2 -2
  22. package/dist/docs/index.mjs +1 -1
  23. package/dist/dynamic/index.d.mts +1 -1
  24. package/dist/dynamic/index.mjs +3 -3
  25. package/dist/{elevation-DtFxrG0s.mjs → elevation-DxQ6ACbt.mjs} +21 -7
  26. package/dist/{errorHandler-f869_8PQ.mjs → errorHandler-CZDW4EXS.mjs} +59 -7
  27. package/dist/{errorHandler-Bah5JhBd.d.mts → errorHandler-DixGcttC.d.mts} +37 -2
  28. package/dist/{eventPlugin-D9DKB2zM.d.mts → eventPlugin-BxvaCIZF.d.mts} +14 -2
  29. package/dist/{eventPlugin-CDjVTM82.mjs → eventPlugin-Dl7MoVWH.mjs} +83 -5
  30. package/dist/events/index.d.mts +147 -36
  31. package/dist/events/index.mjs +338 -101
  32. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  33. package/dist/events/transports/redis.d.mts +1 -1
  34. package/dist/factory/index.d.mts +1 -1
  35. package/dist/factory/index.mjs +2 -2
  36. package/dist/{fields-DpZQa_Q3.d.mts → fields-BC7zcmI9.d.mts} +15 -3
  37. package/dist/{fields-ipsbIRPK.mjs → fields-CU6FlaDV.mjs} +18 -5
  38. package/dist/{filesUpload-C7r7HIeA.mjs → filesUpload-q8oHt--L.mjs} +65 -7
  39. package/dist/hooks/index.d.mts +1 -1
  40. package/dist/hooks/index.mjs +1 -1
  41. package/dist/idempotency/index.d.mts +29 -5
  42. package/dist/idempotency/index.mjs +111 -2
  43. package/dist/idempotency/redis.d.mts +1 -1
  44. package/dist/{index-BLXBmWud.d.mts → index-C-xjcA6F.d.mts} +1 -1
  45. package/dist/{index-DtDzOBn8.d.mts → index-Cibkchnx.d.mts} +3 -134
  46. package/dist/{index-C1meYuDn.d.mts → index-CtGKT0lf.d.mts} +1 -1
  47. package/dist/index.d.mts +7 -7
  48. package/dist/index.mjs +9 -9
  49. package/dist/integrations/event-gateway.d.mts +1 -1
  50. package/dist/integrations/event-gateway.mjs +1 -1
  51. package/dist/integrations/index.d.mts +1 -1
  52. package/dist/integrations/mcp/index.d.mts +26 -8
  53. package/dist/integrations/mcp/index.mjs +96 -17
  54. package/dist/integrations/mcp/testing.d.mts +1 -1
  55. package/dist/integrations/mcp/testing.mjs +1 -1
  56. package/dist/integrations/webhooks.d.mts +5 -0
  57. package/dist/integrations/webhooks.mjs +6 -0
  58. package/dist/{interface-CMRutPfe.d.mts → interface-YrWsmKqE.d.mts} +287 -179
  59. package/dist/{openapi-CbKUJY_m.mjs → openapi-CXuTG1M9.mjs} +2 -2
  60. package/dist/org/index.d.mts +1 -1
  61. package/dist/permissions/index.d.mts +2 -2
  62. package/dist/permissions/index.mjs +3 -3
  63. package/dist/{permissions-CH4cNwJi.mjs → permissions-oNZawnkR.mjs} +1 -1
  64. package/dist/plugins/index.d.mts +7 -7
  65. package/dist/plugins/index.mjs +11 -11
  66. package/dist/plugins/response-cache.mjs +1 -1
  67. package/dist/plugins/tracing-entry.d.mts +1 -1
  68. package/dist/plugins/tracing-entry.mjs +1 -1
  69. package/dist/policies/index.d.mts +25 -32
  70. package/dist/presets/filesUpload.d.mts +26 -4
  71. package/dist/presets/filesUpload.mjs +1 -1
  72. package/dist/presets/index.d.mts +3 -2
  73. package/dist/presets/index.mjs +4 -3
  74. package/dist/presets/multiTenant.d.mts +1 -1
  75. package/dist/presets/multiTenant.mjs +1 -1
  76. package/dist/presets/search.d.mts +91 -0
  77. package/dist/presets/search.mjs +150 -0
  78. package/dist/{presets-C2xgzW6x.mjs → presets-hM4WhNWY.mjs} +1 -1
  79. package/dist/{queryCachePlugin-BJJGBTlu.d.mts → queryCachePlugin-CnTZZTC5.d.mts} +1 -1
  80. package/dist/{queryCachePlugin-BH-fidlv.mjs → queryCachePlugin-DbUVroUG.mjs} +2 -2
  81. package/dist/{redis-BM00zaPB.d.mts → redis-MXLp1oOf.d.mts} +1 -1
  82. package/dist/{redis-stream-CrsfUmPt.d.mts → redis-stream-Bz-4q96t.d.mts} +1 -1
  83. package/dist/registry/index.d.mts +1 -1
  84. package/dist/registry/index.mjs +2 -2
  85. package/dist/{resourceToTools-8s-EsCCe.mjs → resourceToTools-C3cWymnW.mjs} +64 -47
  86. package/dist/rpc/index.d.mts +1 -1
  87. package/dist/rpc/index.mjs +1 -1
  88. package/dist/{schemaConverter-Y7nCYaLJ.mjs → schemaConverter-BxFDdtXu.mjs} +1 -1
  89. package/dist/scope/index.mjs +1 -1
  90. package/dist/{sse-Ad7ypl9e.mjs → sse-CJpt7LGI.mjs} +1 -1
  91. package/dist/store-helpers-DFiZl5TL.mjs +57 -0
  92. package/dist/testing/index.d.mts +5 -14
  93. package/dist/testing/index.mjs +21 -75
  94. package/dist/testing/storageContract.d.mts +1 -1
  95. package/dist/types/index.d.mts +2 -2
  96. package/dist/types/storage.d.mts +1 -1
  97. package/dist/{types-BsbNMEDR.d.mts → types-CoSzA-s-.d.mts} +1 -1
  98. package/dist/{types-Ch9pTQbf.d.mts → types-CunEX4UX.d.mts} +10 -8
  99. package/dist/utils/index.d.mts +4 -4
  100. package/dist/utils/index.mjs +6 -6
  101. package/dist/{utils-yYT3HDXt.mjs → utils-B7FuRr9w.mjs} +1 -1
  102. package/package.json +8 -11
  103. package/skills/arc/SKILL.md +92 -14
  104. package/skills/arc/references/auth.md +94 -0
  105. package/skills/arc/references/events.md +200 -12
  106. package/skills/arc/references/mcp.md +4 -17
  107. package/skills/arc/references/multi-tenancy.md +43 -0
  108. package/skills/arc/references/production.md +34 -19
  109. package/dist/EventTransport-BXja8NOc.d.mts +0 -135
  110. package/dist/audit/mongodb.d.mts +0 -2
  111. package/dist/audit/mongodb.mjs +0 -2
  112. package/dist/idempotency/mongodb.d.mts +0 -2
  113. package/dist/idempotency/mongodb.mjs +0 -123
  114. package/dist/mongodb-BsP-WbhN.d.mts +0 -127
  115. package/dist/mongodb-CTcp0hQZ.d.mts +0 -80
  116. package/dist/mongodb-Utc5k_-0.mjs +0 -90
  117. /package/dist/{HookSystem-HprTmvVY.mjs → HookSystem-BjFu7zf1.mjs} +0 -0
  118. /package/dist/{ResourceRegistry-C6uXlWe3.mjs → ResourceRegistry-Dq3_zBQP.mjs} +0 -0
  119. /package/dist/{applyPermissionResult-D6GPMsvh.mjs → applyPermissionResult-bqGpo9ML.mjs} +0 -0
  120. /package/dist/{caching-IMuYVjTL.mjs → caching-CjybdRwx.mjs} +0 -0
  121. /package/dist/{circuitBreaker-dTtG-UyS.d.mts → circuitBreaker-CvXkjfrW.d.mts} +0 -0
  122. /package/dist/{circuitBreaker-cmi5XDv5.mjs → circuitBreaker-l18oRgL5.mjs} +0 -0
  123. /package/dist/{errors-Ck2h67pm.d.mts → errors-BI8kEKsO.d.mts} +0 -0
  124. /package/dist/{errors-BF2bIOIS.mjs → errors-CqWnSqM-.mjs} +0 -0
  125. /package/dist/{externalPaths-BnkYrNzp.d.mts → externalPaths-Bapitwvd.d.mts} +0 -0
  126. /package/dist/{interface-DfLGcus7.d.mts → interface-B-pe8fhj.d.mts} +0 -0
  127. /package/dist/{interface-4y979v99.d.mts → interface-DplgQO2e.d.mts} +0 -0
  128. /package/dist/{loadResources-PWd0OCpV.mjs → loadResources-Bksk8ydA.mjs} +0 -0
  129. /package/dist/{logger-D1YrIImS.mjs → logger-CDjpjySd.mjs} +0 -0
  130. /package/dist/{memory-Cp7_cAko.mjs → memory-BFAYkf8H.mjs} +0 -0
  131. /package/dist/{metrics-B-PU4-Yu.mjs → metrics-TuOmguhi.mjs} +0 -0
  132. /package/dist/{queryParser-CgCtsjti.mjs → queryParser-Cs-6SHQK.mjs} +0 -0
  133. /package/dist/{registry-BiTKT1Dg.mjs → registry-B0Wl7uVV.mjs} +0 -0
  134. /package/dist/{replyHelpers-CxkYGT81.mjs → replyHelpers-BLojtuvR.mjs} +0 -0
  135. /package/dist/{requestContext-DYvHl113.mjs → requestContext-DYtmNpm5.mjs} +0 -0
  136. /package/dist/{sessionManager-DDCmiNIo.d.mts → sessionManager-D-oNWHz3.d.mts} +0 -0
  137. /package/dist/{storage-Dfzt4VTl.d.mts → storage-BwGQXUpd.d.mts} +0 -0
  138. /package/dist/{tracing-DdN2-wHJ.d.mts → tracing-xqXzWeaf.d.mts} +0 -0
  139. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-Cj5Rgvlg.mjs} +0 -0
  140. /package/dist/{versioning-CDugduqI.mjs → versioning-Cm8qoFDg.mjs} +0 -0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Database-agnostic resource framework for Fastify. Define resources, get CRUD routes, permissions, presets, caching, events, OpenAPI, and MCP tools — without boilerplate.
4
4
 
5
- **v2.8.4** | Fastify 5+ | Node.js 22+ | ESM only | 279+ test files, 3867+ tests
5
+ **v2.9** | Fastify 5+ | Node.js 22+ | ESM only
6
6
 
7
7
  ## Install
8
8
 
@@ -65,8 +65,13 @@ const app = await createApp({
65
65
  Clean DX without growing exclude lists:
66
66
 
67
67
  ```typescript
68
- // app.ts register once with perResource mode
69
- await fastify.register(auditPlugin, { autoAudit: { perResource: true } });
68
+ import { Repository } from '@classytic/mongokit';
69
+
70
+ // app.ts — pass any RepositoryLike (mongokit / prismakit / custom)
71
+ await fastify.register(auditPlugin, {
72
+ autoAudit: { perResource: true },
73
+ repository: new Repository(AuditModel), // or omit for in-memory dev
74
+ });
70
75
 
71
76
  // order.resource.ts — opt in
72
77
  defineResource({ name: 'order', audit: true });
@@ -99,8 +104,8 @@ const productResource = defineResource({
99
104
  delete: roles('admin'),
100
105
  },
101
106
  cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] }, // QueryCache (opt-in)
102
- additionalRoutes: [
103
- { method: 'GET', path: '/featured', handler: 'getFeatured', permissions: allowPublic(), wrapHandler: true },
107
+ routes: [
108
+ { method: 'GET', path: '/featured', handler: 'getFeatured', permissions: allowPublic() },
104
109
  ],
105
110
  });
106
111
 
@@ -383,6 +388,29 @@ await app.events.subscribe('order.*', async (event) => { ... });
383
388
 
384
389
  CRUD events (`product.created`, `product.updated`, `product.deleted`) emit automatically.
385
390
 
391
+ ### Causation Chains & DLQ (v2.9)
392
+
393
+ ```typescript
394
+ import { createEvent, createChildEvent, type DeadLetteredEvent } from '@classytic/arc/events';
395
+
396
+ const placed = createEvent('order.placed', { orderId: 'o1' }, {
397
+ correlationId: req.id, userId: user.id,
398
+ });
399
+ await app.events.publish(placed.type, placed.payload, placed.meta);
400
+
401
+ // Downstream handler emits a child — correlation inherited, causation linked:
402
+ const reserved = createChildEvent(placed, 'inventory.reserved', { sku: 'a' });
403
+ // reserved.meta.correlationId === placed.meta.correlationId (stays stable across chain)
404
+ // reserved.meta.causationId === placed.meta.id (direct parent)
405
+
406
+ // Transports with native DLQ (Kafka, SQS) implement optional deadLetter():
407
+ class KafkaTransport implements EventTransport {
408
+ async deadLetter(dlq: DeadLetteredEvent) { /* route to .DLQ topic */ }
409
+ }
410
+ ```
411
+
412
+ `EventMeta` also accepts `schemaVersion` (evolve event payloads) and `partitionKey` (ordered delivery hint for Kafka/Kinesis).
413
+
386
414
  ### defineEvent — Typed Events with Schema Validation
387
415
 
388
416
  Declare events with schemas for runtime validation and introspection:
@@ -722,6 +750,61 @@ Arc sets `"sideEffects": false` in [package.json](package.json), so modern bundl
722
750
  | `@classytic/arc/docs` | OpenAPI generation |
723
751
  | `@classytic/arc/cli` | CLI commands (programmatic) |
724
752
 
753
+ ## v2.9.1 Highlights
754
+
755
+ `auditPlugin`, `idempotencyPlugin`, and `EventOutbox` take a
756
+ `repository: RepositoryLike` option — pass any `Repository` (mongokit /
757
+ prismakit / your own kit). Arc calls `create` / `getOne` / `findAll` /
758
+ `deleteMany` / `findOneAndUpdate` on it directly:
759
+
760
+ ```typescript
761
+ import { Repository } from '@classytic/mongokit';
762
+ import { auditPlugin } from '@classytic/arc/audit';
763
+ import { idempotencyPlugin } from '@classytic/arc/idempotency';
764
+ import { EventOutbox } from '@classytic/arc/events';
765
+
766
+ await fastify.register(auditPlugin, { repository: new Repository(AuditModel) });
767
+ await fastify.register(idempotencyPlugin, {
768
+ repository: new Repository(IdempotencyModel, [
769
+ methodRegistryPlugin(), batchOperationsPlugin(), mongoOperationsPlugin(),
770
+ ]),
771
+ });
772
+ new EventOutbox({ repository: new Repository(OutboxModel), transport });
773
+ ```
774
+
775
+ Memory + Redis stores unchanged via `store` / `customStores`. Requires
776
+ `@classytic/mongokit ≥3.8.0`.
777
+
778
+ ## v2.9 Highlights
779
+
780
+ **Security defaults (breaking):**
781
+ - **Field-write perms reject by default** — requests with non-writable fields return 403 with denied-field list. Opt into legacy silent-strip via `defineResource({ onFieldWriteDenied: 'strip' })`.
782
+ - **multiTenant preset injects org on UPDATE** — body-supplied `organizationId` is overwritten with caller's scope (prior: CREATE only; a member could hop their own doc to another tenant).
783
+ - **Elevation always emits `arc.scope.elevated`** — privilege elevation can no longer be silently un-audited. Subscribe via `fastify.events.subscribe('arc.scope.elevated', …)`. `onElevation` callback still supported.
784
+ - **`verifySignature` throws on parsed body** — prevents the common `req.body` vs `req.rawBody` footgun that looks like a wrong secret.
785
+ - **Upload `sanitizeFilename` policy** — rejects path separators, NUL, `.`/`..`, >255 chars by default. Flexible: pass `false` / `'*'` / custom function to override.
786
+ - **Idempotency `namespace`** — optional key folded into fingerprint for shared-store deployments (prod + canary on one Redis).
787
+
788
+ **Event contract v2 (additive):**
789
+ - `EventMeta` gets `schemaVersion`, `causationId`, `partitionKey`, `source`, `idempotencyKey`, `aggregate: { type, id }`
790
+ - `createChildEvent(parent, type, payload)` — auto-chains causation + inherits correlation / userId / organizationId / source / idempotencyKey (aggregate stays explicit)
791
+ - `DeadLetteredEvent<T>` type + optional `transport.deadLetter()` for first-class DLQ
792
+ - `withRetry({ transport })` auto-routes exhausted events to `transport.deadLetter()` — no custom `$deadLetter` plumbing
793
+ - Downstream packages narrow `aggregate.type` to a closed DDD union via interface extension
794
+
795
+ **Outbox v2 (additive):**
796
+ - `EventOutbox.store()` auto-maps `event.meta.idempotencyKey` → `OutboxWriteOptions.dedupeKey` (caller's explicit `dedupeKey` still wins)
797
+ - `new EventOutbox({ failurePolicy: ({ attempts, error }) => ({ retryAt?, deadLetter? }) })` — centralises retry + DLQ escalation, no more hand-rolled `exponentialBackoff` at every failure site
798
+ - `outbox.getDeadLettered(limit)` returns typed `DeadLetteredEvent[]` — same shape `withRetry` produces, closes the loop between retry DLQ and outbox DLQ state
799
+ - `RelayResult.deadLettered` — per-batch DLQ transition count for dashboards
800
+ - Durable outbox via any `RepositoryLike` — `new EventOutbox({ repository, transport })` (2.9.1); multi-worker claim, TTL purge, dedupe, and session-threaded writes come from the repository's backing kit
801
+
802
+ **Removed (no replacement kept):**
803
+ - `createActionRouter`, `buildActionBodySchema` — use `defineResource({ actions })`
804
+ - `ResourceConfig.onRegister` — use `actions` or resource `hooks`
805
+ - `PluginResourceResult.additionalRoutes` → `routes: RouteDefinition[]`
806
+ - `PolicyContext` / `PolicyResult` `any` fields → `unknown` (tighten downstream narrowing)
807
+
725
808
  ## v2.8.4 Highlights
726
809
 
727
810
  - **MCP ↔ AI SDK bridge** — expose AI SDK `tool()` definitions over MCP without duplicating code. `bridgeToMcp(bridge)` adapts any AI SDK tool into an MCP tool with automatic auth, guard delegation, and `{ error } → isError` envelope translation. `buildMcpToolsFromBridges(bridges, { include, exclude })` registers a whole catalog at once with per-environment filtering.
@@ -2,9 +2,10 @@ import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-Cxde4
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
4
  import { getUserId } from "./types/index.mjs";
5
- import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-ipsbIRPK.mjs";
5
+ import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-CU6FlaDV.mjs";
6
6
  import { t as getUserRoles } from "./types-ZUu_h0jp.mjs";
7
- import { t as ArcQueryParser } from "./queryParser-CgCtsjti.mjs";
7
+ import { r as ForbiddenError } from "./errors-CqWnSqM-.mjs";
8
+ import { t as ArcQueryParser } from "./queryParser-Cs-6SHQK.mjs";
8
9
  //#region src/core/AccessControl.ts
9
10
  /**
10
11
  * AccessControl - Composable access control logic extracted from BaseController.
@@ -95,20 +96,82 @@ var AccessControl = class AccessControl {
95
96
  * buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
96
97
  */
97
98
  async fetchWithAccessControl(id, req, repository, queryOptions) {
99
+ return (await this.fetchDetailed(id, req, repository, queryOptions)).doc;
100
+ }
101
+ /**
102
+ * Same as `fetchWithAccessControl` but returns a structured result with
103
+ * a denial reason so callers can distinguish "doc doesn't exist" from
104
+ * "doc exists but was filtered by policy/org scope" from "repo threw".
105
+ *
106
+ * Codes:
107
+ * - `null` — doc was found, no denial
108
+ * - `'NOT_FOUND'` — doc genuinely doesn't exist in the DB
109
+ * - `'POLICY_FILTERED'` — doc exists but the request's policy filters exclude it
110
+ * - `'ORG_SCOPE_DENIED'` — doc exists but the caller's org context doesn't match
111
+ * - `'REPO_ERROR'` — the repository threw a "not found" error (mongokit style)
112
+ */
113
+ async fetchDetailed(id, req, repository, queryOptions) {
98
114
  const compoundFilter = this.buildIdFilter(id, req);
99
- const needsCompoundLookup = Object.keys(compoundFilter).length > 1 || this.idField !== "_id";
115
+ const hasCompoundFilters = Object.keys(compoundFilter).length > 1;
116
+ const needsCompoundLookup = hasCompoundFilters || this.idField !== "_id";
117
+ const translateStatus404 = (error) => {
118
+ if (error && typeof error === "object" && error.status === 404) return {
119
+ doc: null,
120
+ reason: "NOT_FOUND"
121
+ };
122
+ return null;
123
+ };
100
124
  try {
101
- if (needsCompoundLookup && typeof repository.getOne === "function") return await repository.getOne(compoundFilter, queryOptions);
125
+ if (needsCompoundLookup && typeof repository.getOne === "function") {
126
+ const doc = await repository.getOne(compoundFilter, queryOptions);
127
+ if (doc) return {
128
+ doc,
129
+ reason: null
130
+ };
131
+ if (hasCompoundFilters) {
132
+ const idOnly = { [this.idField]: id };
133
+ const rawDoc = await repository.getOne(idOnly);
134
+ if (rawDoc) {
135
+ const arcContext = this._meta(req);
136
+ if (!this.checkOrgScope(rawDoc, arcContext)) return {
137
+ doc: null,
138
+ reason: "ORG_SCOPE_DENIED"
139
+ };
140
+ return {
141
+ doc: null,
142
+ reason: "POLICY_FILTERED"
143
+ };
144
+ }
145
+ }
146
+ return {
147
+ doc: null,
148
+ reason: "NOT_FOUND"
149
+ };
150
+ }
102
151
  if (this.idField !== "_id") {
103
152
  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
153
  }
105
154
  const item = await repository.getById(id, queryOptions);
106
- if (!item) return null;
155
+ if (!item) return {
156
+ doc: null,
157
+ reason: "NOT_FOUND"
158
+ };
107
159
  const arcContext = this._meta(req);
108
- if (!this.checkOrgScope(item, arcContext) || !this.checkPolicyFilters(item, req)) return null;
109
- return item;
160
+ if (!this.checkOrgScope(item, arcContext)) return {
161
+ doc: null,
162
+ reason: "ORG_SCOPE_DENIED"
163
+ };
164
+ if (!this.checkPolicyFilters(item, req)) return {
165
+ doc: null,
166
+ reason: "POLICY_FILTERED"
167
+ };
168
+ return {
169
+ doc: item,
170
+ reason: null
171
+ };
110
172
  } catch (error) {
111
- if (error instanceof Error && error.message?.includes("not found")) return null;
173
+ const translated = translateStatus404(error);
174
+ if (translated) return translated;
112
175
  throw error;
113
176
  }
114
177
  }
@@ -224,20 +287,12 @@ var AccessControl = class AccessControl {
224
287
  }
225
288
  }
226
289
  };
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
290
  var BodySanitizer = class {
238
291
  schemaOptions;
292
+ onFieldWriteDenied;
239
293
  constructor(config) {
240
294
  this.schemaOptions = config.schemaOptions;
295
+ this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
241
296
  }
242
297
  /**
243
298
  * Strip readonly and system-managed fields from request body.
@@ -261,7 +316,9 @@ var BodySanitizer = class {
261
316
  const fieldPerms = arcContext?.arc?.fields;
262
317
  if (fieldPerms) {
263
318
  const effectiveRoles = resolveEffectiveRoles(getUserRoles(req.user), isMember(scope) ? scope.orgRoles : []);
264
- sanitized = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
319
+ const { body: filtered, deniedFields } = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
320
+ if (deniedFields.length > 0 && this.onFieldWriteDenied === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
321
+ sanitized = filtered;
265
322
  }
266
323
  }
267
324
  }
@@ -404,6 +461,12 @@ var QueryResolver = class {
404
461
  //#endregion
405
462
  //#region src/core/BaseController.ts
406
463
  /**
464
+ * Portable "run on next tick" scheduler. `setImmediate` is Node-only — not
465
+ * available in Bun workers, Deno, Cloudflare Workers, or edge runtimes. Fall
466
+ * back to queueMicrotask (universal) when setImmediate is absent.
467
+ */
468
+ const scheduleBackground = typeof setImmediate === "function" ? (cb) => void setImmediate(cb) : (cb) => queueMicrotask(cb);
469
+ /**
407
470
  * Framework-agnostic base controller implementing IController.
408
471
  *
409
472
  * Composes AccessControl, BodySanitizer, and QueryResolver for clean
@@ -450,7 +513,10 @@ var BaseController = class {
450
513
  idField: this.idField,
451
514
  matchesFilter: this._matchesFilter
452
515
  });
453
- this.bodySanitizer = new BodySanitizer({ schemaOptions: this.schemaOptions });
516
+ this.bodySanitizer = new BodySanitizer({
517
+ schemaOptions: this.schemaOptions,
518
+ onFieldWriteDenied: options.onFieldWriteDenied
519
+ });
454
520
  this.queryResolver = new QueryResolver({
455
521
  queryParser: this.queryParser,
456
522
  maxLimit: this.maxLimit,
@@ -506,6 +572,28 @@ var BaseController = class {
506
572
  if (repoIdField && repoIdField === this.idField) return id;
507
573
  return String(existing["_id"] ?? id);
508
574
  }
575
+ /**
576
+ * Centralized 404 response builder. Maps the denial reason from
577
+ * `fetchDetailed()` into a structured `details.code` so consumers can
578
+ * programmatically distinguish "doc doesn't exist" from "doc filtered
579
+ * by policy/org scope" without parsing error strings.
580
+ *
581
+ * Error messages are intentionally vague in the `error` field (don't
582
+ * leak whether the doc exists) — the detail is in `details.code` only.
583
+ */
584
+ notFoundResponse(reason = "NOT_FOUND") {
585
+ const code = reason ?? "NOT_FOUND";
586
+ return {
587
+ success: false,
588
+ error: {
589
+ NOT_FOUND: "Resource not found",
590
+ POLICY_FILTERED: "Resource not found",
591
+ ORG_SCOPE_DENIED: "Resource not found"
592
+ }[code] ?? "Resource not found",
593
+ status: 404,
594
+ details: { code }
595
+ };
596
+ }
509
597
  /** Resolve cache config for a specific operation, merging per-op overrides */
510
598
  resolveCacheConfig(operation) {
511
599
  const cfg = this._cacheConfig;
@@ -547,7 +635,7 @@ var BaseController = class {
547
635
  headers: { "x-cache": "HIT" }
548
636
  };
549
637
  if (status === "stale") {
550
- setImmediate(() => {
638
+ scheduleBackground(() => {
551
639
  this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
552
640
  });
553
641
  return {
@@ -616,8 +704,8 @@ var BaseController = class {
616
704
  headers: { "x-cache": "HIT" }
617
705
  };
618
706
  if (status === "stale") {
619
- setImmediate(() => {
620
- this.executeGetQuery(id, options, req).then((fresh) => {
707
+ scheduleBackground(() => {
708
+ this.executeGetQuery(id, options, req).then(({ doc: fresh }) => {
621
709
  if (fresh) qc.set(key, fresh, cacheConfig);
622
710
  }).catch(() => {});
623
711
  });
@@ -628,49 +716,42 @@ var BaseController = class {
628
716
  headers: { "x-cache": "STALE" }
629
717
  };
630
718
  }
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);
719
+ const { doc: cached, reason: cacheReason } = await this.executeGetQuery(id, options, req);
720
+ if (!cached) return this.notFoundResponse(cacheReason);
721
+ await qc.set(key, cached, cacheConfig);
638
722
  return {
639
723
  success: true,
640
- data: item,
724
+ data: cached,
641
725
  status: 200,
642
726
  headers: { "x-cache": "MISS" }
643
727
  };
644
728
  }
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
- }
729
+ const { doc, reason } = await this.executeGetQuery(id, options, req);
730
+ if (!doc) return this.notFoundResponse(reason);
731
+ return {
732
+ success: true,
733
+ data: doc,
734
+ status: 200
735
+ };
665
736
  }
666
737
  /** Execute get query through hooks (extracted for cache revalidation) */
667
738
  async executeGetQuery(id, options, req) {
668
739
  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;
740
+ const fetchItem = async () => {
741
+ return await this.accessControl.fetchDetailed(id, req, this.repository, options);
742
+ };
743
+ if (hooks && this.resourceName) {
744
+ const result = await fetchItem();
745
+ if (!result.doc) return result;
746
+ return {
747
+ doc: await hooks.executeAround(this.resourceName, "read", null, async () => result.doc, {
748
+ user: req.user,
749
+ context: this.meta(req)
750
+ }) ?? null,
751
+ reason: null
752
+ };
753
+ }
754
+ return fetchItem();
674
755
  }
675
756
  async create(req) {
676
757
  const arcContext = this.meta(req);
@@ -733,12 +814,8 @@ var BaseController = class {
733
814
  const user = req.user;
734
815
  const userId = getUserId(user);
735
816
  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
- };
817
+ const { doc: existing, reason: updateReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
818
+ if (!existing) return this.notFoundResponse(updateReason);
742
819
  if (!this.accessControl.checkOwnership(existing, req)) return {
743
820
  success: false,
744
821
  error: "You do not have permission to modify this resource",
@@ -791,11 +868,7 @@ var BaseController = class {
791
868
  }
792
869
  });
793
870
  } else item = await repoUpdate();
794
- if (!item) return {
795
- success: false,
796
- error: "Resource not found",
797
- status: 404
798
- };
871
+ if (!item) return this.notFoundResponse("NOT_FOUND");
799
872
  return {
800
873
  success: true,
801
874
  data: item,
@@ -812,12 +885,8 @@ var BaseController = class {
812
885
  };
813
886
  const arcContext = this.meta(req);
814
887
  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
- };
888
+ const { doc: existing, reason: deleteReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
889
+ if (!existing) return this.notFoundResponse(deleteReason);
821
890
  if (!this.accessControl.checkOwnership(existing, req)) return {
822
891
  success: false,
823
892
  error: "You do not have permission to delete this resource",
@@ -862,11 +931,7 @@ var BaseController = class {
862
931
  if (typeof r.success === "boolean") return r.success;
863
932
  if (typeof r.deletedCount === "number") return r.deletedCount > 0;
864
933
  return true;
865
- })()) return {
866
- success: false,
867
- error: "Resource not found",
868
- status: 404
869
- };
934
+ })()) return this.notFoundResponse("NOT_FOUND");
870
935
  if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "delete", existing, {
871
936
  user,
872
937
  context: arcContext,
@@ -901,11 +966,7 @@ var BaseController = class {
901
966
  error: "Slug lookup not implemented — repository needs getBySlug() or getOne()",
902
967
  status: 501
903
968
  };
904
- if (!this.accessControl.validateItemAccess(item, req)) return {
905
- success: false,
906
- error: "Resource not found",
907
- status: 404
908
- };
969
+ if (!this.accessControl.validateItemAccess(item, req)) return this.notFoundResponse(item ? "POLICY_FILTERED" : "NOT_FOUND");
909
970
  return {
910
971
  success: true,
911
972
  data: item,
@@ -958,11 +1019,7 @@ var BaseController = class {
958
1019
  status: 400
959
1020
  };
960
1021
  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
- };
1022
+ if (!existing) return this.notFoundResponse("NOT_FOUND");
966
1023
  if (!this.accessControl.checkOwnership(existing, req)) return {
967
1024
  success: false,
968
1025
  error: "You do not have permission to restore this resource",
@@ -998,11 +1055,7 @@ var BaseController = class {
998
1055
  meta: { id }
999
1056
  });
1000
1057
  else item = await repoRestore();
1001
- if (!item) return {
1002
- success: false,
1003
- error: "Resource not found",
1004
- status: 404
1005
- };
1058
+ if (!item) return this.notFoundResponse("NOT_FOUND");
1006
1059
  if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "restore", item, {
1007
1060
  user,
1008
1061
  context: arcContext,
@@ -1052,13 +1105,15 @@ var BaseController = class {
1052
1105
  error: "Repository does not support createMany",
1053
1106
  status: 501
1054
1107
  };
1055
- const items = req.body?.items;
1056
- if (!items || items.length === 0) return {
1108
+ const rawItems = req.body?.items;
1109
+ if (!Array.isArray(rawItems) || rawItems.length === 0) return {
1057
1110
  success: false,
1058
1111
  error: "Bulk create requires a non-empty items array",
1059
1112
  status: 400
1060
1113
  };
1114
+ const items = rawItems;
1061
1115
  const arcContext = this.meta(req);
1116
+ const user = req.user;
1062
1117
  const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
1063
1118
  let scopedItems = sanitizedItems;
1064
1119
  if (this.tenantField) {
@@ -1086,7 +1141,10 @@ var BaseController = class {
1086
1141
  }
1087
1142
  }
1088
1143
  }
1089
- const created = await repo.createMany(scopedItems);
1144
+ const created = await repo.createMany(scopedItems, {
1145
+ user,
1146
+ context: arcContext
1147
+ });
1090
1148
  const requested = items.length;
1091
1149
  const inserted = created.length;
1092
1150
  const skipped = requested - inserted;
@@ -1157,13 +1215,23 @@ var BaseController = class {
1157
1215
  */
1158
1216
  sanitizeBulkUpdateData(data, req, arcContext) {
1159
1217
  const stripped = /* @__PURE__ */ new Set();
1160
- if (!Object.keys(data).some((k) => k.startsWith("$"))) {
1218
+ const keys = Object.keys(data);
1219
+ const operatorKeys = keys.filter((k) => k.startsWith("$"));
1220
+ const flatKeys = keys.filter((k) => !k.startsWith("$"));
1221
+ const isOperatorShape = operatorKeys.length > 0;
1222
+ if (isOperatorShape && flatKeys.length > 0) return {
1223
+ sanitized: {},
1224
+ stripped: [],
1225
+ mixedShape: true
1226
+ };
1227
+ if (!isOperatorShape) {
1161
1228
  const before = new Set(Object.keys(data));
1162
1229
  const sanitized = this.bodySanitizer.sanitize(data, "update", req, arcContext);
1163
1230
  for (const key of before) if (!(key in sanitized)) stripped.add(key);
1164
1231
  return {
1165
1232
  sanitized,
1166
- stripped: [...stripped]
1233
+ stripped: [...stripped],
1234
+ mixedShape: false
1167
1235
  };
1168
1236
  }
1169
1237
  const sanitized = {};
@@ -1180,7 +1248,8 @@ var BaseController = class {
1180
1248
  }
1181
1249
  return {
1182
1250
  sanitized,
1183
- stripped: [...stripped]
1251
+ stripped: [...stripped],
1252
+ mixedShape: false
1184
1253
  };
1185
1254
  }
1186
1255
  async bulkUpdate(req) {
@@ -1209,7 +1278,14 @@ var BaseController = class {
1209
1278
  status: 403
1210
1279
  };
1211
1280
  const arcContext = this.meta(req);
1212
- const { sanitized, stripped } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
1281
+ const user = req.user;
1282
+ const { sanitized, stripped, mixedShape } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
1283
+ if (mixedShape) return {
1284
+ success: false,
1285
+ error: "Bulk update payload cannot mix operator keys ($set, $inc, ...) with flat fields. Pick one shape.",
1286
+ details: { code: "MIXED_UPDATE_SHAPE" },
1287
+ status: 400
1288
+ };
1213
1289
  if (Object.keys(sanitized).length === 0) return {
1214
1290
  success: false,
1215
1291
  error: "Bulk update payload contained only protected fields",
@@ -1221,7 +1297,10 @@ var BaseController = class {
1221
1297
  };
1222
1298
  return {
1223
1299
  success: true,
1224
- data: await repo.updateMany(scopedFilter, sanitized),
1300
+ data: await repo.updateMany(scopedFilter, sanitized, {
1301
+ user,
1302
+ context: arcContext
1303
+ }),
1225
1304
  status: 200,
1226
1305
  ...stripped.length > 0 && { meta: { stripped } }
1227
1306
  };
@@ -1272,9 +1351,16 @@ var BaseController = class {
1272
1351
  details: { code: "ORG_CONTEXT_REQUIRED" },
1273
1352
  status: 403
1274
1353
  };
1354
+ const hardHint = req.query?.hard === "true" || req.query?.hard === true || body.mode === "hard";
1355
+ const arcContext = this.meta(req);
1356
+ const options = {
1357
+ user: req.user,
1358
+ context: arcContext
1359
+ };
1360
+ if (hardHint) options.mode = "hard";
1275
1361
  return {
1276
1362
  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),
1363
+ data: await repo.deleteMany(scopedFilter, options),
1278
1364
  status: 200
1279
1365
  };
1280
1366
  }