@classytic/arc 2.6.3 → 2.7.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 (135) hide show
  1. package/README.md +84 -1
  2. package/dist/{BaseController-DzRtluEF.mjs → BaseController-CpMfCXdn.mjs} +134 -16
  3. package/dist/adapters/index.d.mts +2 -2
  4. package/dist/adapters/index.mjs +1 -1
  5. package/dist/{adapters-gM-WYjNe.mjs → adapters-BxGgSHjj.mjs} +1 -9
  6. package/dist/applyPermissionResult-D6GPMsvh.mjs +37 -0
  7. package/dist/audit/index.d.mts +1 -1
  8. package/dist/audit/index.mjs +1 -1
  9. package/dist/audit/mongodb.d.mts +1 -1
  10. package/dist/audit/mongodb.mjs +1 -1
  11. package/dist/auth/index.d.mts +4 -4
  12. package/dist/auth/index.mjs +7 -6
  13. package/dist/auth/mongoose.d.mts +191 -0
  14. package/dist/auth/mongoose.mjs +73 -0
  15. package/dist/auth/redis-session.d.mts +1 -1
  16. package/dist/{betterAuthOpenApi-lz0IRbXJ.mjs → betterAuthOpenApi-CCw3YX0g.mjs} +1 -1
  17. package/dist/cache/index.d.mts +2 -2
  18. package/dist/cache/index.mjs +2 -2
  19. package/dist/cli/commands/docs.mjs +2 -2
  20. package/dist/cli/commands/generate.mjs +1 -1
  21. package/dist/cli/commands/init.mjs +7 -5
  22. package/dist/cli/commands/introspect.mjs +1 -1
  23. package/dist/core/index.d.mts +3 -3
  24. package/dist/core/index.mjs +4 -4
  25. package/dist/{core-C1XCMtqM.mjs → core-BWekSEju.mjs} +41 -13
  26. package/dist/{createApp-D2w0LdYJ.mjs → createApp-B_nvKNAQ.mjs} +11 -11
  27. package/dist/{defineResource-wWMBB4GP.mjs → defineResource-DZzyl4a4.mjs} +42 -37
  28. package/dist/docs/index.d.mts +2 -2
  29. package/dist/docs/index.mjs +1 -1
  30. package/dist/dynamic/index.d.mts +2 -2
  31. package/dist/dynamic/index.mjs +2 -2
  32. package/dist/{elevation-BEdACOLB.mjs → elevation-By_p2lnn.mjs} +1 -1
  33. package/dist/elevation-Dm-HTBCt.d.mts +23 -0
  34. package/dist/{errorHandler-Do4vVQ1f.d.mts → errorHandler-COa51ho_.d.mts} +1 -1
  35. package/dist/{errorHandler-r2595m8T.mjs → errorHandler-DXUttWEO.mjs} +1 -1
  36. package/dist/{eventPlugin-DW45v4V5.d.mts → eventPlugin-BgLxJkIB.d.mts} +1 -1
  37. package/dist/{eventPlugin-Ba00swHF.mjs → eventPlugin-DsaNNXzZ.mjs} +1 -1
  38. package/dist/events/index.d.mts +3 -3
  39. package/dist/events/index.mjs +1 -1
  40. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  41. package/dist/events/transports/redis.d.mts +1 -1
  42. package/dist/factory/index.d.mts +1 -1
  43. package/dist/factory/index.mjs +1 -1
  44. package/dist/hooks/index.d.mts +1 -1
  45. package/dist/hooks/index.mjs +1 -1
  46. package/dist/idempotency/index.d.mts +3 -3
  47. package/dist/idempotency/mongodb.d.mts +1 -1
  48. package/dist/idempotency/redis.d.mts +1 -1
  49. package/dist/index-BYpRGXif.d.mts +640 -0
  50. package/dist/{index-gz6iuzCp.d.mts → index-KXM8_JmQ.d.mts} +47 -4
  51. package/dist/{index-CHeJa4Zd.d.mts → index-StgFaQKD.d.mts} +1 -1
  52. package/dist/index.d.mts +8 -8
  53. package/dist/index.mjs +10 -9
  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 +2 -2
  58. package/dist/integrations/mcp/index.mjs +1 -1
  59. package/dist/integrations/mcp/testing.d.mts +1 -1
  60. package/dist/integrations/mcp/testing.mjs +1 -1
  61. package/dist/{interface-DYH8AXGe.d.mts → interface-Dwzqt4mn.d.mts} +150 -14
  62. package/dist/{mongodb-pMvOlR5_.d.mts → mongodb-Bq90j-Uj.d.mts} +1 -1
  63. package/dist/{mongodb-kltrBPa1.d.mts → mongodb-DdyYlIXg.d.mts} +1 -1
  64. package/dist/{openapi-CBmZ6EQN.mjs → openapi-C5UhIeWu.mjs} +1 -1
  65. package/dist/org/index.d.mts +2 -2
  66. package/dist/org/index.mjs +1 -1
  67. package/dist/permissions/index.d.mts +4 -4
  68. package/dist/permissions/index.mjs +3 -2
  69. package/dist/{permissions-C8ImI8gC.mjs → permissions-CH4cNwJi.mjs} +358 -64
  70. package/dist/plugins/index.d.mts +4 -4
  71. package/dist/plugins/index.mjs +10 -10
  72. package/dist/plugins/response-cache.mjs +1 -1
  73. package/dist/plugins/tracing-entry.d.mts +1 -1
  74. package/dist/plugins/tracing-entry.mjs +1 -1
  75. package/dist/policies/index.d.mts +1 -1
  76. package/dist/presets/index.d.mts +3 -3
  77. package/dist/presets/index.mjs +1 -1
  78. package/dist/presets/multiTenant.d.mts +53 -3
  79. package/dist/presets/multiTenant.mjs +89 -47
  80. package/dist/{presets-BMfdy34e.mjs → presets-BFrGvvjL.mjs} +2 -2
  81. package/dist/{queryCachePlugin-DcmETvcB.d.mts → queryCachePlugin-Bw8XyJpX.d.mts} +1 -1
  82. package/dist/{queryCachePlugin-XtFplYO9.mjs → queryCachePlugin-CwTpR04-.mjs} +2 -2
  83. package/dist/{redis-D0Qc-9EW.d.mts → redis-CyCntzTO.d.mts} +1 -1
  84. package/dist/{redis-stream-BW9UKLZM.d.mts → redis-stream-We_Ucl9-.d.mts} +1 -1
  85. package/dist/registry/index.d.mts +1 -1
  86. package/dist/registry/index.mjs +2 -2
  87. package/dist/{resourceToTools-nCJWnG1r.mjs → resourceToTools-CkVSSzKg.mjs} +64 -21
  88. package/dist/rpc/index.d.mts +1 -1
  89. package/dist/rpc/index.mjs +1 -1
  90. package/dist/scope/index.d.mts +3 -2
  91. package/dist/scope/index.mjs +4 -3
  92. package/dist/{sse-BF7GR7IB.mjs → sse-Bp3dabF1.mjs} +2 -2
  93. package/dist/testing/index.d.mts +2 -2
  94. package/dist/testing/index.mjs +1 -1
  95. package/dist/types/index.d.mts +4 -3
  96. package/dist/types/index.mjs +1 -1
  97. package/dist/types-AOD8fxIw.mjs +229 -0
  98. package/dist/types-CNEbix8T.d.mts +286 -0
  99. package/dist/{types-B4_TDdPe.d.mts → types-ClmkMDK1.d.mts} +1 -1
  100. package/dist/{types-By-5mIfn.d.mts → types-D0qf0Mf4.d.mts} +9 -9
  101. package/dist/types-DPsC0taJ.d.mts +178 -0
  102. package/dist/utils/index.d.mts +3 -3
  103. package/dist/utils/index.mjs +5 -5
  104. package/package.json +17 -5
  105. package/skills/arc/SKILL.md +253 -6
  106. package/skills/arc/references/multi-tenancy.md +208 -0
  107. package/dist/elevation-C_taLQrM.d.mts +0 -147
  108. package/dist/index-NGZksqM5.d.mts +0 -398
  109. package/dist/types-BNUccdcf.d.mts +0 -101
  110. package/dist/types-BhtYdxZU.mjs +0 -91
  111. /package/dist/{EventTransport-wc5hSLik.d.mts → EventTransport-CUpRK_Lg.d.mts} +0 -0
  112. /package/dist/{HookSystem-COkyWztM.mjs → HookSystem-D7lfx--K.mjs} +0 -0
  113. /package/dist/{ResourceRegistry-C6ngvOnn.mjs → ResourceRegistry-DsHiG9cL.mjs} +0 -0
  114. /package/dist/{caching-BSXB-Xr7.mjs → caching-5DtLwIqb.mjs} +0 -0
  115. /package/dist/{circuitBreaker-JP2GdJ4b.d.mts → circuitBreaker-DwxrljLB.d.mts} +0 -0
  116. /package/dist/{circuitBreaker-BOBOpN2w.mjs → circuitBreaker-l18oRgL5.mjs} +0 -0
  117. /package/dist/{errors-CcVbl1-T.d.mts → errors-CCSsMpXE.d.mts} +0 -0
  118. /package/dist/{errors-NoQKsbAT.mjs → errors-Cg58SLNi.mjs} +0 -0
  119. /package/dist/{externalPaths-DpO-s7r8.d.mts → externalPaths-Dg7OLsKo.d.mts} +0 -0
  120. /package/dist/{fields-DFwdaWCq.d.mts → fields-CYuLMJPD.d.mts} +0 -0
  121. /package/dist/{interface-gr-7qo9j.d.mts → interface-B9rHWPxD.d.mts} +0 -0
  122. /package/dist/{interface-D_BWALyZ.d.mts → interface-CnluRL4_.d.mts} +0 -0
  123. /package/dist/{logger-Dz3j1ItV.mjs → logger-DLg8-Ueg.mjs} +0 -0
  124. /package/dist/{memory-BFAYkf8H.mjs → memory-Cp7_cAko.mjs} +0 -0
  125. /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
  126. /package/dist/{mongodb-BuQ7fNTg.mjs → mongodb-mlgxkYI3.mjs} +0 -0
  127. /package/dist/{pluralize-CcT6qF0a.mjs → pluralize-COpOVar8.mjs} +0 -0
  128. /package/dist/{registry-I-ogLgL9.mjs → registry-B3lRFBWo.mjs} +0 -0
  129. /package/dist/{requestContext-DYtmNpm5.mjs → requestContext-xHIKedG6.mjs} +0 -0
  130. /package/dist/{schemaConverter-DjzHpFam.mjs → schemaConverter-0TyONAwM.mjs} +0 -0
  131. /package/dist/{sessionManager-wbkYj2HL.d.mts → sessionManager-IW4sbIea.d.mts} +0 -0
  132. /package/dist/{tracing-bz_U4EM1.d.mts → tracing-65B51Dw3.d.mts} +0 -0
  133. /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
  134. /package/dist/{utils-Dc0WhlIl.mjs → utils-B-l6410F.mjs} +0 -0
  135. /package/dist/{versioning-BzfeHmhj.mjs → versioning-aUUVziBY.mjs} +0 -0
