@classytic/arc 2.6.2 → 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 +95 -1
  2. package/dist/{BaseController-AbbRx3e0.mjs → BaseController-CpMfCXdn.mjs} +214 -16
  3. package/dist/adapters/index.d.mts +2 -2
  4. package/dist/adapters/index.mjs +1 -1
  5. package/dist/{adapters-CTn28N4y.mjs → adapters-BxGgSHjj.mjs} +7 -13
  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-Ckxg6HrZ.mjs → defineResource-DZzyl4a4.mjs} +73 -56
  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-B4uZm82R.d.mts → index-KXM8_JmQ.d.mts} +47 -4
  51. package/dist/{index-DrCqa3Jq.d.mts → index-StgFaQKD.d.mts} +3 -3
  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-CrN45qz1.d.mts → interface-Dwzqt4mn.d.mts} +204 -18
  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-DH3c3e-T.mjs → resourceToTools-CkVSSzKg.mjs} +313 -33
  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-DurlBP2N.d.mts → types-ClmkMDK1.d.mts} +1 -1
  100. package/dist/{types-C1Z28coa.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 +34 -22
  105. package/skills/arc/SKILL.md +278 -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
@@ -108,6 +108,17 @@ const productResource = defineResource({
108
108
  // Plus preset routes: GET /deleted, POST /:id/restore, GET /slug/:slug
109
109
  ```
110
110
 
111
+ **Custom primary key?** Use `idField` for resources keyed by UUIDs, slugs, or business identifiers:
112
+
113
+ ```typescript
114
+ defineResource({
115
+ name: 'job',
116
+ adapter: createMongooseAdapter(JobModel, jobRepository),
117
+ idField: 'jobId', // routes + BaseController lookups + OpenAPI + MCP tools all use this
118
+ });
119
+ // GET /jobs/job-5219f346-a4d → 200 (no ObjectId pattern enforcement)
120
+ ```
121
+
111
122
  ## Authentication
112
123
 
113
124
  Auth uses a discriminated union — pick a `type`:
@@ -132,6 +143,33 @@ auth: false
132
143
 
133
144
  **Decorates:** `app.authenticate`, `app.optionalAuthenticate`, `app.authorize`
134
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
+
135
173
  ### Token Revocation
136
174
 
137
175
  Arc provides the `isRevoked` primitive — you implement the store (Redis, DB, Better Auth):
@@ -156,7 +194,9 @@ Function-based, composable:
156
194
  ```typescript
157
195
  import {
158
196
  allowPublic, requireAuth, requireRoles, requireOwnership,
159
- requireOrgMembership, requireOrgRole, allOf, anyOf, denyAll,
197
+ requireOrgMembership, requireOrgRole, requireServiceScope,
198
+ requireScopeContext,
199
+ allOf, anyOf, denyAll,
160
200
  createDynamicPermissionMatrix,
161
201
  } from '@classytic/arc';
162
202
 
@@ -166,9 +206,63 @@ permissions: {
166
206
  create: requireRoles(['admin', 'editor']),
167
207
  update: anyOf(requireOwnership('userId'), requireRoles(['admin'])),
168
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),
169
224
  }
170
225
  ```
171
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
+
172
266
  **Field-level permissions:**
173
267
 
174
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";
@@ -96,9 +96,12 @@ var AccessControl = class AccessControl {
96
96
  */
97
97
  async fetchWithAccessControl(id, req, repository, queryOptions) {
98
98
  const compoundFilter = this.buildIdFilter(id, req);
99
- const hasCompoundFilters = Object.keys(compoundFilter).length > 1;
99
+ const needsCompoundLookup = Object.keys(compoundFilter).length > 1 || this.idField !== "_id";
100
100
  try {
101
- if (hasCompoundFilters && typeof repository.getOne === "function") return await repository.getOne(compoundFilter, queryOptions);
101
+ if (needsCompoundLookup && typeof repository.getOne === "function") return await repository.getOne(compoundFilter, queryOptions);
102
+ if (this.idField !== "_id") {
103
+ 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
+ }
102
105
  const item = await repository.getById(id, queryOptions);
103
106
  if (!item) return null;
104
107
  const arcContext = this._meta(req);
@@ -438,7 +441,7 @@ var BaseController = class {
438
441
  this.defaultSort = options.defaultSort ?? "-createdAt";
439
442
  this.resourceName = options.resourceName;
440
443
  this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
441
- this.idField = options.idField ?? "_id";
444
+ this.idField = options.idField ?? repository?.idField ?? "_id";
442
445
  this._matchesFilter = options.matchesFilter;
443
446
  if (options.cache) this._cacheConfig = options.cache;
444
447
  if (options.presetFields) this._presetFields = options.presetFields;
@@ -480,6 +483,29 @@ var BaseController = class {
480
483
  getHooks(req) {
481
484
  return this.meta(req)?.arc?.hooks ?? null;
482
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
+ }
483
509
  /** Resolve cache config for a specific operation, merging per-op overrides */
484
510
  resolveCacheConfig(operation) {
485
511
  const cfg = this._cacheConfig;
@@ -719,6 +745,7 @@ var BaseController = class {
719
745
  details: { code: "OWNERSHIP_DENIED" },
720
746
  status: 403
721
747
  };
748
+ const repoId = this.resolveRepoId(id, existing);
722
749
  const hooks = this.getHooks(req);
723
750
  let processedData = data;
724
751
  if (hooks && this.resourceName) try {
@@ -741,7 +768,7 @@ var BaseController = class {
741
768
  status: 400
742
769
  };
743
770
  }
744
- const repoUpdate = async () => this.repository.update(id, processedData, {
771
+ const repoUpdate = async () => this.repository.update(repoId, processedData, {
745
772
  user,
746
773
  context: arcContext
747
774
  });
@@ -797,6 +824,7 @@ var BaseController = class {
797
824
  details: { code: "OWNERSHIP_DENIED" },
798
825
  status: 403
799
826
  };
827
+ const repoId = this.resolveRepoId(id, existing);
800
828
  const hooks = this.getHooks(req);
801
829
  if (hooks && this.resourceName) try {
802
830
  await hooks.executeBefore(this.resourceName, "delete", existing, {
@@ -815,7 +843,7 @@ var BaseController = class {
815
843
  status: 400
816
844
  };
817
845
  }
818
- const repoDelete = async () => this.repository.delete(id, {
846
+ const repoDelete = async () => this.repository.delete(repoId, {
819
847
  user,
820
848
  context: arcContext
821
849
  });
@@ -910,7 +938,7 @@ var BaseController = class {
910
938
  error: "ID parameter is required",
911
939
  status: 400
912
940
  };
913
- const existing = await this.accessControl.fetchWithAccessControl(id, req, repo);
941
+ const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
914
942
  if (!existing) return {
915
943
  success: false,
916
944
  error: "Resource not found",
@@ -922,7 +950,8 @@ var BaseController = class {
922
950
  details: { code: "OWNERSHIP_DENIED" },
923
951
  status: 403
924
952
  };
925
- const item = await repo.restore(id);
953
+ const repoId = this.resolveRepoId(id, existing);
954
+ const item = await repo.restore(repoId);
926
955
  if (!item) return {
927
956
  success: false,
928
957
  error: "Resource not found",
@@ -978,12 +1007,129 @@ var BaseController = class {
978
1007
  error: "Bulk create requires a non-empty items array",
979
1008
  status: 400
980
1009
  };
981
- const created = await repo.createMany(items);
1010
+ const arcContext = this.meta(req);
1011
+ const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
1012
+ let scopedItems = sanitizedItems;
1013
+ if (this.tenantField) {
1014
+ const scope = arcContext?._scope;
1015
+ if (scope) {
1016
+ if (scope.kind === "public") return {
1017
+ success: false,
1018
+ error: "Organization context required to bulk-create resources",
1019
+ details: { code: "ORG_CONTEXT_REQUIRED" },
1020
+ status: 403
1021
+ };
1022
+ if (!isElevated(scope)) {
1023
+ const orgId = getOrgId(scope);
1024
+ if (!orgId) return {
1025
+ success: false,
1026
+ error: "Organization context required to bulk-create resources",
1027
+ details: { code: "ORG_CONTEXT_REQUIRED" },
1028
+ status: 403
1029
+ };
1030
+ const tenantField = this.tenantField;
1031
+ scopedItems = sanitizedItems.map((item) => ({
1032
+ ...item,
1033
+ [tenantField]: orgId
1034
+ }));
1035
+ }
1036
+ }
1037
+ }
1038
+ const created = await repo.createMany(scopedItems);
1039
+ const requested = items.length;
1040
+ const inserted = created.length;
1041
+ const skipped = requested - inserted;
982
1042
  return {
983
1043
  success: true,
984
1044
  data: created,
985
- status: 201,
986
- 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
+ }
1056
+ };
1057
+ }
1058
+ /**
1059
+ * Build a tenant-scoped filter for bulk update/delete.
1060
+ *
1061
+ * Mirrors `AccessControl.buildIdFilter` semantics for single-doc ops:
1062
+ * - Always merge `_policyFilters` (from permission middleware)
1063
+ * - When `tenantField` is set AND a `member` scope is present, add the
1064
+ * org filter so cross-tenant data can't be touched.
1065
+ * - When the scope is `elevated` (platform admin), no org filter is
1066
+ * applied — admins can bulk-update across orgs intentionally.
1067
+ * - When the scope is `public` on a tenant-scoped resource, deny.
1068
+ * - When NO scope is present at all (e.g., direct controller calls in
1069
+ * unit tests, or app routes without auth middleware), the controller
1070
+ * stays lenient — it's the middleware layer's job to fail-close.
1071
+ * Apps that want fail-close on bulk routes should run the multi-tenant
1072
+ * preset middleware (or equivalent) ahead of these handlers.
1073
+ *
1074
+ * Returns the merged filter, or `null` when access must be denied.
1075
+ */
1076
+ buildBulkFilter(userFilter, req) {
1077
+ const filter = { ...userFilter };
1078
+ const arcContext = this.meta(req);
1079
+ const policyFilters = arcContext?._policyFilters;
1080
+ if (policyFilters) Object.assign(filter, policyFilters);
1081
+ if (this.tenantField) {
1082
+ const scope = arcContext?._scope;
1083
+ if (!scope) return filter;
1084
+ if (scope.kind === "public") return null;
1085
+ if (isElevated(scope)) return filter;
1086
+ const orgId = getOrgId(scope);
1087
+ if (!orgId) return null;
1088
+ filter[this.tenantField] = orgId;
1089
+ }
1090
+ return filter;
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]
987
1133
  };
988
1134
  }
989
1135
  async bulkUpdate(req) {
@@ -1004,12 +1150,48 @@ var BaseController = class {
1004
1150
  error: "Bulk update requires non-empty data",
1005
1151
  status: 400
1006
1152
  };
1153
+ const scopedFilter = this.buildBulkFilter(body.filter, req);
1154
+ if (scopedFilter === null) return {
1155
+ success: false,
1156
+ error: "Organization context required for bulk update",
1157
+ details: { code: "ORG_CONTEXT_REQUIRED" },
1158
+ status: 403
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
+ };
1007
1171
  return {
1008
1172
  success: true,
1009
- data: await repo.updateMany(body.filter, body.data),
1010
- status: 200
1173
+ data: await repo.updateMany(scopedFilter, sanitized),
1174
+ status: 200,
1175
+ ...stripped.length > 0 && { meta: { stripped } }
1011
1176
  };
1012
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
+ */
1013
1195
  async bulkDelete(req) {
1014
1196
  const repo = this.repository;
1015
1197
  if (!repo.deleteMany) return {
@@ -1018,14 +1200,30 @@ var BaseController = class {
1018
1200
  status: 501
1019
1201
  };
1020
1202
  const body = req.body;
1021
- 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 {
1022
1213
  success: false,
1023
- error: "Bulk delete requires a non-empty filter",
1214
+ error: "Bulk delete requires a non-empty `filter` or `ids` array",
1024
1215
  status: 400
1025
1216
  };
1217
+ const scopedFilter = this.buildBulkFilter(userFilter, req);
1218
+ if (scopedFilter === null) return {
1219
+ success: false,
1220
+ error: "Organization context required for bulk delete",
1221
+ details: { code: "ORG_CONTEXT_REQUIRED" },
1222
+ status: 403
1223
+ };
1026
1224
  return {
1027
1225
  success: true,
1028
- data: await repo.deleteMany(body.filter),
1226
+ data: await repo.deleteMany(scopedFilter),
1029
1227
  status: 200
1030
1228
  };
1031
1229
  }
@@ -1,3 +1,3 @@
1
- import { a as RepositoryLike, i as RelationMetadata, n as DataAdapter, o as SchemaMetadata, r as FieldMetadata, s as ValidationResult, t as AdapterFactory } from "../interface-CrN45qz1.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-DrCqa3Jq.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-CTn28N4y.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 };
@@ -71,9 +71,9 @@ var MongooseAdapter = class {
71
71
  * If a `schemaGenerator` plugin was provided (e.g. MongoKit's buildCrudSchemasFromModel),
72
72
  * it is used instead of the built-in basic conversion.
73
73
  */
74
- generateSchemas(schemaOptions) {
74
+ generateSchemas(schemaOptions, context) {
75
75
  try {
76
- if (this.schemaGenerator) return this.schemaGenerator(this.model, schemaOptions);
76
+ if (this.schemaGenerator) return this.schemaGenerator(this.model, schemaOptions, context);
77
77
  const paths = this.model.schema.paths;
78
78
  const properties = {};
79
79
  const required = [];
@@ -104,11 +104,13 @@ var MongooseAdapter = class {
104
104
  createBody: {
105
105
  type: "object",
106
106
  properties: inputProperties,
107
- required: inputRequired.length > 0 ? inputRequired : void 0
107
+ required: inputRequired.length > 0 ? inputRequired : void 0,
108
+ additionalProperties: true
108
109
  },
109
110
  updateBody: {
110
111
  type: "object",
111
- properties: updateProperties
112
+ properties: updateProperties,
113
+ additionalProperties: true
112
114
  },
113
115
  response: {
114
116
  type: "object",
@@ -187,15 +189,7 @@ var MongooseAdapter = class {
187
189
  else baseType.items = {};
188
190
  break;
189
191
  }
190
- case "Mixed":
191
- baseType.type = [
192
- "string",
193
- "number",
194
- "boolean",
195
- "object",
196
- "array"
197
- ];
198
- break;
192
+ case "Mixed": break;
199
193
  case "Map":
200
194
  baseType.type = "object";
201
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 { h as AuthPluginOptions, m as AuthHelpers } from "../interface-CrN45qz1.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