@classytic/arc 2.6.3 → 2.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +98 -3
  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-D7e77m8C.mjs} +25 -14
  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-D7WK0RXq.d.mts +23 -0
  34. package/dist/{errorHandler-r2595m8T.mjs → errorHandler-CH8wk1eD.mjs} +17 -2
  35. package/dist/{errorHandler-Do4vVQ1f.d.mts → errorHandler-pCpEtNd7.d.mts} +46 -2
  36. package/dist/{eventPlugin-Ba00swHF.mjs → eventPlugin-B6U_nCFU.mjs} +4 -3
  37. package/dist/{eventPlugin-DW45v4V5.d.mts → eventPlugin-CdvUoUna.d.mts} +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-B0extFr4.d.mts +640 -0
  50. package/dist/{index-gz6iuzCp.d.mts → index-BjShrzoj.d.mts} +47 -4
  51. package/dist/{index-CHeJa4Zd.d.mts → index-C9eYNjGR.d.mts} +1 -1
  52. package/dist/index.d.mts +9 -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 +8 -5
  59. package/dist/integrations/mcp/testing.d.mts +1 -1
  60. package/dist/integrations/mcp/testing.mjs +1 -1
  61. package/dist/integrations/webhooks.d.mts +58 -1
  62. package/dist/integrations/webhooks.mjs +78 -7
  63. package/dist/integrations/websocket.d.mts +7 -1
  64. package/dist/integrations/websocket.mjs +7 -1
  65. package/dist/{interface-DYH8AXGe.d.mts → interface-B91alUzq.d.mts} +151 -15
  66. package/dist/{mongodb-pMvOlR5_.d.mts → mongodb-B7zupyck.d.mts} +1 -1
  67. package/dist/{mongodb-kltrBPa1.d.mts → mongodb-Cgu9F1Nd.d.mts} +1 -1
  68. package/dist/{openapi-CBmZ6EQN.mjs → openapi-BBSTVcMm.mjs} +1 -1
  69. package/dist/org/index.d.mts +2 -2
  70. package/dist/org/index.mjs +1 -1
  71. package/dist/permissions/index.d.mts +4 -4
  72. package/dist/permissions/index.mjs +3 -2
  73. package/dist/{permissions-C8ImI8gC.mjs → permissions-CH4cNwJi.mjs} +358 -64
  74. package/dist/plugins/index.d.mts +52 -5
  75. package/dist/plugins/index.mjs +12 -11
  76. package/dist/plugins/response-cache.mjs +1 -1
  77. package/dist/plugins/tracing-entry.d.mts +1 -1
  78. package/dist/plugins/tracing-entry.mjs +1 -1
  79. package/dist/policies/index.d.mts +1 -1
  80. package/dist/presets/index.d.mts +3 -3
  81. package/dist/presets/index.mjs +1 -1
  82. package/dist/presets/multiTenant.d.mts +53 -3
  83. package/dist/presets/multiTenant.mjs +89 -47
  84. package/dist/{presets-BMfdy34e.mjs → presets-BFrGvvjL.mjs} +2 -2
  85. package/dist/{queryCachePlugin-DcmETvcB.d.mts → queryCachePlugin-Ckl71mkc.d.mts} +1 -1
  86. package/dist/{queryCachePlugin-XtFplYO9.mjs → queryCachePlugin-CwTpR04-.mjs} +2 -2
  87. package/dist/{redis-D0Qc-9EW.d.mts → redis-3TQxm2VZ.d.mts} +1 -1
  88. package/dist/{redis-stream-BW9UKLZM.d.mts → redis-stream-Dag5LFa9.d.mts} +1 -1
  89. package/dist/registry/index.d.mts +1 -1
  90. package/dist/registry/index.mjs +2 -2
  91. package/dist/replyHelpers-uDUIYh7u.mjs +40 -0
  92. package/dist/{resourceToTools-nCJWnG1r.mjs → resourceToTools-BJkoQoUP.mjs} +74 -25
  93. package/dist/rpc/index.d.mts +1 -1
  94. package/dist/rpc/index.mjs +1 -1
  95. package/dist/scope/index.d.mts +3 -2
  96. package/dist/scope/index.mjs +4 -3
  97. package/dist/{sse-BF7GR7IB.mjs → sse-6W0hjVS_.mjs} +2 -2
  98. package/dist/testing/index.d.mts +2 -2
  99. package/dist/testing/index.mjs +1 -1
  100. package/dist/types/index.d.mts +4 -3
  101. package/dist/types/index.mjs +1 -1
  102. package/dist/types--D3vvfdt.d.mts +286 -0
  103. package/dist/{types-By-5mIfn.d.mts → types-2FlNl0mL.d.mts} +44 -9
  104. package/dist/types-AOD8fxIw.mjs +229 -0
  105. package/dist/types-B4BNthET.d.mts +178 -0
  106. package/dist/{types-B4_TDdPe.d.mts → types-C5g2oRC7.d.mts} +18 -2
  107. package/dist/utils/index.d.mts +3 -3
  108. package/dist/utils/index.mjs +5 -5
  109. package/package.json +21 -6
  110. package/skills/arc/SKILL.md +314 -6
  111. package/skills/arc/references/integrations.md +32 -7
  112. package/skills/arc/references/mcp.md +31 -7
  113. package/skills/arc/references/multi-tenancy.md +208 -0
  114. package/skills/arc/references/production.md +69 -0
  115. package/dist/elevation-C_taLQrM.d.mts +0 -147
  116. package/dist/index-NGZksqM5.d.mts +0 -398
  117. package/dist/types-BNUccdcf.d.mts +0 -101
  118. package/dist/types-BhtYdxZU.mjs +0 -91
  119. /package/dist/{EventTransport-wc5hSLik.d.mts → EventTransport-C4VheKeC.d.mts} +0 -0
  120. /package/dist/{HookSystem-COkyWztM.mjs → HookSystem-D7lfx--K.mjs} +0 -0
  121. /package/dist/{ResourceRegistry-C6ngvOnn.mjs → ResourceRegistry-DsHiG9cL.mjs} +0 -0
  122. /package/dist/{caching-BSXB-Xr7.mjs → caching-5DtLwIqb.mjs} +0 -0
  123. /package/dist/{circuitBreaker-JP2GdJ4b.d.mts → circuitBreaker-BBPDt-J_.d.mts} +0 -0
  124. /package/dist/{circuitBreaker-BOBOpN2w.mjs → circuitBreaker-l18oRgL5.mjs} +0 -0
  125. /package/dist/{errors-CcVbl1-T.d.mts → errors-BS6lZvWy.d.mts} +0 -0
  126. /package/dist/{errors-NoQKsbAT.mjs → errors-Cg58SLNi.mjs} +0 -0
  127. /package/dist/{externalPaths-DpO-s7r8.d.mts → externalPaths-iba7jD3d.d.mts} +0 -0
  128. /package/dist/{fields-DFwdaWCq.d.mts → fields-D4nMDqnK.d.mts} +0 -0
  129. /package/dist/{interface-D_BWALyZ.d.mts → interface-CG7oRZjX.d.mts} +0 -0
  130. /package/dist/{interface-gr-7qo9j.d.mts → interface-CSbZdv_3.d.mts} +0 -0
  131. /package/dist/{logger-Dz3j1ItV.mjs → logger-DLg8-Ueg.mjs} +0 -0
  132. /package/dist/{memory-BFAYkf8H.mjs → memory-Cp7_cAko.mjs} +0 -0
  133. /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
  134. /package/dist/{mongodb-BuQ7fNTg.mjs → mongodb-B7X7P1P8.mjs} +0 -0
  135. /package/dist/{pluralize-CcT6qF0a.mjs → pluralize-Dckfq6US.mjs} +0 -0
  136. /package/dist/{registry-I-ogLgL9.mjs → registry-B3lRFBWo.mjs} +0 -0
  137. /package/dist/{requestContext-DYtmNpm5.mjs → requestContext-xHIKedG6.mjs} +0 -0
  138. /package/dist/{schemaConverter-DjzHpFam.mjs → schemaConverter-0TyONAwM.mjs} +0 -0
  139. /package/dist/{sessionManager-wbkYj2HL.d.mts → sessionManager-CEo9jwPI.d.mts} +0 -0
  140. /package/dist/{tracing-bz_U4EM1.d.mts → tracing-DEqdGkr-.d.mts} +0 -0
  141. /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
  142. /package/dist/{utils-Dc0WhlIl.mjs → utils-B-l6410F.mjs} +0 -0
  143. /package/dist/{versioning-BzfeHmhj.mjs → versioning-CdBbFefk.mjs} +0 -0