package/README.md CHANGED
@@ -143,6 +143,33 @@ auth: false
143
143
 
144
144
  **Decorates:** `app.authenticate`, `app.optionalAuthenticate`, `app.authorize`
145
145
 
146
+ ### Better Auth + Mongoose populate bridge
147
+
148
+ When you back Better Auth with `@better-auth/mongo-adapter`, BA writes through the native `mongodb` driver and never registers anything with Mongoose. Any arc resource that does `Schema({ userId: { ref: 'user' } })` and calls `.populate('userId')` then throws `MissingSchemaError`.
149
+
150
+ Optional helper at a dedicated subpath registers `strict: false` stub Mongoose models for BA's collections so populate works. Lives behind `@classytic/arc/auth/mongoose` so users on Prisma/Drizzle/Kysely never get Mongoose pulled into their bundle.
151
+
152
+ ```typescript
153
+ import mongoose from 'mongoose';
154
+ import { registerBetterAuthMongooseModels } from '@classytic/arc/auth/mongoose';
155
+
156
+ // Default is core only — every plugin set is opt-in.
157
+ registerBetterAuthMongooseModels(mongoose, {
158
+ plugins: ['organization', 'organization-teams'],
159
+ // For separate @better-auth/* packages:
160
+ extraCollections: ['passkey', 'ssoProvider'],
161
+ });
162
+
163
+ // Now arc resources can populate BA-owned references:
164
+ const Post = mongoose.model('Post', new mongoose.Schema({
165
+ title: String,
166
+ authorId: { type: String, ref: 'user' },
167
+ }));
168
+ await Post.findOne().populate('authorId');
169
+ ```
170
+
171
+ Supports `usePlural` (matches `mongodbAdapter({ usePlural: true })`) and `modelOverrides` (for custom `user: { modelName: 'profile' }` configs). Idempotent and de-dupes overlapping plugin sets.
172
+
146
173
  ### Token Revocation
