@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.
- package/README.md +98 -3
- package/dist/{BaseController-DzRtluEF.mjs → BaseController-CpMfCXdn.mjs} +134 -16
- package/dist/adapters/index.d.mts +2 -2
- package/dist/adapters/index.mjs +1 -1
- package/dist/{adapters-gM-WYjNe.mjs → adapters-BxGgSHjj.mjs} +1 -9
- package/dist/applyPermissionResult-D6GPMsvh.mjs +37 -0
- package/dist/audit/index.d.mts +1 -1
- package/dist/audit/index.mjs +1 -1
- package/dist/audit/mongodb.d.mts +1 -1
- package/dist/audit/mongodb.mjs +1 -1
- package/dist/auth/index.d.mts +4 -4
- package/dist/auth/index.mjs +7 -6
- package/dist/auth/mongoose.d.mts +191 -0
- package/dist/auth/mongoose.mjs +73 -0
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi-lz0IRbXJ.mjs → betterAuthOpenApi-CCw3YX0g.mjs} +1 -1
- package/dist/cache/index.d.mts +2 -2
- package/dist/cache/index.mjs +2 -2
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +1 -1
- package/dist/cli/commands/init.mjs +7 -5
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -4
- package/dist/{core-C1XCMtqM.mjs → core-BWekSEju.mjs} +41 -13
- package/dist/{createApp-D2w0LdYJ.mjs → createApp-D7e77m8C.mjs} +25 -14
- package/dist/{defineResource-wWMBB4GP.mjs → defineResource-DZzyl4a4.mjs} +42 -37
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +1 -1
- package/dist/dynamic/index.d.mts +2 -2
- package/dist/dynamic/index.mjs +2 -2
- package/dist/{elevation-BEdACOLB.mjs → elevation-By_p2lnn.mjs} +1 -1
- package/dist/elevation-D7WK0RXq.d.mts +23 -0
- package/dist/{errorHandler-r2595m8T.mjs → errorHandler-CH8wk1eD.mjs} +17 -2
- package/dist/{errorHandler-Do4vVQ1f.d.mts → errorHandler-pCpEtNd7.d.mts} +46 -2
- package/dist/{eventPlugin-Ba00swHF.mjs → eventPlugin-B6U_nCFU.mjs} +4 -3
- package/dist/{eventPlugin-DW45v4V5.d.mts → eventPlugin-CdvUoUna.d.mts} +1 -1
- package/dist/events/index.d.mts +3 -3
- package/dist/events/index.mjs +1 -1
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +3 -3
- package/dist/idempotency/mongodb.d.mts +1 -1
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/index-B0extFr4.d.mts +640 -0
- package/dist/{index-gz6iuzCp.d.mts → index-BjShrzoj.d.mts} +47 -4
- package/dist/{index-CHeJa4Zd.d.mts → index-C9eYNjGR.d.mts} +1 -1
- package/dist/index.d.mts +9 -8
- package/dist/index.mjs +10 -9
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +8 -5
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/webhooks.d.mts +58 -1
- package/dist/integrations/webhooks.mjs +78 -7
- package/dist/integrations/websocket.d.mts +7 -1
- package/dist/integrations/websocket.mjs +7 -1
- package/dist/{interface-DYH8AXGe.d.mts → interface-B91alUzq.d.mts} +151 -15
- package/dist/{mongodb-pMvOlR5_.d.mts → mongodb-B7zupyck.d.mts} +1 -1
- package/dist/{mongodb-kltrBPa1.d.mts → mongodb-Cgu9F1Nd.d.mts} +1 -1
- package/dist/{openapi-CBmZ6EQN.mjs → openapi-BBSTVcMm.mjs} +1 -1
- package/dist/org/index.d.mts +2 -2
- package/dist/org/index.mjs +1 -1
- package/dist/permissions/index.d.mts +4 -4
- package/dist/permissions/index.mjs +3 -2
- package/dist/{permissions-C8ImI8gC.mjs → permissions-CH4cNwJi.mjs} +358 -64
- package/dist/plugins/index.d.mts +52 -5
- package/dist/plugins/index.mjs +12 -11
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/policies/index.d.mts +1 -1
- package/dist/presets/index.d.mts +3 -3
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +53 -3
- package/dist/presets/multiTenant.mjs +89 -47
- package/dist/{presets-BMfdy34e.mjs → presets-BFrGvvjL.mjs} +2 -2
- package/dist/{queryCachePlugin-DcmETvcB.d.mts → queryCachePlugin-Ckl71mkc.d.mts} +1 -1
- package/dist/{queryCachePlugin-XtFplYO9.mjs → queryCachePlugin-CwTpR04-.mjs} +2 -2
- package/dist/{redis-D0Qc-9EW.d.mts → redis-3TQxm2VZ.d.mts} +1 -1
- package/dist/{redis-stream-BW9UKLZM.d.mts → redis-stream-Dag5LFa9.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/replyHelpers-uDUIYh7u.mjs +40 -0
- package/dist/{resourceToTools-nCJWnG1r.mjs → resourceToTools-BJkoQoUP.mjs} +74 -25
- package/dist/rpc/index.d.mts +1 -1
- package/dist/rpc/index.mjs +1 -1
- package/dist/scope/index.d.mts +3 -2
- package/dist/scope/index.mjs +4 -3
- package/dist/{sse-BF7GR7IB.mjs → sse-6W0hjVS_.mjs} +2 -2
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/index.mjs +1 -1
- package/dist/types/index.d.mts +4 -3
- package/dist/types/index.mjs +1 -1
- package/dist/types--D3vvfdt.d.mts +286 -0
- package/dist/{types-By-5mIfn.d.mts → types-2FlNl0mL.d.mts} +44 -9
- package/dist/types-AOD8fxIw.mjs +229 -0
- package/dist/types-B4BNthET.d.mts +178 -0
- package/dist/{types-B4_TDdPe.d.mts → types-C5g2oRC7.d.mts} +18 -2
- package/dist/utils/index.d.mts +3 -3
- package/dist/utils/index.mjs +5 -5
- package/package.json +21 -6
- package/skills/arc/SKILL.md +314 -6
- package/skills/arc/references/integrations.md +32 -7
- package/skills/arc/references/mcp.md +31 -7
- package/skills/arc/references/multi-tenancy.md +208 -0
- package/skills/arc/references/production.md +69 -0
- package/dist/elevation-C_taLQrM.d.mts +0 -147
- package/dist/index-NGZksqM5.d.mts +0 -398
- package/dist/types-BNUccdcf.d.mts +0 -101
- package/dist/types-BhtYdxZU.mjs +0 -91
- /package/dist/{EventTransport-wc5hSLik.d.mts → EventTransport-C4VheKeC.d.mts} +0 -0
- /package/dist/{HookSystem-COkyWztM.mjs → HookSystem-D7lfx--K.mjs} +0 -0
- /package/dist/{ResourceRegistry-C6ngvOnn.mjs → ResourceRegistry-DsHiG9cL.mjs} +0 -0
- /package/dist/{caching-BSXB-Xr7.mjs → caching-5DtLwIqb.mjs} +0 -0
- /package/dist/{circuitBreaker-JP2GdJ4b.d.mts → circuitBreaker-BBPDt-J_.d.mts} +0 -0
- /package/dist/{circuitBreaker-BOBOpN2w.mjs → circuitBreaker-l18oRgL5.mjs} +0 -0
- /package/dist/{errors-CcVbl1-T.d.mts → errors-BS6lZvWy.d.mts} +0 -0
- /package/dist/{errors-NoQKsbAT.mjs → errors-Cg58SLNi.mjs} +0 -0
- /package/dist/{externalPaths-DpO-s7r8.d.mts → externalPaths-iba7jD3d.d.mts} +0 -0
- /package/dist/{fields-DFwdaWCq.d.mts → fields-D4nMDqnK.d.mts} +0 -0
- /package/dist/{interface-D_BWALyZ.d.mts → interface-CG7oRZjX.d.mts} +0 -0
- /package/dist/{interface-gr-7qo9j.d.mts → interface-CSbZdv_3.d.mts} +0 -0
- /package/dist/{logger-Dz3j1ItV.mjs → logger-DLg8-Ueg.mjs} +0 -0
- /package/dist/{memory-BFAYkf8H.mjs → memory-Cp7_cAko.mjs} +0 -0
- /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
- /package/dist/{mongodb-BuQ7fNTg.mjs → mongodb-B7X7P1P8.mjs} +0 -0
- /package/dist/{pluralize-CcT6qF0a.mjs → pluralize-Dckfq6US.mjs} +0 -0
- /package/dist/{registry-I-ogLgL9.mjs → registry-B3lRFBWo.mjs} +0 -0
- /package/dist/{requestContext-DYtmNpm5.mjs → requestContext-xHIKedG6.mjs} +0 -0
- /package/dist/{schemaConverter-DjzHpFam.mjs → schemaConverter-0TyONAwM.mjs} +0 -0
- /package/dist/{sessionManager-wbkYj2HL.d.mts → sessionManager-CEo9jwPI.d.mts} +0 -0
- /package/dist/{tracing-bz_U4EM1.d.mts → tracing-DEqdGkr-.d.mts} +0 -0
- /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
- /package/dist/{utils-Dc0WhlIl.mjs → utils-B-l6410F.mjs} +0 -0
- /package/dist/{versioning-BzfeHmhj.mjs → versioning-CdBbFefk.mjs} +0 -0
package/skills/arc/SKILL.md
CHANGED
|
@@ -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.
|
|
11
|
+
version: 2.7.3
|
|
12
12
|
license: MIT
|
|
13
13
|
metadata:
|
|
14
14
|
author: Classytic
|
|
15
|
-
version: "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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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.
|
|
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 `
|
|
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: '
|
|
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:
|