@@ -8,11 +8,11 @@ description: |
8
8
  Triggers: arc, fastify resource, defineResource, createApp, BaseController, arc preset,
9
9
  arc auth, arc events, arc jobs, arc websocket, arc mcp, arc plugin, arc testing, arc cli,
10
10
  arc permissions, arc hooks, arc pipeline, arc factory, arc cache, arc QueryCache.
11
- version: 2.6.2
11
+ version: 2.7.3
12
12
  license: MIT
13
13
  metadata:
14
14
  author: Classytic
15
- version: "2.6.2"
15
+ version: "2.7.1"
16
16
  tags:
17
17
  - fastify
18
18
  - rest-api
@@ -126,15 +126,57 @@ auth: false
126
126
 
127
127
  **Decorates:** `app.authenticate`, `app.optionalAuthenticate`, `app.authorize`
128
128
 
129
+ ### Better Auth + Mongoose populate bridge (`@classytic/arc/auth/mongoose`)
130
+
131
+ When BA uses `@better-auth/mongo-adapter`, it writes via the native `mongodb` driver and never registers Mongoose models. arc resources doing `Schema({ userId: { ref: 'user' } })` then throw `MissingSchemaError` on `.populate()`.
132
+
133
+ ```typescript
134
+ import mongoose from 'mongoose';
135
+ import { registerBetterAuthMongooseModels } from '@classytic/arc/auth/mongoose';
136
+
137
+ // Default: core only (user/session/account/verification). Plugins are opt-in.
138
+ registerBetterAuthMongooseModels(mongoose, {
139
+ plugins: ['organization', 'organization-teams', 'mcp'],
140
+ // For separate @better-auth/* packages (passkey, sso, api-key):
141
+ extraCollections: ['passkey', 'ssoProvider'],
142
+ // Optional:
143
+ usePlural: false, // matches mongodbAdapter({ usePlural })
144
+ modelOverrides: { user: 'profile' }, // for custom user.modelName configs
145
+ });
146
+ ```
147
+
148
+ **Plugin keys** (core BA only — separate packages use `extraCollections`):
149
+ - `organization` → `organization`, `member`, `invitation`
150
+ - `organization-teams` → `team`, `teamMember`
151
+ - `twoFactor` → `twoFactor`
152
+ - `jwt` → `jwks`
153
+ - `oidcProvider` / `oauthProvider` (alias) → `oauthApplication`, `oauthAccessToken`, `oauthConsent`
154
+ - `mcp` → reuses oidcProvider schema (per BA docs)
155
+ - `deviceAuthorization` → `deviceCode`
156
+
157
+ **Field-only plugins** (admin, username, phoneNumber, magicLink, emailOtp, anonymous, bearer, multiSession, siwe, lastLoginMethod, genericOAuth) need NO entry — `strict: false` stubs round-trip extra fields automatically.
158
+
159
+ Lives at a dedicated subpath so non-Mongoose users (Prisma/Drizzle/Kysely) never get Mongoose pulled into their bundle. Idempotent + de-dupes overlapping plugin sets, so `plugins: ['mcp', 'oidcProvider']` won't crash.
160
+
129
161
  ## Permissions