147
174
 
148
175
  Arc provides the `isRevoked` primitive — you implement the store (Redis, DB, Better Auth):
@@ -167,7 +194,9 @@ Function-based, composable:
167
194
  ```typescript
168
195
  import {
169
196
  allowPublic, requireAuth, requireRoles, requireOwnership,
170
- requireOrgMembership, requireOrgRole, allOf, anyOf, denyAll,
197
+ requireOrgMembership, requireOrgRole, requireServiceScope,
198
+ requireScopeContext,
199
+ allOf, anyOf, denyAll,
171
200
  createDynamicPermissionMatrix,
172
201
  } from '@classytic/arc';
173
202
 
@@ -177,9 +206,63 @@ permissions: {
177
206
  create: requireRoles(['admin', 'editor']),
178
207
  update: anyOf(requireOwnership('userId'), requireRoles(['admin'])),
179
208
  delete: allOf(requireAuth(), requireRoles(['admin'])),
209
+
210
+ // Mixed human + machine routes — accept org admins OR API keys
211
+ bulkImport: anyOf(
212
+ requireOrgRole('admin'), // human path
213
+ requireServiceScope('jobs:bulk-write'), // machine path (OAuth-style)
214
+ ),
215
+
216
+ // Multi-level tenancy — branch/project/region scoped routes
217
+ branchAdmin: allOf(requireOrgRole('admin'), requireScopeContext('branchId')),
218
+ euOnly: requireScopeContext('region', 'eu'),
219
+ projectEdit: requireScopeContext({ projectId: 'p-1', region: 'eu' }),
220
+
221
+ // Parent-child org hierarchy (holding → subsidiary → branch, MSP, white-label)
222
+ // Reads scope.ancestorOrgIds (loaded by your auth function from your own org table)
223
+ childOrgAccess: requireOrgInScope((ctx) => ctx.request.params.orgId),
180
224
  }
181
225
  ```
