@classytic/arc 2.6.3 → 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -1
- 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-B_nvKNAQ.mjs} +11 -11
- 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-Dm-HTBCt.d.mts +23 -0
- package/dist/{errorHandler-Do4vVQ1f.d.mts → errorHandler-COa51ho_.d.mts} +1 -1
- package/dist/{errorHandler-r2595m8T.mjs → errorHandler-DXUttWEO.mjs} +1 -1
- package/dist/{eventPlugin-DW45v4V5.d.mts → eventPlugin-BgLxJkIB.d.mts} +1 -1
- package/dist/{eventPlugin-Ba00swHF.mjs → eventPlugin-DsaNNXzZ.mjs} +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-BYpRGXif.d.mts +640 -0
- package/dist/{index-gz6iuzCp.d.mts → index-KXM8_JmQ.d.mts} +47 -4
- package/dist/{index-CHeJa4Zd.d.mts → index-StgFaQKD.d.mts} +1 -1
- package/dist/index.d.mts +8 -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 +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/{interface-DYH8AXGe.d.mts → interface-Dwzqt4mn.d.mts} +150 -14
- package/dist/{mongodb-pMvOlR5_.d.mts → mongodb-Bq90j-Uj.d.mts} +1 -1
- package/dist/{mongodb-kltrBPa1.d.mts → mongodb-DdyYlIXg.d.mts} +1 -1
- package/dist/{openapi-CBmZ6EQN.mjs → openapi-C5UhIeWu.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 +4 -4
- package/dist/plugins/index.mjs +10 -10
- 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-Bw8XyJpX.d.mts} +1 -1
- package/dist/{queryCachePlugin-XtFplYO9.mjs → queryCachePlugin-CwTpR04-.mjs} +2 -2
- package/dist/{redis-D0Qc-9EW.d.mts → redis-CyCntzTO.d.mts} +1 -1
- package/dist/{redis-stream-BW9UKLZM.d.mts → redis-stream-We_Ucl9-.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{resourceToTools-nCJWnG1r.mjs → resourceToTools-CkVSSzKg.mjs} +64 -21
- 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-Bp3dabF1.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-AOD8fxIw.mjs +229 -0
- package/dist/types-CNEbix8T.d.mts +286 -0
- package/dist/{types-B4_TDdPe.d.mts → types-ClmkMDK1.d.mts} +1 -1
- package/dist/{types-By-5mIfn.d.mts → types-D0qf0Mf4.d.mts} +9 -9
- package/dist/types-DPsC0taJ.d.mts +178 -0
- package/dist/utils/index.d.mts +3 -3
- package/dist/utils/index.mjs +5 -5
- package/package.json +17 -5
- package/skills/arc/SKILL.md +253 -6
- package/skills/arc/references/multi-tenancy.md +208 -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-CUpRK_Lg.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-DwxrljLB.d.mts} +0 -0
- /package/dist/{circuitBreaker-BOBOpN2w.mjs → circuitBreaker-l18oRgL5.mjs} +0 -0
- /package/dist/{errors-CcVbl1-T.d.mts → errors-CCSsMpXE.d.mts} +0 -0
- /package/dist/{errors-NoQKsbAT.mjs → errors-Cg58SLNi.mjs} +0 -0
- /package/dist/{externalPaths-DpO-s7r8.d.mts → externalPaths-Dg7OLsKo.d.mts} +0 -0
- /package/dist/{fields-DFwdaWCq.d.mts → fields-CYuLMJPD.d.mts} +0 -0
- /package/dist/{interface-gr-7qo9j.d.mts → interface-B9rHWPxD.d.mts} +0 -0
- /package/dist/{interface-D_BWALyZ.d.mts → interface-CnluRL4_.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-mlgxkYI3.mjs} +0 -0
- /package/dist/{pluralize-CcT6qF0a.mjs → pluralize-COpOVar8.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-IW4sbIea.d.mts} +0 -0
- /package/dist/{tracing-bz_U4EM1.d.mts → tracing-65B51Dw3.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-aUUVziBY.mjs} +0 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { r as RequestScope } from "./types-CNEbix8T.mjs";
|
|
2
|
+
import { FastifyRequest } from "fastify";
|
|
3
|
+
|
|
4
|
+
//#region src/permissions/types.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* User base interface - minimal shape Arc expects
|
|
7
|
+
* Your actual User can have any additional fields
|
|
8
|
+
*/
|
|
9
|
+
interface UserBase {
|
|
10
|
+
id?: string;
|
|
11
|
+
_id?: string;
|
|
12
|
+
/** User roles — string (comma-separated), string[], or undefined. Matches Better Auth's admin plugin pattern. */
|
|
13
|
+
role?: string | string[];
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Extract normalized roles from a user object.
|
|
18
|
+
*
|
|
19
|
+
* Reads `user.role` which can be:
|
|
20
|
+
* - A comma-separated string: `"superadmin,user"` (Better Auth admin plugin)
|
|
21
|
+
* - A string array: `["admin", "user"]` (JWT / custom auth)
|
|
22
|
+
* - A single string: `"admin"`
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Normalize a raw role value (string, comma-separated string, or array) into a string[].
|
|
26
|
+
* Shared low-level helper used by both getUserRoles() and the Better Auth adapter.
|
|
27
|
+
*/
|
|
28
|
+
declare function normalizeRoles(value: unknown): string[];
|
|
29
|
+
declare function getUserRoles(user: UserBase | null | undefined): string[];
|
|
30
|
+
/**
|
|
31
|
+
* Context passed to permission check functions
|
|
32
|
+
*/
|
|
33
|
+
interface PermissionContext<TDoc = Record<string, unknown>> {
|
|
34
|
+
/** Authenticated user or null if unauthenticated */
|
|
35
|
+
user: UserBase | null;
|
|
36
|
+
/** Fastify request object */
|
|
37
|
+
request: FastifyRequest;
|
|
38
|
+
/** Resource name being accessed */
|
|
39
|
+
resource: string;
|
|
40
|
+
/** Action being performed (list, get, create, update, delete, or custom operation name) */
|
|
41
|
+
action: string;
|
|
42
|
+
/** Resource ID for single-resource operations (shortcut for params.id) */
|
|
43
|
+
resourceId?: string;
|
|
44
|
+
/** All route parameters (slug, parentId, custom params, etc.) */
|
|
45
|
+
params?: Record<string, string>;
|
|
46
|
+
/** Request body data */
|
|
47
|
+
data?: Partial<TDoc> | Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Result from a permission check.
|
|
51
|
+
*
|
|
52
|
+
* Permission checks can do three things:
|
|
53
|
+
* 1. **Grant or deny** access (`granted`, `reason`)
|
|
54
|
+
* 2. **Attach row-level filters** (`filters`) — these merge into `_policyFilters`
|
|
55
|
+
* and narrow subsequent queries (e.g. `{ userId: ctx.user.id }` for ownership)
|
|
56
|
+
* 3. **Install the request scope** (`scope`) — when a custom authenticator wants
|
|
57
|
+
* to set tenant/identity context directly from the permission layer, without
|
|
58
|
+
* relying on a separate auth plugin
|
|
59
|
+
*
|
|
60
|
+
* The `scope` field is the clean integration point for custom auth strategies
|
|
61
|
+
* (API keys, service accounts, gateway headers). When present, Arc writes it to
|
|
62
|
+
* `request.scope` which then flows through the normal tenant-filtering pipeline
|
|
63
|
+
* (QueryResolver + AccessControl). This is the idiomatic way to wire non-Better-Auth
|
|
64
|
+
* identity providers into Arc's multi-tenancy without touching the auth plugin layer.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* // Custom API-key auth — grant access AND install a service scope in one step
|
|
69
|
+
* export function requireApiKey(): PermissionCheck {
|
|
70
|
+
* return async ({ request }) => {
|
|
71
|
+
* const apiKey = request.headers['x-api-key'] as string | undefined;
|
|
72
|
+
* if (!apiKey) return { granted: false, reason: 'Missing API key' };
|
|
73
|
+
*
|
|
74
|
+
* const client = await ClientModel.findOne({ apiKey });
|
|
75
|
+
* if (!client) return { granted: false, reason: 'Invalid API key' };
|
|
76
|
+
*
|
|
77
|
+
* return {
|
|
78
|
+
* granted: true,
|
|
79
|
+
* // Install service scope — Arc writes this to request.scope automatically,
|
|
80
|
+
* // and tenantField filtering picks it up via metadata._scope
|
|
81
|
+
* scope: {
|
|
82
|
+
* kind: 'service',
|
|
83
|
+
* clientId: String(client._id),
|
|
84
|
+
* organizationId: String(client.companyId),
|
|
85
|
+
* scopes: client.allowedScopes,
|
|
86
|
+
* },
|
|
87
|
+
* // Optional row-level narrowing (e.g. per-project API keys)
|
|
88
|
+
* filters: client.projectId ? { projectId: client.projectId } : undefined,
|
|
89
|
+
* };
|
|
90
|
+
* };
|
|
91
|
+
* }
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
interface PermissionResult {
|
|
95
|
+
/** Whether access is granted */
|
|
96
|
+
granted: boolean;
|
|
97
|
+
/** Reason for denial (for error messages) */
|
|
98
|
+
reason?: string;
|
|
99
|
+
/** Query filters to apply (for ownership / row-level security patterns) */
|
|
100
|
+
filters?: Record<string, unknown>;
|
|
101
|
+
/**
|
|
102
|
+
* Install this scope on `request.scope` when granted. Flows through to
|
|
103
|
+
* `metadata._scope` and is read by QueryResolver / AccessControl for
|
|
104
|
+
* tenant-field filtering. Use this to wire custom auth (API keys, service
|
|
105
|
+
* accounts, gateway headers) into Arc's multi-tenancy without a separate
|
|
106
|
+
* auth plugin.
|
|
107
|
+
*/
|
|
108
|
+
scope?: RequestScope;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Permission Check Function
|
|
112
|
+
*
|
|
113
|
+
* THE ONLY way to define permissions in Arc.
|
|
114
|
+
* Returns boolean, PermissionResult, or Promise of either.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```typescript
|
|
118
|
+
* // Simple boolean return
|
|
119
|
+
* const isAdmin: PermissionCheck = (ctx) => getUserRoles(ctx.user).includes('admin');
|
|
120
|
+
*
|
|
121
|
+
* // With filters for ownership
|
|
122
|
+
* const ownedByUser: PermissionCheck = (ctx) => ({
|
|
123
|
+
* granted: true,
|
|
124
|
+
* filters: { userId: ctx.user?.id }
|
|
125
|
+
* });
|
|
126
|
+
*
|
|
127
|
+
* // Async check
|
|
128
|
+
* const canAccessOrg: PermissionCheck = async (ctx) => {
|
|
129
|
+
* const isMember = await checkMembership(ctx.user?.id, ctx.organizationId);
|
|
130
|
+
* return { granted: isMember, reason: isMember ? undefined : 'Not a member' };
|
|
131
|
+
* };
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
type PermissionCheck<TDoc = Record<string, unknown>> = ((context: PermissionContext<TDoc>) => boolean | PermissionResult | Promise<boolean | PermissionResult>) & PermissionCheckMeta;
|
|
135
|
+
/**
|
|
136
|
+
* Optional metadata attached to permission check functions.
|
|
137
|
+
* Used for OpenAPI docs, introspection, and route-level auth decisions.
|
|
138
|
+
*
|
|
139
|
+
* Each helper from `permissions/index.ts` writes its own discriminating tag
|
|
140
|
+
* so downstream tooling (OpenAPI generator, MCP resource builder, route
|
|
141
|
+
* audit utilities) can read off the requirement without re-parsing the
|
|
142
|
+
* function body. All fields are optional — only the helpers that emit them
|
|
143
|
+
* set them.
|
|
144
|
+
*/
|
|
145
|
+
interface PermissionCheckMeta {
|
|
146
|
+
/** Set by allowPublic() — marks the endpoint as publicly accessible */
|
|
147
|
+
_isPublic?: boolean;
|
|
148
|
+
/** Set by requireRoles() — the roles required for access */
|
|
149
|
+
_roles?: readonly string[];
|
|
150
|
+
/** Set by requireOrgMembership() — org-level permission type */
|
|
151
|
+
_orgPermission?: string;
|
|
152
|
+
/** Set by requireOrgRole() — the org roles required for access */
|
|
153
|
+
_orgRoles?: readonly string[];
|
|
154
|
+
/** Set by requireTeamMembership() — team-level permission type */
|
|
155
|
+
_teamPermission?: string;
|
|
156
|
+
/**
|
|
157
|
+
* Set by requireServiceScope() — the OAuth-style scope strings the
|
|
158
|
+
* caller's `service` identity must hold (any-match logic, parallels
|
|
159
|
+
* `_orgRoles`).
|
|
160
|
+
*/
|
|
161
|
+
_serviceScopes?: readonly string[];
|
|
162
|
+
/**
|
|
163
|
+
* Set by requireScopeContext() — the app-defined scope dimensions the
|
|
164
|
+
* caller must satisfy. Map keys are dimension names (`branchId`,
|
|
165
|
+
* `projectId`, etc.); values are the required string OR `undefined`
|
|
166
|
+
* for "must be present, any value".
|
|
167
|
+
*/
|
|
168
|
+
_scopeContext?: Record<string, string | undefined>;
|
|
169
|
+
/**
|
|
170
|
+
* Set by requireOrgInScope() — the target organization that must appear
|
|
171
|
+
* in the caller's org chain (current org or `ancestorOrgIds`). Either
|
|
172
|
+
* a static org id or a function extracting it from the request context
|
|
173
|
+
* (e.g. from route params).
|
|
174
|
+
*/
|
|
175
|
+
_orgInScopeTarget?: string | ((ctx: PermissionContext) => string | undefined);
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
export { getUserRoles as a, UserBase as i, PermissionContext as n, normalizeRoles as o, PermissionResult as r, PermissionCheck as t };
|
package/dist/utils/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { G as OpenApiSchemas, Q as QueryParserInterface, q as ParsedQuery, u as AnyRecord } from "../interface-
|
|
2
|
-
import { a as NotFoundError, c as RateLimitError, d as ValidationError, i as ForbiddenError, l as ServiceUnavailableError, m as isArcError, n as ConflictError, o as OrgAccessDeniedError, p as createError, r as ErrorDetails, s as OrgRequiredError, t as ArcError, u as UnauthorizedError } from "../errors-
|
|
3
|
-
import { a as CircuitBreakerStats, c as createCircuitBreakerRegistry, i as CircuitBreakerRegistry, n as CircuitBreakerError, o as CircuitState, r as CircuitBreakerOptions, s as createCircuitBreaker, t as CircuitBreaker } from "../circuitBreaker-
|
|
1
|
+
import { G as OpenApiSchemas, Q as QueryParserInterface, q as ParsedQuery, u as AnyRecord } from "../interface-Dwzqt4mn.mjs";
|
|
2
|
+
import { a as NotFoundError, c as RateLimitError, d as ValidationError, i as ForbiddenError, l as ServiceUnavailableError, m as isArcError, n as ConflictError, o as OrgAccessDeniedError, p as createError, r as ErrorDetails, s as OrgRequiredError, t as ArcError, u as UnauthorizedError } from "../errors-CCSsMpXE.mjs";
|
|
3
|
+
import { a as CircuitBreakerStats, c as createCircuitBreakerRegistry, i as CircuitBreakerRegistry, n as CircuitBreakerError, o as CircuitState, r as CircuitBreakerOptions, s as createCircuitBreaker, t as CircuitBreaker } from "../circuitBreaker-DwxrljLB.mjs";
|
|
4
4
|
import { FastifyInstance } from "fastify";
|
|
5
5
|
|
|
6
6
|
//#region src/utils/compensation.d.ts
|
package/dist/utils/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { n as createQueryParser, t as ArcQueryParser } from "../queryParser-CgCtsjti.mjs";
|
|
2
|
-
import { a as createCircuitBreaker, i as CircuitState, n as CircuitBreakerError, o as createCircuitBreakerRegistry, r as CircuitBreakerRegistry, t as CircuitBreaker } from "../circuitBreaker-
|
|
3
|
-
import { _ as defineCompensation, a as getListQueryParams, c as listResponse, d as paginateWrapper, f as paginationSchema, g as wrapResponse, h as successResponseSchema, i as getDefaultCrudSchemas, l as messageWrapper, m as responses, n as deleteResponse, o as itemResponse, p as queryParams, r as errorResponseSchema, s as itemWrapper, t as createStateMachine, u as mutationResponse, v as withCompensation } from "../utils-
|
|
4
|
-
import { a as OrgAccessDeniedError, c as ServiceUnavailableError, f as createError, i as NotFoundError, l as UnauthorizedError, n as ConflictError, o as OrgRequiredError, p as isArcError, r as ForbiddenError, s as RateLimitError, t as ArcError, u as ValidationError } from "../errors-
|
|
5
|
-
import { a as toJsonSchema, i as isZodSchema, n as convertRouteSchema, r as isJsonSchema, t as convertOpenApiSchemas } from "../schemaConverter-
|
|
6
|
-
import { t as hasEvents } from "../typeGuards-
|
|
2
|
+
import { a as createCircuitBreaker, i as CircuitState, n as CircuitBreakerError, o as createCircuitBreakerRegistry, r as CircuitBreakerRegistry, t as CircuitBreaker } from "../circuitBreaker-l18oRgL5.mjs";
|
|
3
|
+
import { _ as defineCompensation, a as getListQueryParams, c as listResponse, d as paginateWrapper, f as paginationSchema, g as wrapResponse, h as successResponseSchema, i as getDefaultCrudSchemas, l as messageWrapper, m as responses, n as deleteResponse, o as itemResponse, p as queryParams, r as errorResponseSchema, s as itemWrapper, t as createStateMachine, u as mutationResponse, v as withCompensation } from "../utils-B-l6410F.mjs";
|
|
4
|
+
import { a as OrgAccessDeniedError, c as ServiceUnavailableError, f as createError, i as NotFoundError, l as UnauthorizedError, n as ConflictError, o as OrgRequiredError, p as isArcError, r as ForbiddenError, s as RateLimitError, t as ArcError, u as ValidationError } from "../errors-Cg58SLNi.mjs";
|
|
5
|
+
import { a as toJsonSchema, i as isZodSchema, n as convertRouteSchema, r as isJsonSchema, t as convertOpenApiSchemas } from "../schemaConverter-0TyONAwM.mjs";
|
|
6
|
+
import { t as hasEvents } from "../typeGuards-CcFZXgU7.mjs";
|
|
7
7
|
export { ArcError, ArcQueryParser, CircuitBreaker, CircuitBreakerError, CircuitBreakerRegistry, CircuitState, ConflictError, ForbiddenError, NotFoundError, OrgAccessDeniedError, OrgRequiredError, RateLimitError, ServiceUnavailableError, UnauthorizedError, ValidationError, convertOpenApiSchemas, convertRouteSchema, createCircuitBreaker, createCircuitBreakerRegistry, createError, createQueryParser, createStateMachine, defineCompensation, deleteResponse, errorResponseSchema, getDefaultCrudSchemas, getListQueryParams, hasEvents, isArcError, isJsonSchema, isZodSchema, itemResponse, itemWrapper, listResponse, messageWrapper, mutationResponse, paginateWrapper, paginationSchema, queryParams, responses, successResponseSchema, toJsonSchema, withCompensation, wrapResponse };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/arc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.1",
|
|
4
4
|
"description": "Resource-oriented backend framework for Fastify — clean, minimal, powerful, tree-shakable",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -168,6 +168,10 @@
|
|
|
168
168
|
"types": "./dist/auth/redis-session.d.mts",
|
|
169
169
|
"default": "./dist/auth/redis-session.mjs"
|
|
170
170
|
},
|
|
171
|
+
"./auth/mongoose": {
|
|
172
|
+
"types": "./dist/auth/mongoose.d.mts",
|
|
173
|
+
"default": "./dist/auth/mongoose.mjs"
|
|
174
|
+
},
|
|
171
175
|
"./plugins/response-cache": {
|
|
172
176
|
"types": "./dist/plugins/response-cache.d.mts",
|
|
173
177
|
"default": "./dist/plugins/response-cache.mjs"
|
|
@@ -220,7 +224,7 @@
|
|
|
220
224
|
"node": ">=22"
|
|
221
225
|
},
|
|
222
226
|
"peerDependencies": {
|
|
223
|
-
"@classytic/mongokit": ">=3.5.
|
|
227
|
+
"@classytic/mongokit": ">=3.5.5",
|
|
224
228
|
"@classytic/streamline": ">=2.0.0",
|
|
225
229
|
"@fastify/cors": ">=11.0.0",
|
|
226
230
|
"@fastify/helmet": ">=13.0.0",
|
|
@@ -238,13 +242,13 @@
|
|
|
238
242
|
"@opentelemetry/instrumentation-mongodb": ">=0.40.0",
|
|
239
243
|
"@opentelemetry/sdk-node": ">=0.50.0",
|
|
240
244
|
"@sinclair/typebox": ">=0.34.0",
|
|
241
|
-
"better-auth": ">=1.
|
|
245
|
+
"better-auth": ">=1.6.0",
|
|
242
246
|
"bullmq": ">=5.0.0",
|
|
243
247
|
"fastify": ">=5.0.0",
|
|
244
248
|
"fastify-raw-body": ">=5.0.0",
|
|
245
249
|
"ioredis": ">=5.0.0",
|
|
246
250
|
"mongodb": ">=6.0.0",
|
|
247
|
-
"mongoose": ">=9.
|
|
251
|
+
"mongoose": ">=9.4.1",
|
|
248
252
|
"pino-pretty": ">=13.0.0",
|
|
249
253
|
"zod": ">=4.0.0"
|
|
250
254
|
},
|
|
@@ -337,22 +341,30 @@
|
|
|
337
341
|
"secure-json-parse": "^4.1.0"
|
|
338
342
|
},
|
|
339
343
|
"devDependencies": {
|
|
344
|
+
"@better-auth/mongo-adapter": "^1.6.0",
|
|
340
345
|
"@biomejs/biome": "^2.4.10",
|
|
341
|
-
"@classytic/mongokit": "^3.5.
|
|
346
|
+
"@classytic/mongokit": "^3.5.5",
|
|
347
|
+
"@fastify/cors": "^11.2.0",
|
|
348
|
+
"@fastify/helmet": "^13.0.2",
|
|
342
349
|
"@fastify/jwt": "^10.0.0",
|
|
343
350
|
"@fastify/multipart": "^9.0.0",
|
|
351
|
+
"@fastify/rate-limit": "^10.3.0",
|
|
352
|
+
"@fastify/sensible": "^6.0.4",
|
|
344
353
|
"@fastify/type-provider-typebox": "^6.0.0",
|
|
354
|
+
"@fastify/under-pressure": "^9.0.3",
|
|
345
355
|
"@fastify/websocket": "^11.0.0",
|
|
346
356
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
347
357
|
"@sinclair/typebox": "^0.34.0",
|
|
348
358
|
"@types/node": "^22.10.0",
|
|
349
359
|
"@types/qs": "^6.14.0",
|
|
350
360
|
"@vitest/coverage-v8": "^3.2.4",
|
|
361
|
+
"better-auth": "^1.6.0",
|
|
351
362
|
"fastify-raw-body": "^5.0.0",
|
|
352
363
|
"jsonwebtoken": "^9.0.0",
|
|
353
364
|
"knip": "^6.3.0",
|
|
354
365
|
"mongodb": "^7.1.0",
|
|
355
366
|
"mongodb-memory-server": "^11.0.1",
|
|
367
|
+
"mongoose": "^9.4.1",
|
|
356
368
|
"tsdown": "^0.21.7",
|
|
357
369
|
"typescript": "^6.0.2",
|
|
358
370
|
"vitest": "^3.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.1
|
|
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.
|
|
@@ -660,6 +888,10 @@ import { defineResource, BaseController, allowPublic } from '@classytic/arc';
|
|
|
660
888
|
import { createApp } from '@classytic/arc/factory';
|
|
661
889
|
import { MemoryCacheStore, RedisCacheStore, QueryCache } from '@classytic/arc/cache';
|
|
662
890
|
import { createBetterAuthAdapter, extractBetterAuthOpenApi } from '@classytic/arc/auth';
|
|
891
|
+
// 2.7.1+: optional Mongoose stub-models bridge for `populate()` against
|
|
892
|
+
// Better Auth collections — only loaded if you import it (subpath gate
|
|
893
|
+
// keeps Mongoose out of Prisma/Drizzle/Kysely bundles).
|
|
894
|
+
import { registerBetterAuthMongooseModels } from '@classytic/arc/auth/mongoose';
|
|
663
895
|
import type { ExternalOpenApiPaths } from '@classytic/arc/docs';
|
|
664
896
|
import { eventPlugin } from '@classytic/arc/events';
|
|
665
897
|
import { RedisEventTransport } from '@classytic/arc/events/redis';
|
|
@@ -676,7 +908,21 @@ import { createTestApp } from '@classytic/arc/testing';
|
|
|
676
908
|
import { Type, ArcListResponse } from '@classytic/arc/schemas';
|
|
677
909
|
import { createStateMachine, CircuitBreaker, withCompensation, defineCompensation } from '@classytic/arc/utils';
|
|
678
910
|
import { defineMigration } from '@classytic/arc/migrations';
|
|
679
|
-
|
|
911
|
+
// Scope accessors — full surface as of 2.7.1
|
|
912
|
+
import {
|
|
913
|
+
// Type guards
|
|
914
|
+
isMember, isService, isElevated, isAuthenticated, hasOrgAccess,
|
|
915
|
+
// Identity / org accessors
|
|
916
|
+
getUserId, getUserRoles, getOrgId, getOrgRoles, getTeamId, getClientId,
|
|
917
|
+
// Service scopes (OAuth-style strings on API keys)
|
|
918
|
+
getServiceScopes,
|
|
919
|
+
// App-defined scope dimensions (branch, project, region, …)
|
|
920
|
+
getScopeContext, getScopeContextMap,
|
|
921
|
+
// Parent-child org hierarchy
|
|
922
|
+
getAncestorOrgIds, isOrgInScope,
|
|
923
|
+
// Generic request-side helper
|
|
924
|
+
getRequestScope,
|
|
925
|
+
} from '@classytic/arc/scope';
|
|
680
926
|
import { createTenantKeyGenerator } from '@classytic/arc/scope';
|
|
681
927
|
import { createRoleHierarchy } from '@classytic/arc/permissions';
|
|
682
928
|
import { createServiceClient } from '@classytic/arc/rpc';
|
|
@@ -684,7 +930,7 @@ import { metricsPlugin, versioningPlugin } from '@classytic/arc/plugins';
|
|
|
684
930
|
import { webhookPlugin } from '@classytic/arc/integrations/webhooks';
|
|
685
931
|
import { mcpPlugin, createMcpServer, defineTool, definePrompt, fieldRulesToZod, resourceToTools } from '@classytic/arc/mcp';
|
|
686
932
|
import { EventOutbox, MemoryOutboxStore } from '@classytic/arc/events';
|
|
687
|
-
import { bulkPreset } from '@classytic/arc/presets';
|
|
933
|
+
import { bulkPreset, multiTenantPreset, type TenantFieldSpec } from '@classytic/arc/presets';
|
|
688
934
|
```
|
|
689
935
|
|
|
690
936
|
## References (Progressive Disclosure)
|
|
@@ -693,5 +939,6 @@ import { bulkPreset } from '@classytic/arc/presets';
|
|
|
693
939
|
- **[events](references/events.md)** — Domain events, transports, retry, outbox pattern, auto-emission
|
|
694
940
|
- **[integrations](references/integrations.md)** — BullMQ jobs, WebSocket, EventGateway, Streamline, Webhooks
|
|
695
941
|
- **[mcp](references/mcp.md)** — MCP tools for AI agents, auto-generation from resources, custom tools, Better Auth OAuth 2.1
|
|
942
|
+
- **[multi-tenancy](references/multi-tenancy.md)** — Scope ladder, `tenantField` read sites, `PermissionResult.scope`, API key auth without a separate auth plugin
|
|
696
943
|
- **[production](references/production.md)** — Health, audit, idempotency, tracing, metrics, versioning, SSE, QueryCache, bulk ops, saga, RPC schema versioning, tenant rate limiting
|
|
697
944
|
- **[testing](references/testing.md)** — Test app, mocks, data factories, in-memory MongoDB
|