130
162
 
131
- Function-based. A `PermissionCheck` returns `boolean | { granted, reason?, filters? }`:
163
+ Function-based. A `PermissionCheck` returns `boolean | { granted, reason?, filters?, scope? }`:
132
164
 
133
165
  ```typescript
134
166
  import {
167
+ // Core
135
168
  allowPublic, requireAuth, requireRoles, requireOwnership,
169
+ // Org-bound
136
170
  requireOrgMembership, requireOrgRole, requireTeamMembership,
171
+ // Service / API key (OAuth-style)
172
+ requireServiceScope,
173
+ // App-defined scope dimensions (branch, project, region, …)
174
+ requireScopeContext,
175
+ // Parent-child org hierarchy
176
+ requireOrgInScope,
177
+ // Combinators
137
178
  allOf, anyOf, when, denyAll,
179
+ // Dynamic ACL
138
180
  createDynamicPermissionMatrix,
139
181
  } from '@classytic/arc';
140
182
 
@@ -147,6 +189,173 @@ permissions: {
147
189
  }
148
190
  ```
149
191
 
192
+ **Mixed human + machine routes** — accept both an org admin and an API key:
193
+
194
+ ```typescript
195
+ import { requireServiceScope } from '@classytic/arc';
196
+
197
+ permissions: {
198
+ // Human admins OR API keys with the right OAuth scope
199
+ create: anyOf(
200
+ requireOrgRole('admin'),
201
+ requireServiceScope('jobs:write'),
202
+ ),
203
+
204
+ // Org-bound API key with a specific scope (no human path)
205
+ bulkImport: allOf(
206
+ requireOrgMembership(), // accepts member, service, elevated
207
+ requireServiceScope('jobs:bulk-write'), // OAuth-style scope check
208
+ ),
209
+ }
210
+ ```
211
+
212
+ **Multi-level tenancy** — for app-defined scope dimensions beyond org/team
213
+ (branch, project, region, workspace, department, …):
214
+
215
+ ```typescript
216
+ import { requireScopeContext } from '@classytic/arc';
217
+ import { multiTenantPreset } from '@classytic/arc/presets';
218
+
219
+ // 1. Populate scope.context in your auth function (from headers, JWT claims,
220
+ // BA session fields — arc takes no position on the source).
221
+ authFn: async (request) => {
222
+ const session = await myAuth.getSession(request);
223
+ request.scope = {
224
+ kind: 'member',
225
+ userId: session.userId,
226
+ userRoles: session.userRoles,
227
+ organizationId: session.orgId,
228
+ orgRoles: session.orgRoles,
229
+ context: {
230
+ branchId: request.headers['x-branch-id'],
231
+ projectId: request.headers['x-project-id'],
232
+ },
233
+ };
234
+ }
235
+
236
+ // 2. Gate routes by context dimensions
237
+ permissions: {
238
+ branchAdmin: allOf(requireOrgRole('admin'), requireScopeContext('branchId')),
239
+ euOnly: requireScopeContext('region', 'eu'),
240
+ projectEdit: requireScopeContext({ projectId: undefined, region: 'eu' }),
241
+ }
242
+
243
+ // 3. Auto-filter resource queries across all dimensions in lockstep
244
+ defineResource({
245
+ name: 'job',
246
+ presets: [
247
+ multiTenantPreset({
248
+ tenantFields: [
249
+ { field: 'organizationId', type: 'org' },
250
+ { field: 'branchId', contextKey: 'branchId' },
251
+ { field: 'projectId', contextKey: 'projectId' },
252
+ ],
253
+ }),
254
+ ],
255
+ });
256
+ ```
257
+
258
+ Fail-closed: missing dimensions → 403 with the specific missing field name.
259
+ Elevated scopes (platform admins) apply whatever resolves and skip the rest
260
+ (cross-context bypass).
261
+
262
+ **Parent-child org hierarchy** — for holding companies, MSPs managing
263
+ multiple tenants, white-label parent → child accounts. Arc takes no position
264
+ on the source: your auth function loads the chain from your own org table.
265
+
266
+ ```typescript
267
+ import { requireOrgInScope } from '@classytic/arc';
268
+
269
+ // 1. Auth function loads ancestorOrgIds from your org table.
270
+ // Order is closest-first (immediate parent → root).
271
+ authFn: async (request) => {
272
+ const session = await myAuth.getSession(request);
273
+ const ancestors = await orgRepo.findAncestors(session.orgId);
274
+ request.scope = {
275
+ kind: 'member',
276
+ userId: session.userId,
277
+ userRoles: session.userRoles,
278
+ organizationId: session.orgId,
279
+ orgRoles: session.orgRoles,
280
+ ancestorOrgIds: ancestors.map(a => a.id), // ['acme-eu', 'acme-holding']
281
+ };
282
+ }
283
+
284
+ // 2. Gate routes — accepts current org or any ancestor in the chain
285
+ permissions: {
286
+ // GET /orgs/:orgId/jobs — caller can act on any org in their hierarchy
287
+ list: requireOrgInScope((ctx) => ctx.request.params.orgId),
288
+
289
+ // Static target (rare): one route, one specific org
290
+ holdingDashboard: requireOrgInScope('acme-holding'),
291
+
292
+ // Composed: must be admin AND target must be in hierarchy
293
+ childAdmin: allOf(
294
+ requireOrgRole('admin'),
295
+ requireOrgInScope((ctx) => ctx.request.params.orgId),
296
+ ),
297
+ }
298
+ ```
299
+
300
+ **No automatic inheritance** — every check is explicit. `multiTenantPreset`
301
+ does NOT auto-include ancestor data (would be a footgun). Sibling
302
+ subsidiaries naturally don't see each other's data because they aren't in
303
+ each other's chain. Elevated bypass still applies on the permission helper.
304
+
305
+ **Auth source agnostic** — `requireRoles()` checks platform roles
306
+ (`user.role`) AND org roles (`scope.orgRoles`) by default, so it works
307
+ identically with arc JWT, Better Auth user roles, and Better Auth org plugin.
308
+ `requireOrgMembership()` accepts `member`, `service` (API key), and
309
+ `elevated` scopes. `requireOrgRole()` is human-only by design — use
310
+ `anyOf(requireOrgRole(...), requireServiceScope(...))` for mixed routes.
311
+ `scope.context` and `scope.ancestorOrgIds` are populated by your own auth
312
+ function or adapter — arc doesn't bake in any specific dimension or transport.
313
+
314
+ ### RequestScope (quick reference)
315
+
316
+ Five kinds, all opt-in. Always read via accessors from `@classytic/arc/scope`,
317
+ never via direct property access.
318
+
319
+ ```typescript
320
+ type RequestScope =
321
+ | { kind: 'public' }
322
+ | { kind: 'authenticated'; userId?; userRoles? }
323
+ | { kind: 'member'; userId?; userRoles; organizationId; orgRoles; teamId?; context?; ancestorOrgIds? }
324
+ | { kind: 'service'; clientId; organizationId; scopes?; context?; ancestorOrgIds? }
325
+ | { kind: 'elevated'; userId?; organizationId?; elevatedBy; context?; ancestorOrgIds? };
326
+ ```
327
+
328
+ | Kind | Identity | Org context | Set by |
329
+ |---|---|---|---|
330
+ | `public` | none | none | Default for anonymous requests |
331
+ | `authenticated` | userId, userRoles | none | Logged in, no active org |
332
+ | `member` | userId, userRoles | organizationId + orgRoles (+ teamId, context, ancestorOrgIds) | BA org plugin / JWT custom auth |
333
+ | `service` | clientId, scopes | organizationId (required) | API key via `PermissionResult.scope` |
334
+ | `elevated` | userId | organizationId optional | Elevation plugin via `x-arc-scope: platform` header |
335
+
336
+ | Helper | `member` | `service` | `elevated` |
337
+ |---|---|---|---|
338
+ | `requireOrgMembership()` | ✅ | ✅ | ✅ |
339
+ | `requireOrgRole(roles)` | If role matches | ❌ deny w/ guidance | ✅ bypass |
340
+ | `requireServiceScope(scopes)` | ❌ | If scope matches | ✅ bypass |
341
+ | `requireScopeContext(...)` | If keys match | If keys match | ✅ bypass |
342
+ | `requireTeamMembership()` | If `teamId` set | (n/a) | ✅ bypass |
343
+ | `requireOrgInScope(target)` | If target in chain | If target in chain | ✅ bypass |
344
+
345
+ ```typescript
346
+ import {
347
+ isMember, isService, isElevated, hasOrgAccess,
348
+ getOrgId, getUserId, getOrgRoles, getServiceScopes,
349
+ getScopeContext, getAncestorOrgIds, isOrgInScope,
350
+ } from '@classytic/arc/scope';
351
+
352
+ if (hasOrgAccess(scope)) // member | service | elevated
353
+ if (isService(scope)) // narrows to API key
354
+ const orgId = getOrgId(scope); // member | service | elevated
355
+ const branch = getScopeContext(scope, 'branchId'); // custom dimension
356
+ isOrgInScope(scope, 'acme-holding'); // pure predicate (no elevated bypass)
357
+ ```
358
+
150
359
  **Custom permission:**