182
226
 
227
+ `requireRoles()` checks platform roles (`user.role`) AND org roles
228
+ (`scope.orgRoles`) by default — same call works for arc JWT, Better Auth user
229
+ roles, and Better Auth org plugin. `requireOrgMembership()` accepts `member`,
230
+ `service` (API key), and `elevated` scopes; `multiTenantPreset` filters by
231
+ org for all three. For machine identities, `requireServiceScope('jobs:write')`
232
+ mirrors OAuth 2.0 scope strings. For app-defined dimensions beyond org/team
233
+ (branch, project, region, workspace), `requireScopeContext('branchId')`
234
+ reads from `scope.context` populated by your auth function. For parent-child
235
+ org hierarchies (holding → subsidiary, MSP → tenants, white-label),
236
+ `requireOrgInScope((ctx) => ctx.request.params.orgId)` accepts the current
237
+ org or any ancestor in `scope.ancestorOrgIds`.
238
+
239
+ **Multi-level tenant filtering** — the `multiTenantPreset` scales from
240
+ single-org isolation to lockstep filtering across any number of dimensions:
241
+
242
+ ```typescript
243
+ import { multiTenantPreset } from '@classytic/arc/presets';
244
+
245
+ // Single-field (default, backwards compatible)
246
+ multiTenantPreset({ tenantField: 'organizationId' })
247
+
248
+ // Multi-field — org + branch + project, all enforced in lockstep
249
+ multiTenantPreset({
250
+ tenantFields: [
251
+ { field: 'organizationId', type: 'org' }, // → getOrgId(scope)
252
+ { field: 'teamId', type: 'team' }, // → getTeamId(scope)
253
+ { field: 'branchId', contextKey: 'branchId' }, // → scope.context.branchId
254
+ { field: 'projectId', contextKey: 'projectId' },
255
+ ],
256
+ })
257
+ ```
258
+
259
+ Fail-closed semantics: if any required dimension is missing from the caller's
260
+ scope, list/get/update/delete return 403 with the specific missing field name,
261
+ and create is rejected. Elevated scopes apply whatever resolves and skip the
262
+ rest (cross-context admin bypass). Your auth function populates
263
+ `scope.context` from JWT claims, BA session fields, or request headers — arc
264
+ takes no position on which dimension names you use.
265
+
183
266
  **Field-level permissions:**
184
267
 