151
360
 
152
361
  ```typescript
@@ -187,15 +396,34 @@ permissions: { list: acl.canAction('product', 'read') }
187
396
  | `slugLookup` | GET /slug/:slug | `ISlugLookupController` | `{ slugField }` |
188
397
  | `tree` | GET /tree, GET /:parent/children | `ITreeController` | `{ parentField }` |
189
398
  | `ownedByUser` | none (middleware) | — | `{ ownerField }` |
190
- | `multiTenant` | none (middleware) | — | `{ tenantField }` |
399
+ | `multiTenant` | none (middleware) | — | `{ tenantField }` OR `{ tenantFields: TenantFieldSpec[] }` (2.7.1+) |
191
400
  | `audited` | none (middleware) | — | — |
192
401
  | `bulk` | POST/PATCH/DELETE /bulk | — | `{ operations?, maxCreateItems? }` |
193
402
 
194
403
  ```typescript
404
+ // Single-field (default, backwards compatible)
195
405
  presets: ['softDelete', { name: 'multiTenant', tenantField: 'organizationId' }]
406
+
407
+ // Multi-field — org + branch + project in lockstep (2.7.1+)
408
+ presets: [
409
+ multiTenantPreset({
410
+ tenantFields: [
411
+ { field: 'organizationId', type: 'org' }, // → getOrgId(scope)
412
+ { field: 'teamId', type: 'team' }, // → getTeamId(scope)
413
+ { field: 'branchId', contextKey: 'branchId' }, // → scope.context.branchId
414
+ { field: 'projectId', contextKey: 'projectId' },
415
+ ],
416
+ }),
417
+ ]
418
+
196
419
  // Bulk: presets: ['bulk'] or bulkPreset({ operations: ['createMany', 'updateMany'] })