185
268
  ```typescript
@@ -1,5 +1,5 @@
1
1
  import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-Cxde4rpC.mjs";
2
- import { d as isElevated, f as isMember, i as getOrgId, n as PUBLIC_SCOPE } from "./types-BhtYdxZU.mjs";
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
5
  import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-ipsbIRPK.mjs";
@@ -441,7 +441,7 @@ var BaseController = class {
441
441
  this.defaultSort = options.defaultSort ?? "-createdAt";
442
442
  this.resourceName = options.resourceName;
443
443
  this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
444
- this.idField = options.idField ?? "_id";
444
+ this.idField = options.idField ?? repository?.idField ?? "_id";
445
445
  this._matchesFilter = options.matchesFilter;
446
446
  if (options.cache) this._cacheConfig = options.cache;
447
447
  if (options.presetFields) this._presetFields = options.presetFields;
@@ -483,6 +483,29 @@ var BaseController = class {
483
483
  getHooks(req) {
484
484
  return this.meta(req)?.arc?.hooks ?? null;
485
485
  }
486
+ /**
487
+ * Resolve the repository primary key for mutation calls (update/delete/restore).
488
+ *
489
+ * When the resource declares a custom `idField` (e.g. `slug`, `jobId`, UUID),
490
+ * the default behavior is to translate the route id → the fetched doc's `_id`
491
+ * because most Mongo repositories key their mutation methods off `_id`.
492
+ *
493
+ * Exception: if the repository itself exposes a matching `idField` property
494
+ * (e.g. MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
495
+ * repository already knows how to look up by that field — so we pass the
496
+ * route id through unchanged and skip the translation.
497
+ *
498
+ * This makes `defineResource({ idField: 'id' })` work end-to-end with repos
499
+ * that natively support custom primary keys, without breaking the slug-style
500
+ * aliasing that Arc 2.6.3 introduced for repos keyed on `_id`.
501
+ */
502
+ resolveRepoId(id, existing) {
503
+ if (this.idField === "_id") return id;
504
+ if (!existing) return id;
505
+ const repoIdField = this.repository.idField;
506
+ if (repoIdField && repoIdField === this.idField) return id;
507
+ return String(existing["_id"] ?? id);
508
+ }
486
509
  /** Resolve cache config for a specific operation, merging per-op overrides */
487
510
  resolveCacheConfig(operation) {
488
511
  const cfg = this._cacheConfig;
@@ -722,7 +745,7 @@ var BaseController = class {
722
745
  details: { code: "OWNERSHIP_DENIED" },
723
746
  status: 403
724
747
  };
725
- const repoId = this.idField !== "_id" && existing ? String(existing["_id"] ?? id) : id;
748
+ const repoId = this.resolveRepoId(id, existing);
726
749
  const hooks = this.getHooks(req);
727
750
  let processedData = data;
728
751
  if (hooks && this.resourceName) try {
@@ -801,7 +824,7 @@ var BaseController = class {
801
824
  details: { code: "OWNERSHIP_DENIED" },
802
825
  status: 403
803
826
  };
804
- const repoId = this.idField !== "_id" && existing ? String(existing["_id"] ?? id) : id;
827
+ const repoId = this.resolveRepoId(id, existing);
805
828
  const hooks = this.getHooks(req);
806
829
  if (hooks && this.resourceName) try {
807
830
  await hooks.executeBefore(this.resourceName, "delete", existing, {
@@ -915,7 +938,7 @@ var BaseController = class {
915
938
  error: "ID parameter is required",
916
939
  status: 400
917
940
  };
918
- const existing = await this.accessControl.fetchWithAccessControl(id, req, repo);
941
+ const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
919
942
  if (!existing) return {
920
943
  success: false,
921
944
  error: "Resource not found",
@@ -927,7 +950,7 @@ var BaseController = class {
927
950
  details: { code: "OWNERSHIP_DENIED" },
928
951
  status: 403
929
952
  };
930
- const repoId = this.idField !== "_id" && existing ? String(existing["_id"] ?? id) : id;
953
+ const repoId = this.resolveRepoId(id, existing);
931
954
  const item = await repo.restore(repoId);
932
955
  if (!item) return {
933
956
  success: false,
@@ -984,9 +1007,11 @@ var BaseController = class {
984
1007
  error: "Bulk create requires a non-empty items array",
985
1008
  status: 400
986
1009
  };
987
- let scopedItems = items;
1010
+ const arcContext = this.meta(req);
1011
+ const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
1012
+ let scopedItems = sanitizedItems;
988
1013
  if (this.tenantField) {
989
- const scope = this.meta(req)?._scope;
1014
+ const scope = arcContext?._scope;
990
1015
  if (scope) {
991
1016
  if (scope.kind === "public") return {
992
1017
  success: false,
@@ -1003,7 +1028,7 @@ var BaseController = class {
1003
1028
  status: 403
1004
1029
  };
1005
1030
  const tenantField = this.tenantField;
1006
- scopedItems = items.map((item) => ({
1031
+ scopedItems = sanitizedItems.map((item) => ({
1007
1032
  ...item,
1008
1033
  [tenantField]: orgId
1009
1034
  }));
@@ -1011,11 +1036,23 @@ var BaseController = class {
1011
1036
  }
1012
1037
  }
1013
1038
  const created = await repo.createMany(scopedItems);
1039
+ const requested = items.length;
1040
+ const inserted = created.length;
1041
+ const skipped = requested - inserted;
1014
1042
  return {
1015
1043
  success: true,
1016
1044
  data: created,
1017
- status: 201,
1018
- meta: { count: created.length }
1045
+ status: skipped === 0 ? 201 : inserted === 0 ? 422 : 207,
1046
+ meta: {
1047
+ count: inserted,
1048
+ requested,
1049
+ inserted,
1050
+ skipped,
1051
+ ...skipped > 0 && {
1052
+ partial: true,
1053
+ reason: inserted === 0 ? "all_invalid" : "some_invalid"
1054
+ }
1055
+ }
1019
1056
  };
1020
1057
  }
1021
1058
  /**
@@ -1052,6 +1089,49 @@ var BaseController = class {
1052
1089
  }
1053
1090
  return filter;
1054
1091
  }
1092
+ /**
1093
+ * Sanitize a bulk update data payload through the same write-permission
1094
+ * pipeline as single-doc update(). Handles both shapes:
1095
+ *
1096
+ * - Flat: `{ name: 'x', status: 'y' }`
1097
+ * - Mongo operator: `{ $set: { name: 'x' }, $inc: { views: 1 }, $unset: { tag: '' } }`
1098
+ *
1099
+ * For each operand, runs `bodySanitizer.sanitize('update', ...)` so that
1100
+ * system fields, systemManaged/readonly/immutable rules, AND field-level
1101
+ * write permissions are enforced. Without this, a tenant-scoped user could
1102
+ * pass `{ $set: { organizationId: 'org-b' } }` to move records across orgs.
1103
+ *
1104
+ * Returns the sanitized payload along with the list of stripped fields for
1105
+ * audit/error reporting.
1106
+ */
1107
+ sanitizeBulkUpdateData(data, req, arcContext) {
1108
+ const stripped = /* @__PURE__ */ new Set();
1109
+ if (!Object.keys(data).some((k) => k.startsWith("$"))) {
1110
+ const before = new Set(Object.keys(data));
1111
+ const sanitized = this.bodySanitizer.sanitize(data, "update", req, arcContext);
1112
+ for (const key of before) if (!(key in sanitized)) stripped.add(key);
1113
+ return {
1114
+ sanitized,
1115
+ stripped: [...stripped]
1116
+ };
1117
+ }
1118
+ const sanitized = {};
1119
+ for (const [op, operand] of Object.entries(data)) {
1120
+ if (!op.startsWith("$") || operand === null || typeof operand !== "object") {
1121
+ sanitized[op] = operand;
1122
+ continue;
1123
+ }
1124
+ const operandObj = operand;
1125
+ const before = new Set(Object.keys(operandObj));
1126
+ const sanitizedOperand = this.bodySanitizer.sanitize(operandObj, "update", req, arcContext);
1127
+ for (const key of before) if (!(key in sanitizedOperand)) stripped.add(key);
1128
+ if (Object.keys(sanitizedOperand).length > 0) sanitized[op] = sanitizedOperand;
1129
+ }
1130
+ return {
1131
+ sanitized,
1132
+ stripped: [...stripped]
1133
+ };
1134
+ }
1055
1135
  async bulkUpdate(req) {
1056
1136
  const repo = this.repository;
1057
1137
  if (!repo.updateMany) return {
@@ -1077,12 +1157,41 @@ var BaseController = class {
1077
1157
  details: { code: "ORG_CONTEXT_REQUIRED" },
1078
1158
  status: 403
1079
1159
  };
1160
+ const arcContext = this.meta(req);
1161
+ const { sanitized, stripped } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
1162
+ if (Object.keys(sanitized).length === 0) return {
1163
+ success: false,
1164
+ error: "Bulk update payload contained only protected fields",
1165
+ details: {
1166
+ code: "ALL_FIELDS_STRIPPED",
1167
+ stripped
1168
+ },
1169
+ status: 400
1170
+ };
1080
1171
  return {
1081
1172
  success: true,
1082
- data: await repo.updateMany(scopedFilter, body.data),
1083
- status: 200
1173
+ data: await repo.updateMany(scopedFilter, sanitized),
1174
+ status: 200,
1175
+ ...stripped.length > 0 && { meta: { stripped } }
1084
1176
  };
1085
1177
  }
1178
+ /**
1179
+ * Bulk delete by `filter` or `ids`.
1180
+ *
1181
+ * Body shape (one of):
1182
+ * - `{ filter: { status: 'archived' } }` — delete by query filter
1183
+ * - `{ ids: ['id1', 'id2', 'id3'] }` — delete specific docs by id
1184
+ *
1185
+ * The `ids` form translates to `{ [idField]: { $in: ids } }` using the
1186
+ * resource's `idField` (so it works with custom PKs like `slug`, `jobId`,
1187
+ * UUID, etc.). Tenant scope and policy filters are merged in either way,
1188
+ * so cross-tenant deletes are blocked at the controller layer.
1189
+ *
1190
+ * Both forms perform a single `repo.deleteMany()` DB call — no per-doc
1191
+ * fetch loop. Per-doc lifecycle hooks (`before:delete`/`after:delete`) do
1192
+ * NOT fire for bulk operations; use the single-doc `delete()` if you need
1193
+ * them, or subscribe to the bulk lifecycle event from the events plugin.
1194
+ */
1086
1195
  async bulkDelete(req) {
1087
1196
  const repo = this.repository;
1088
1197
  if (!repo.deleteMany) return {
@@ -1091,12 +1200,21 @@ var BaseController = class {
1091
1200
  status: 501
1092
1201
  };
1093
1202
  const body = req.body;
1094
- if (!body.filter || Object.keys(body.filter).length === 0) return {
1203
+ let userFilter;
1204
+ if (body.ids && body.ids.length > 0) {
1205
+ if (body.filter && Object.keys(body.filter).length > 0) return {
1206
+ success: false,
1207
+ error: "Bulk delete accepts either `ids` or `filter`, not both",
1208
+ status: 400
1209
+ };
1210
+ userFilter = { [this.idField]: { $in: body.ids } };
1211
+ } else if (body.filter && Object.keys(body.filter).length > 0) userFilter = body.filter;
1212
+ else return {
1095
1213
  success: false,
1096
- error: "Bulk delete requires a non-empty filter",
1214
+ error: "Bulk delete requires a non-empty `filter` or `ids` array",
1097
1215
  status: 400
1098
1216
  };
1099
- const scopedFilter = this.buildBulkFilter(body.filter, req);
1217
+ const scopedFilter = this.buildBulkFilter(userFilter, req);
1100
1218
  if (scopedFilter === null) return {
1101
1219
  success: false,
1102
1220
  error: "Organization context required for bulk delete",
@@ -1,3 +1,3 @@
1
- import { a as RelationMetadata, c as ValidationResult, i as FieldMetadata, o as RepositoryLike, r as DataAdapter, s as SchemaMetadata, t as AdapterFactory } from "../interface-DYH8AXGe.mjs";
2
- import { a as PrismaQueryParserOptions, c as MongooseAdapterOptions, i as PrismaQueryParser, l as createMongooseAdapter, n as PrismaAdapterOptions, o as createPrismaAdapter, r as PrismaQueryOptions, s as MongooseAdapter, t as PrismaAdapter } from "../index-CHeJa4Zd.mjs";
1
+ import { a as RelationMetadata, c as ValidationResult, i as FieldMetadata, o as RepositoryLike, r as DataAdapter, s as SchemaMetadata, t as AdapterFactory } from "../interface-Dwzqt4mn.mjs";
2
+ import { a as PrismaQueryParserOptions, c as MongooseAdapterOptions, i as PrismaQueryParser, l as createMongooseAdapter, n as PrismaAdapterOptions, o as createPrismaAdapter, r as PrismaQueryOptions, s as MongooseAdapter, t as PrismaAdapter } from "../index-StgFaQKD.mjs";
3
3
  export { AdapterFactory, DataAdapter, FieldMetadata, MongooseAdapter, MongooseAdapterOptions, PrismaAdapter, PrismaAdapterOptions, PrismaQueryOptions, PrismaQueryParser, PrismaQueryParserOptions, RelationMetadata, RepositoryLike, SchemaMetadata, ValidationResult, createMongooseAdapter, createPrismaAdapter };
@@ -1,2 +1,2 @@
1
- import { a as createMongooseAdapter, i as MongooseAdapter, n as PrismaQueryParser, r as createPrismaAdapter, t as PrismaAdapter } from "../adapters-gM-WYjNe.mjs";
1
+ import { a as createMongooseAdapter, i as MongooseAdapter, n as PrismaQueryParser, r as createPrismaAdapter, t as PrismaAdapter } from "../adapters-BxGgSHjj.mjs";
2
2
  export { MongooseAdapter, PrismaAdapter, PrismaQueryParser, createMongooseAdapter, createPrismaAdapter };
@@ -189,15 +189,7 @@ var MongooseAdapter = class {
189
189
  else baseType.items = {};
190
190
  break;
191
191
  }
192
- case "Mixed":
193
- baseType.type = [
194
- "string",
195
- "number",
196
- "boolean",
197
- "object",
198
- "array"
199
- ];
200
- break;
192
+ case "Mixed": break;
201
193
  case "Map":
202
194
  baseType.type = "object";
203
195
  baseType.additionalProperties = true;
@@ -0,0 +1,37 @@
1
+ //#region src/permissions/applyPermissionResult.ts
2
+ /**
3
+ * Normalize a permission check return value (`boolean | PermissionResult`)
4
+ * into a concrete `PermissionResult`. This is the only place in Arc that
5
+ * promotes booleans to results — keeps the type narrowing honest everywhere.
6
+ */
7
+ function normalizePermissionResult(result) {
8
+ if (typeof result === "boolean") return { granted: result };
9
+ return result;
10
+ }
11
+ /**
12
+ * Apply a granted `PermissionResult` to a Fastify request — merges row-level
13
+ * filters into `_policyFilters` and conditionally installs the scope.
14
+ *
15
+ * **Scope install rule:** only writes `scope` when the current request scope
16
+ * is absent or `public`. This prevents downgrading an already-authenticated
17
+ * request (e.g. Better Auth set `member`, then a permission check returns a
18
+ * narrower `service` scope — the original `member` wins because it came from
19
+ * a more authoritative source).
20
+ *
21
+ * Safe to call with a non-granted result — it simply no-ops. Callers should
22
+ * still check `result.granted` and send an error response before reaching here,
23
+ * but this function tolerates the misuse defensively.
24
+ */
25
+ function applyPermissionResult(result, request) {
26
+ if (!result.granted) return;
27
+ if (result.filters) request._policyFilters = {
28
+ ...request._policyFilters ?? {},
29
+ ...result.filters
30
+ };
31
+ if (result.scope) {
32
+ const current = request.scope;
33
+ if (!current || current.kind === "public") request.scope = result.scope;
34
+ }
35
+ }
36
+ //#endregion
37
+ export { normalizePermissionResult as n, applyPermissionResult as t };
@@ -1,4 +1,4 @@
1
- import { a as AuditContext, c as AuditStore, i as AuditAction, l as AuditStoreOptions, n as MongoAuditStoreOptions, o as AuditEntry, r as MongoConnection, s as AuditQueryOptions, u as createAuditEntry } from "../mongodb-kltrBPa1.mjs";
1
+ import { a as AuditContext, c as AuditStore, i as AuditAction, l as AuditStoreOptions, n as MongoAuditStoreOptions, o as AuditEntry, r as MongoConnection, s as AuditQueryOptions, u as createAuditEntry } from "../mongodb-DdyYlIXg.mjs";
2
2
  import { FastifyPluginAsync } from "fastify";
3
3
 
4
4
  //#region src/audit/auditPlugin.d.ts
@@ -1,4 +1,4 @@
1
- import { t as MongoAuditStore } from "../mongodb-BuQ7fNTg.mjs";
1
+ import { t as MongoAuditStore } from "../mongodb-mlgxkYI3.mjs";
2
2
  import fp from "fastify-plugin";
3
3
  //#region src/audit/stores/interface.ts
4
4
  /**
@@ -1,2 +1,2 @@
1
- import { n as MongoAuditStoreOptions, t as MongoAuditStore } from "../mongodb-kltrBPa1.mjs";
1
+ import { n as MongoAuditStoreOptions, t as MongoAuditStore } from "../mongodb-DdyYlIXg.mjs";
2
2
  export { MongoAuditStore, type MongoAuditStoreOptions };
@@ -1,2 +1,2 @@
1
- import { t as MongoAuditStore } from "../mongodb-BuQ7fNTg.mjs";
1
+ import { t as MongoAuditStore } from "../mongodb-mlgxkYI3.mjs";
2
2
  export { MongoAuditStore };
@@ -1,7 +1,7 @@
1
- import { g as AuthPluginOptions, h as AuthHelpers } from "../interface-DYH8AXGe.mjs";
2
- import { t as PermissionCheck } from "../types-BNUccdcf.mjs";
3
- import { t as ExternalOpenApiPaths } from "../externalPaths-DpO-s7r8.mjs";
4
- import { a as SessionManagerOptions, c as createSessionManager, i as SessionData, n as MemorySessionStoreOptions, o as SessionManagerResult, r as SessionCookieOptions, s as SessionStore, t as MemorySessionStore } from "../sessionManager-wbkYj2HL.mjs";
1
+ import { g as AuthPluginOptions, h as AuthHelpers } from "../interface-Dwzqt4mn.mjs";
2
+ import { t as PermissionCheck } from "../types-DPsC0taJ.mjs";
3
+ import { t as ExternalOpenApiPaths } from "../externalPaths-Dg7OLsKo.mjs";
4
+ import { a as SessionManagerOptions, c as createSessionManager, i as SessionData, n as MemorySessionStoreOptions, o as SessionManagerResult, r as SessionCookieOptions, s as SessionStore, t as MemorySessionStore } from "../sessionManager-IW4sbIea.mjs";
5
5
  import { FastifyPluginAsync, FastifyReply as FastifyReply$1, FastifyRequest as FastifyRequest$1 } from "fastify";
6
6
 
7
7
  //#region src/auth/authPlugin.d.ts
@@ -1,7 +1,7 @@
1
1
  import { n as normalizeRoles, t as getUserRoles } from "../types-ZUu_h0jp.mjs";
2
- import { t as ArcError } from "../errors-NoQKsbAT.mjs";
3
- import { c as requireOrgMembership, f as requireTeamMembership, l as requireOrgRole } from "../permissions-C8ImI8gC.mjs";
4
- import { n as extractBetterAuthOpenApi } from "../betterAuthOpenApi-lz0IRbXJ.mjs";
2
+ import { t as ArcError } from "../errors-Cg58SLNi.mjs";
3
+ import { h as requireTeamMembership, l as requireOrgMembership, u as requireOrgRole } from "../permissions-CH4cNwJi.mjs";
4
+ import { n as extractBetterAuthOpenApi } from "../betterAuthOpenApi-CCw3YX0g.mjs";
5
5
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
6
6
  import fp from "fastify-plugin";
7
7
  //#region src/auth/authPlugin.ts
@@ -588,10 +588,11 @@ function createBetterAuthAdapter(options) {
588
588
  const teamsResponse = await auth.handler(teamsRequest);
589
589
  if (teamsResponse.ok) {
590
590
  const teamsData = await teamsResponse.json();
591
- teams = Array.isArray(teamsData) ? teamsData : teamsData?.teams ?? [];
591
+ const teamsList = Array.isArray(teamsData) ? teamsData : teamsData?.teams;
592
+ teams = Array.isArray(teamsList) ? teamsList : [];
592
593
  }
593
594
  }
594
- if (teams?.some((t) => t.id === activeTeamId)) scope.teamId = activeTeamId;
595
+ if (teams?.some((t) => normalizeId(t.id) === activeTeamId)) scope.teamId = activeTeamId;
595
596
  }
596
597
  req.scope = scope;
597
598
  }
@@ -676,7 +677,7 @@ function createBetterAuthAdapter(options) {
676
677
  if (!fastify.hasDecorator("authenticate")) fastify.decorate("authenticate", authenticate);
677
678
  if (!fastify.hasDecorator("optionalAuthenticate")) fastify.decorate("optionalAuthenticate", optionalAuthenticate);
678
679
  if (!extractedOpenApi && openapiOpt !== false && auth.api && typeof auth.api === "object") {
679
- const { extractBetterAuthOpenApi } = await import("../betterAuthOpenApi-lz0IRbXJ.mjs").then((n) => n.t);
680
+ const { extractBetterAuthOpenApi } = await import("../betterAuthOpenApi-CCw3YX0g.mjs").then((n) => n.t);
680
681
  extractedOpenApi = extractBetterAuthOpenApi(auth.api, {
681
682
  basePath,
682
683
  userFields