197
420
  ```
198
421
 
422
+ `multiTenant` recognizes `member`, `service` (API key), and `elevated`
423
+ scopes uniformly via `hasOrgAccess()`. Multi-field uses fail-closed
424
+ semantics: missing dimensions → 403 with the specific missing field name.
425
+ Elevated scopes apply whatever resolves and skip the rest.
426
+
199
427
  ### tenantField — When to Use and When to Disable
200
428
 
201
429
  Arc defaults `tenantField` to `'organizationId'` on BaseController. This silently adds `{ organizationId: scope.organizationId }` to every query when the user has an org context. Correct for per-org resources, wrong for company-wide resources.
@@ -520,14 +748,26 @@ Connect Claude CLI: `claude mcp add --transport http my-api http://localhost:300
520
748
  **Auth** — three modes, user chooses: `false` | `getAuth()` (Better Auth OAuth 2.1) | custom function:
521
749
 
522
750
  ```typescript
751
+ // Human user auth
523
752
  auth: async (headers) => {
524
753
  if (headers['x-api-key'] !== process.env.MCP_KEY) return null;
525
754
  return { userId: 'bot', organizationId: 'org-1', roles: ['admin'] };
526
755
  },
756
+
757
+ // Service account / machine-to-machine (produces kind: "service" scope)
758
+ auth: async (headers) => ({
759
+ clientId: 'ingestion-pipeline',
760
+ organizationId: 'org-1',
761
+ scopes: ['read:products', 'write:events'],
762
+ }),
527
763
  ```
528
764
 
765
+ `auth: false` → `ctx.user` is `null`, `scope.kind` is `"public"`. Permission guards like `!!ctx.user` correctly block anonymous callers.
766
+
529
767
  **Guards** for custom tools: `guard(requireAuth, requireOrg, requireRole('admin'), handler)`
530
768
 
769
+ **Service scope**: When `clientId` is set in auth result, MCP produces `kind: "service"` RequestScope — works with `requireServiceScope()`, `getClientId()`, `getServiceScopes()`. No synthetic userId needed for machine principals.
770
+
531
771
  **Multi-tenancy**: `organizationId` from auth flows into BaseController org-scoping automatically.
532
772
 
533
773
  **Permission filters**: `PermissionResult.filters` from resource permissions flow into MCP tools — same as REST. Define once, works everywhere:
@@ -653,6 +893,55 @@ additionalRoutes: [{ preAuth: [(req) => { req.headers.authorization = `Bearer ${
653
893
  additionalRoutes: [{ streamResponse: true, handler: async (req, reply) => reply.send(stream) }]
654
894
  ```
655
895
 
896
+ ## DX Helpers (v2.7.3)
897
+
898
+ **Reply helpers** — consistent response envelopes (opt-in via `createApp({ replyHelpers: true })`):
899
+
900
+ ```typescript
901
+ return reply.ok({ name: 'MacBook' }); // → 200 { success: true, data: {...} }
902
+ return reply.ok(product, 201); // → 201 { success: true, data: {...} }
903
+ return reply.fail('Not found', 404); // → 404 { success: false, error: '...' }
904
+ return reply.fail(['err1', 'err2'], 422); // → 422 { success: false, errors: [...] }
905
+ return reply.paginated({ docs, total, page, limit });
906
+ return reply.stream(csvReadable, { contentType: 'text/csv', filename: 'export.csv' });
907
+ ```
908
+
909
+ **Error mappers** — class-based domain error → HTTP response (in `errorHandler` options):
910
+
911
+ ```typescript
912
+ const app = await createApp({
913
+ errorHandler: {
914
+ errorMappers: [{
915
+ type: AccountingError,
916
+ toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
917
+ }],
918
+ },
919
+ });
920
+ // Handlers just throw — Arc catches and maps automatically
921
+ ```
922
+
923
+ **BigInt serialization** — opt-in via `createApp({ serializeBigInt: true })`. Converts BigInt → Number in all JSON responses.
924
+
925
+ **Multipart body middleware** — opt-in file upload for CRUD routes:
926
+
927
+ ```typescript
928
+ import { multipartBody } from '@classytic/arc/middleware';
929
+
930
+ defineResource({
931
+ name: 'product',
932
+ adapter,
933
+ middlewares: { create: [multipartBody({ allowedMimeTypes: ['image/png', 'image/jpeg'], maxFileSize: 5 * 1024 * 1024 })] },
934
+ hooks: {
935
+ 'before:create': async (data) => {
936
+ if (data._files?.image) { data.imageUrl = await uploadToS3(data._files.image); delete data._files; }
937
+ return data;
938
+ },
939
+ },
940
+ });
941
+ ```
942
+
943
+ `multipartBody()` is a no-op for JSON requests — safe to always add.
944
+
656
945
  ## Subpath Imports
657
946
 
658
947
  ```typescript
@@ -660,6 +949,10 @@ import { defineResource, BaseController, allowPublic } from '@classytic/arc';
660
949
  import { createApp } from '@classytic/arc/factory';
661
950
  import { MemoryCacheStore, RedisCacheStore, QueryCache } from '@classytic/arc/cache';
662
951
  import { createBetterAuthAdapter, extractBetterAuthOpenApi } from '@classytic/arc/auth';
952
+ // 2.7.1+: optional Mongoose stub-models bridge for `populate()` against
953
+ // Better Auth collections — only loaded if you import it (subpath gate
954
+ // keeps Mongoose out of Prisma/Drizzle/Kysely bundles).
955
+ import { registerBetterAuthMongooseModels } from '@classytic/arc/auth/mongoose';
663
956
  import type { ExternalOpenApiPaths } from '@classytic/arc/docs';
664
957
  import { eventPlugin } from '@classytic/arc/events';
665
958
  import { RedisEventTransport } from '@classytic/arc/events/redis';
@@ -676,7 +969,21 @@ import { createTestApp } from '@classytic/arc/testing';
676
969
  import { Type, ArcListResponse } from '@classytic/arc/schemas';
677
970
  import { createStateMachine, CircuitBreaker, withCompensation, defineCompensation } from '@classytic/arc/utils';
678
971
  import { defineMigration } from '@classytic/arc/migrations';
679
- import { isMember, isElevated, getOrgId, getUserId, getUserRoles } from '@classytic/arc/scope';
972
+ // Scope accessors full surface as of 2.7.1
973
+ import {
974
+ // Type guards
975
+ isMember, isService, isElevated, isAuthenticated, hasOrgAccess,
976
+ // Identity / org accessors
977
+ getUserId, getUserRoles, getOrgId, getOrgRoles, getTeamId, getClientId,
978
+ // Service scopes (OAuth-style strings on API keys)
979
+ getServiceScopes,
980
+ // App-defined scope dimensions (branch, project, region, …)
981
+ getScopeContext, getScopeContextMap,
982
+ // Parent-child org hierarchy
983
+ getAncestorOrgIds, isOrgInScope,
984
+ // Generic request-side helper
985
+ getRequestScope,
986
+ } from '@classytic/arc/scope';
680
987
  import { createTenantKeyGenerator } from '@classytic/arc/scope';
681
988
  import { createRoleHierarchy } from '@classytic/arc/permissions';
682
989
  import { createServiceClient } from '@classytic/arc/rpc';
@@ -684,7 +991,7 @@ import { metricsPlugin, versioningPlugin } from '@classytic/arc/plugins';
684
991
  import { webhookPlugin } from '@classytic/arc/integrations/webhooks';
685
992
  import { mcpPlugin, createMcpServer, defineTool, definePrompt, fieldRulesToZod, resourceToTools } from '@classytic/arc/mcp';
686
993
  import { EventOutbox, MemoryOutboxStore } from '@classytic/arc/events';
687
- import { bulkPreset } from '@classytic/arc/presets';
994
+ import { bulkPreset, multiTenantPreset, type TenantFieldSpec } from '@classytic/arc/presets';
688
995
  ```
689
996
 
690
997
  ## References (Progressive Disclosure)
@@ -693,5 +1000,6 @@ import { bulkPreset } from '@classytic/arc/presets';
693
1000
  - **[events](references/events.md)** — Domain events, transports, retry, outbox pattern, auto-emission
694
1001
  - **[integrations](references/integrations.md)** — BullMQ jobs, WebSocket, EventGateway, Streamline, Webhooks
695
1002
  - **[mcp](references/mcp.md)** — MCP tools for AI agents, auto-generation from resources, custom tools, Better Auth OAuth 2.1
1003
+ - **[multi-tenancy](references/multi-tenancy.md)** — Scope ladder, `tenantField` read sites, `PermissionResult.scope`, API key auth without a separate auth plugin
696
1004
  - **[production](references/production.md)** — Health, audit, idempotency, tracing, metrics, versioning, SSE, QueryCache, bulk ops, saga, RPC schema versioning, tenant rate limiting
697
1005
  - **[testing](references/testing.md)** — Test app, mocks, data factories, in-memory MongoDB
@@ -297,7 +297,7 @@ All optional, gracefully degrade:
297
297
  import { webhookPlugin } from '@classytic/arc/integrations/webhooks';
298
298
  ```
299
299
 
300
- Fastify plugin that auto-dispatches Arc events to customer webhook endpoints with HMAC-SHA256 signing, delivery logging, and pluggable persistence.
300
+ Fastify plugin that auto-dispatches Arc events to customer webhook endpoints with HMAC-SHA256 signing, delivery logging, bounded concurrency, and pluggable persistence.
301
301
 
302
302
  ### Setup
303
303
 
@@ -309,6 +309,7 @@ await fastify.register(webhookPlugin, {
309
309
  store: myMongoWebhookStore, // implements WebhookStore { getAll, save, remove }
310
310
  timeout: 5000, // delivery timeout (default: 10000ms)
311
311
  maxLogEntries: 500, // ring buffer cap (default: 1000)
312
+ concurrency: 10, // max parallel deliveries per event (default: 5)
312
313
  });
313
314
  ```
314
315
 
@@ -338,21 +339,45 @@ await app.events.publish('order.created', { orderId: '123' });
338
339
  // Body: { type, payload, meta }
339
340
  ```
340
341
 
341
- ### HMAC Signing
342
+ Deliveries run with bounded concurrency (default: 5) — one slow endpoint won't block the rest. Set `concurrency: 1` for sequential delivery.
342
343
 
343
- Every delivery is signed with the subscription's secret using HMAC-SHA256:
344
+ ### HMAC Signing & Verification
345
+
346
+ **Outbound** — every delivery is signed with the subscription's secret:
344
347
 
345
348
  ```
346
349
  x-webhook-signature: sha256=a1b2c3...
347
350
  ```
348
351
 
349
- Verify on the receiving end:
352
+ **Inbound** verify with `verifySignature()` (timing-safe, never throws):
353
+
354
+ ```typescript
355
+ import { verifySignature } from '@classytic/arc/integrations/webhooks';
356
+
357
+ fastify.post('/webhooks/incoming', async (req, reply) => {
358
+ const sig = req.headers['x-webhook-signature'] as string;
359
+ if (!verifySignature(req.rawBody, secret, sig)) {
360
+ return reply.status(401).send({ error: 'Invalid signature' });
361
+ }
362
+ // handle event via req.headers['x-webhook-event']
363
+ });
364
+ ```
365
+
366
+ Accepts `string | Buffer` body, `string | undefined` signature. Configurable for non-Arc senders:
367
+
350
368
  ```typescript
351
- import { createHmac } from 'node:crypto';
352
- const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
353
- if (expected !== req.headers['x-webhook-signature']) throw new Error('Invalid signature');
369
+ // GitHub (same prefix, same algorithm — works with defaults)
370
+ verifySignature(body, secret, req.headers['x-hub-signature-256']);
371
+
372
+ // Custom algorithm / bare hex
373
+ verifySignature(body, secret, req.headers['x-custom-sig'], {
374
+ prefix: '', // bare hex, no prefix
375
+ algorithm: 'sha512', // non-default algorithm
376
+ });
354
377
  ```
355
378
 
379
+ **Note:** `req.rawBody` requires `fastify-raw-body` — JSON re-serialization breaks HMAC since field ordering differs.
380
+
356
381
  ### Delivery Log
357
382
 
358
383
  ```typescript
@@ -139,7 +139,7 @@ Arc doesn't enforce an auth strategy. You choose what fits.
139
139
  await app.register(mcpPlugin, { resources, auth: false });
140
140
  ```
141
141
 
142
- All tools open. Every request gets `{ userId: 'anonymous' }`.
142
+ All tools open. `ctx.user` is `null`, `scope.kind` is `"public"`. Permission guards like `!!ctx.user` correctly block — anonymous callers cannot bypass auth checks.
143
143
 
144
144
  ### 2. Better Auth OAuth 2.1 (production SaaS)
145
145
 
@@ -175,13 +175,27 @@ type McpAuthResolver = (headers: Record<string, string | undefined>) =>
175
175
  Promise<McpAuthResult | null> | McpAuthResult | null;
176
176
  ```
177
177
 
178
- Return `{ userId, organizationId? }` to allow. Return `null` to reject (401).
178
+ Return `McpAuthResult` to allow. Return `null` to reject (401).
179
+
180
+ **`McpAuthResult` fields:**
181
+ - `userId?` — human user ID (optional for machine principals)
182
+ - `organizationId?` — org scope
183
+ - `roles?` / `orgRoles?` — user roles
184
+ - `clientId?` — set this to produce `kind: "service"` scope (machine-to-machine)
185
+ - `scopes?` — OAuth scopes for service accounts
179
186
 
180
187
  ```typescript
181
- // API key
188
+ // Human user — API key
182
189
  auth: async (headers) => {
183
190
  if (headers['x-api-key'] !== process.env.MCP_API_KEY) return null;
184
- return { userId: 'service', organizationId: 'org-123' };
191
+ return { userId: 'alice', organizationId: 'org-123', roles: ['admin'] };
192
+ },
193
+
194
+ // Machine principal — service account (no userId needed)
195
+ auth: async (headers) => {
196
+ const key = headers['x-service-key'];
197
+ if (key !== process.env.SVC_KEY) return null;
198
+ return { clientId: 'ingestion-pipeline', organizationId: 'org-123', scopes: ['write:events'] };
185
199
  },
186
200
 
187
201
  // Gateway-validated JWT (token already verified upstream)
@@ -191,9 +205,6 @@ auth: async (headers) => {
191
205
  return userId ? { userId, organizationId: orgId } : null;
192
206
  },
193
207
 
194
- // Static org (trusted internal network)
195
- auth: async () => ({ userId: 'internal', organizationId: 'org-main' }),
196
-
197
208
  // Bearer token with custom validation
198
209
  auth: async (headers) => {
199
210
  const token = headers['authorization']?.replace('Bearer ', '');
@@ -203,6 +214,19 @@ auth: async (headers) => {
203
214
  },
204
215
  ```
205
216
 
217
+ ### Service Scope (machine-to-machine)
218
+
219
+ When `clientId` is present in the auth result, Arc produces `kind: "service"` RequestScope:
220
+
221
+ ```
222
+ auth resolver returns { clientId: 'pipeline-v2', organizationId: 'org-a', scopes: ['read:all'] }
223
+ → buildRequestContext sets _scope: { kind: 'service', clientId: 'pipeline-v2', organizationId: 'org-a', scopes: ['read:all'] }
224
+ → ctx.user is null (machine principals don't masquerade as users)
225
+ → isService(scope), getClientId(scope), getServiceScopes(scope) all work
226
+ ```
227
+
228
+ When `userId` is present (without `clientId`), Arc produces `kind: "member"` or `kind: "authenticated"` as before.
229
+
206
230
  ### Multi-Tenancy
207
231
 
208
232
  The `organizationId` from auth flows into BaseController's org-scoping automatically: