@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/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @classytic/arc
|
|
2
2
|
|
|
3
|
-
Database-agnostic resource framework for Fastify. Define resources, get CRUD routes, permissions, presets, caching, events, and
|
|
3
|
+
Database-agnostic resource framework for Fastify. Define resources, get CRUD routes, permissions, presets, caching, events, OpenAPI, and MCP tools — without boilerplate.
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**v2.7.3** | Fastify 5+ | Node.js 22+ | ESM only | 260+ test files, 3523+ tests
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -143,6 +143,33 @@ auth: false
|
|
|
143
143
|
|
|
144
144
|
**Decorates:** `app.authenticate`, `app.optionalAuthenticate`, `app.authorize`
|
|
145
145
|
|
|
146
|
+
### Better Auth + Mongoose populate bridge
|
|
147
|
+
|
|
148
|
+
When you back Better Auth with `@better-auth/mongo-adapter`, BA writes through the native `mongodb` driver and never registers anything with Mongoose. Any arc resource that does `Schema({ userId: { ref: 'user' } })` and calls `.populate('userId')` then throws `MissingSchemaError`.
|
|
149
|
+
|
|
150
|
+
Optional helper at a dedicated subpath registers `strict: false` stub Mongoose models for BA's collections so populate works. Lives behind `@classytic/arc/auth/mongoose` so users on Prisma/Drizzle/Kysely never get Mongoose pulled into their bundle.
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
import mongoose from 'mongoose';
|
|
154
|
+
import { registerBetterAuthMongooseModels } from '@classytic/arc/auth/mongoose';
|
|
155
|
+
|
|
156
|
+
// Default is core only — every plugin set is opt-in.
|
|
157
|
+
registerBetterAuthMongooseModels(mongoose, {
|
|
158
|
+
plugins: ['organization', 'organization-teams'],
|
|
159
|
+
// For separate @better-auth/* packages:
|
|
160
|
+
extraCollections: ['passkey', 'ssoProvider'],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Now arc resources can populate BA-owned references:
|
|
164
|
+
const Post = mongoose.model('Post', new mongoose.Schema({
|
|
165
|
+
title: String,
|
|
166
|
+
authorId: { type: String, ref: 'user' },
|
|
167
|
+
}));
|
|
168
|
+
await Post.findOne().populate('authorId');
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Supports `usePlural` (matches `mongodbAdapter({ usePlural: true })`) and `modelOverrides` (for custom `user: { modelName: 'profile' }` configs). Idempotent and de-dupes overlapping plugin sets.
|
|
172
|
+
|
|
146
173
|
### Token Revocation
|
|
147
174
|
|
|
148
175
|
Arc provides the `isRevoked` primitive — you implement the store (Redis, DB, Better Auth):
|
|
@@ -167,7 +194,9 @@ Function-based, composable:
|
|
|
167
194
|
```typescript
|
|
168
195
|
import {
|
|
169
196
|
allowPublic, requireAuth, requireRoles, requireOwnership,
|
|
170
|
-
requireOrgMembership, requireOrgRole,
|
|
197
|
+
requireOrgMembership, requireOrgRole, requireServiceScope,
|
|
198
|
+
requireScopeContext,
|
|
199
|
+
allOf, anyOf, denyAll,
|
|
171
200
|
createDynamicPermissionMatrix,
|
|
172
201
|
} from '@classytic/arc';
|
|
173
202
|
|
|
@@ -177,9 +206,63 @@ permissions: {
|
|
|
177
206
|
create: requireRoles(['admin', 'editor']),
|
|
178
207
|
update: anyOf(requireOwnership('userId'), requireRoles(['admin'])),
|
|
179
208
|
delete: allOf(requireAuth(), requireRoles(['admin'])),
|
|
209
|
+
|
|
210
|
+
// Mixed human + machine routes — accept org admins OR API keys
|
|
211
|
+
bulkImport: anyOf(
|
|
212
|
+
requireOrgRole('admin'), // human path
|
|
213
|
+
requireServiceScope('jobs:bulk-write'), // machine path (OAuth-style)
|
|
214
|
+
),
|
|
215
|
+
|
|
216
|
+
// Multi-level tenancy — branch/project/region scoped routes
|
|
217
|
+
branchAdmin: allOf(requireOrgRole('admin'), requireScopeContext('branchId')),
|
|
218
|
+
euOnly: requireScopeContext('region', 'eu'),
|
|
219
|
+
projectEdit: requireScopeContext({ projectId: 'p-1', region: 'eu' }),
|
|
220
|
+
|
|
221
|
+
// Parent-child org hierarchy (holding → subsidiary → branch, MSP, white-label)
|
|
222
|
+
// Reads scope.ancestorOrgIds (loaded by your auth function from your own org table)
|
|
223
|
+
childOrgAccess: requireOrgInScope((ctx) => ctx.request.params.orgId),
|
|
180
224
|
}
|
|
181
225
|
```
|
|
182
226
|
|
|
227
|
+
`requireRoles()` checks platform roles (`user.role`) AND org roles
|
|
228
|
+
(`scope.orgRoles`) by default — same call works for arc JWT, Better Auth user
|
|
229
|
+
roles, and Better Auth org plugin. `requireOrgMembership()` accepts `member`,
|
|
230
|
+
`service` (API key), and `elevated` scopes; `multiTenantPreset` filters by
|
|
231
|
+
org for all three. For machine identities, `requireServiceScope('jobs:write')`
|
|
232
|
+
mirrors OAuth 2.0 scope strings. For app-defined dimensions beyond org/team
|
|
233
|
+
(branch, project, region, workspace), `requireScopeContext('branchId')`
|
|
234
|
+
reads from `scope.context` populated by your auth function. For parent-child
|
|
235
|
+
org hierarchies (holding → subsidiary, MSP → tenants, white-label),
|
|
236
|
+
`requireOrgInScope((ctx) => ctx.request.params.orgId)` accepts the current
|
|
237
|
+
org or any ancestor in `scope.ancestorOrgIds`.
|
|
238
|
+
|
|
239
|
+
**Multi-level tenant filtering** — the `multiTenantPreset` scales from
|
|
240
|
+
single-org isolation to lockstep filtering across any number of dimensions:
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
import { multiTenantPreset } from '@classytic/arc/presets';
|
|
244
|
+
|
|
245
|
+
// Single-field (default, backwards compatible)
|
|
246
|
+
multiTenantPreset({ tenantField: 'organizationId' })
|
|
247
|
+
|
|
248
|
+
// Multi-field — org + branch + project, all enforced in lockstep
|
|
249
|
+
multiTenantPreset({
|
|
250
|
+
tenantFields: [
|
|
251
|
+
{ field: 'organizationId', type: 'org' }, // → getOrgId(scope)
|
|
252
|
+
{ field: 'teamId', type: 'team' }, // → getTeamId(scope)
|
|
253
|
+
{ field: 'branchId', contextKey: 'branchId' }, // → scope.context.branchId
|
|
254
|
+
{ field: 'projectId', contextKey: 'projectId' },
|
|
255
|
+
],
|
|
256
|
+
})
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Fail-closed semantics: if any required dimension is missing from the caller's
|
|
260
|
+
scope, list/get/update/delete return 403 with the specific missing field name,
|
|
261
|
+
and create is rejected. Elevated scopes apply whatever resolves and skip the
|
|
262
|
+
rest (cross-context admin bypass). Your auth function populates
|
|
263
|
+
`scope.context` from JWT claims, BA session fields, or request headers — arc
|
|
264
|
+
takes no position on which dimension names you use.
|
|
265
|
+
|
|
183
266
|
**Field-level permissions:**
|
|
184
267
|
|
|
185
268
|
```typescript
|
|
@@ -607,9 +690,21 @@ npx @classytic/arc doctor # Health check
|
|
|
607
690
|
| `@classytic/arc/integrations/websocket` | WebSocket |
|
|
608
691
|
| `@classytic/arc/integrations/event-gateway` | Unified SSE + WebSocket gateway |
|
|
609
692
|
| `@classytic/arc/integrations/streamline` | Workflow orchestration |
|
|
693
|
+
| `@classytic/arc/mcp` | MCP tools for AI agents |
|
|
610
694
|
| `@classytic/arc/docs` | OpenAPI generation |
|
|
611
695
|
| `@classytic/arc/cli` | CLI commands (programmatic) |
|
|
612
696
|
|
|
697
|
+
## v2.7.3 Highlights
|
|
698
|
+
|
|
699
|
+
- **MCP Integration** — expose resources as AI agent tools (stateless by default, service scope, multi-tenancy)
|
|
700
|
+
- **Reply Helpers** — `reply.ok()`, `reply.fail()`, `reply.paginated()`, `reply.stream()` (opt-in)
|
|
701
|
+
- **Error Mappers** — class-based `instanceof` domain error → HTTP response mapping
|
|
702
|
+
- **Multipart Body** — `multipartBody()` middleware for file upload in CRUD routes
|
|
703
|
+
- **Service Scope** — `kind: "service"` RequestScope for machine-to-machine auth (MCP + WebSocket)
|
|
704
|
+
- **BigInt Serialization** — `serializeBigInt: true` auto-converts BigInt → Number
|
|
705
|
+
- **Event WAL** — skips internal `arc.*` events to prevent startup timeout with durable stores
|
|
706
|
+
- **Security** — `auth: false` produces null `ctx.user` (prevents anonymous bypass of `!!ctx.user` guards)
|
|
707
|
+
|
|
613
708
|
## License
|
|
614
709
|
|
|
615
710
|
MIT
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-Cxde4rpC.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { _ as isElevated, n as PUBLIC_SCOPE, o as getOrgId, v as isMember } from "./types-AOD8fxIw.mjs";
|
|
3
3
|
import { t as buildQueryKey } from "./keys-qcD-TVJl.mjs";
|
|
4
4
|
import { getUserId } from "./types/index.mjs";
|
|
5
5
|
import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-ipsbIRPK.mjs";
|
|
@@ -441,7 +441,7 @@ var BaseController = class {
|
|
|
441
441
|
this.defaultSort = options.defaultSort ?? "-createdAt";
|
|
442
442
|
this.resourceName = options.resourceName;
|
|
443
443
|
this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
|
|
444
|
-
this.idField = options.idField ?? "_id";
|
|
444
|
+
this.idField = options.idField ?? repository?.idField ?? "_id";
|
|
445
445
|
this._matchesFilter = options.matchesFilter;
|
|
446
446
|
if (options.cache) this._cacheConfig = options.cache;
|
|
447
447
|
if (options.presetFields) this._presetFields = options.presetFields;
|
|
@@ -483,6 +483,29 @@ var BaseController = class {
|
|
|
483
483
|
getHooks(req) {
|
|
484
484
|
return this.meta(req)?.arc?.hooks ?? null;
|
|
485
485
|
}
|
|
486
|
+
/**
|
|
487
|
+
* Resolve the repository primary key for mutation calls (update/delete/restore).
|
|
488
|
+
*
|
|
489
|
+
* When the resource declares a custom `idField` (e.g. `slug`, `jobId`, UUID),
|
|
490
|
+
* the default behavior is to translate the route id → the fetched doc's `_id`
|
|
491
|
+
* because most Mongo repositories key their mutation methods off `_id`.
|
|
492
|
+
*
|
|
493
|
+
* Exception: if the repository itself exposes a matching `idField` property
|
|
494
|
+
* (e.g. MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
|
|
495
|
+
* repository already knows how to look up by that field — so we pass the
|
|
496
|
+
* route id through unchanged and skip the translation.
|
|
497
|
+
*
|
|
498
|
+
* This makes `defineResource({ idField: 'id' })` work end-to-end with repos
|
|
499
|
+
* that natively support custom primary keys, without breaking the slug-style
|
|
500
|
+
* aliasing that Arc 2.6.3 introduced for repos keyed on `_id`.
|
|
501
|
+
*/
|
|
502
|
+
resolveRepoId(id, existing) {
|
|
503
|
+
if (this.idField === "_id") return id;
|
|
504
|
+
if (!existing) return id;
|
|
505
|
+
const repoIdField = this.repository.idField;
|
|
506
|
+
if (repoIdField && repoIdField === this.idField) return id;
|
|
507
|
+
return String(existing["_id"] ?? id);
|
|
508
|
+
}
|
|
486
509
|
/** Resolve cache config for a specific operation, merging per-op overrides */
|
|
487
510
|
resolveCacheConfig(operation) {
|
|
488
511
|
const cfg = this._cacheConfig;
|
|
@@ -722,7 +745,7 @@ var BaseController = class {
|
|
|
722
745
|
details: { code: "OWNERSHIP_DENIED" },
|
|
723
746
|
status: 403
|
|
724
747
|
};
|
|
725
|
-
const repoId = this.
|
|
748
|
+
const repoId = this.resolveRepoId(id, existing);
|
|
726
749
|
const hooks = this.getHooks(req);
|
|
727
750
|
let processedData = data;
|
|
728
751
|
if (hooks && this.resourceName) try {
|
|
@@ -801,7 +824,7 @@ var BaseController = class {
|
|
|
801
824
|
details: { code: "OWNERSHIP_DENIED" },
|
|
802
825
|
status: 403
|
|
803
826
|
};
|
|
804
|
-
const repoId = this.
|
|
827
|
+
const repoId = this.resolveRepoId(id, existing);
|
|
805
828
|
const hooks = this.getHooks(req);
|
|
806
829
|
if (hooks && this.resourceName) try {
|
|
807
830
|
await hooks.executeBefore(this.resourceName, "delete", existing, {
|
|
@@ -915,7 +938,7 @@ var BaseController = class {
|
|
|
915
938
|
error: "ID parameter is required",
|
|
916
939
|
status: 400
|
|
917
940
|
};
|
|
918
|
-
const existing = await this.accessControl.fetchWithAccessControl(id, req, repo);
|
|
941
|
+
const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
|
|
919
942
|
if (!existing) return {
|
|
920
943
|
success: false,
|
|
921
944
|
error: "Resource not found",
|
|
@@ -927,7 +950,7 @@ var BaseController = class {
|
|
|
927
950
|
details: { code: "OWNERSHIP_DENIED" },
|
|
928
951
|
status: 403
|
|
929
952
|
};
|
|
930
|
-
const repoId = this.
|
|
953
|
+
const repoId = this.resolveRepoId(id, existing);
|
|
931
954
|
const item = await repo.restore(repoId);
|
|
932
955
|
if (!item) return {
|
|
933
956
|
success: false,
|
|
@@ -984,9 +1007,11 @@ var BaseController = class {
|
|
|
984
1007
|
error: "Bulk create requires a non-empty items array",
|
|
985
1008
|
status: 400
|
|
986
1009
|
};
|
|
987
|
-
|
|
1010
|
+
const arcContext = this.meta(req);
|
|
1011
|
+
const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
|
|
1012
|
+
let scopedItems = sanitizedItems;
|
|
988
1013
|
if (this.tenantField) {
|
|
989
|
-
const scope =
|
|
1014
|
+
const scope = arcContext?._scope;
|
|
990
1015
|
if (scope) {
|
|
991
1016
|
if (scope.kind === "public") return {
|
|
992
1017
|
success: false,
|
|
@@ -1003,7 +1028,7 @@ var BaseController = class {
|
|
|
1003
1028
|
status: 403
|
|
1004
1029
|
};
|
|
1005
1030
|
const tenantField = this.tenantField;
|
|
1006
|
-
scopedItems =
|
|
1031
|
+
scopedItems = sanitizedItems.map((item) => ({
|
|
1007
1032
|
...item,
|
|
1008
1033
|
[tenantField]: orgId
|
|
1009
1034
|
}));
|
|
@@ -1011,11 +1036,23 @@ var BaseController = class {
|
|
|
1011
1036
|
}
|
|
1012
1037
|
}
|
|
1013
1038
|
const created = await repo.createMany(scopedItems);
|
|
1039
|
+
const requested = items.length;
|
|
1040
|
+
const inserted = created.length;
|
|
1041
|
+
const skipped = requested - inserted;
|
|
1014
1042
|
return {
|
|
1015
1043
|
success: true,
|
|
1016
1044
|
data: created,
|
|
1017
|
-
status: 201,
|
|
1018
|
-
meta: {
|
|
1045
|
+
status: skipped === 0 ? 201 : inserted === 0 ? 422 : 207,
|
|
1046
|
+
meta: {
|
|
1047
|
+
count: inserted,
|
|
1048
|
+
requested,
|
|
1049
|
+
inserted,
|
|
1050
|
+
skipped,
|
|
1051
|
+
...skipped > 0 && {
|
|
1052
|
+
partial: true,
|
|
1053
|
+
reason: inserted === 0 ? "all_invalid" : "some_invalid"
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1019
1056
|
};
|
|
1020
1057
|
}
|
|
1021
1058
|
/**
|
|
@@ -1052,6 +1089,49 @@ var BaseController = class {
|
|
|
1052
1089
|
}
|
|
1053
1090
|
return filter;
|
|
1054
1091
|
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Sanitize a bulk update data payload through the same write-permission
|
|
1094
|
+
* pipeline as single-doc update(). Handles both shapes:
|
|
1095
|
+
*
|
|
1096
|
+
* - Flat: `{ name: 'x', status: 'y' }`
|
|
1097
|
+
* - Mongo operator: `{ $set: { name: 'x' }, $inc: { views: 1 }, $unset: { tag: '' } }`
|
|
1098
|
+
*
|
|
1099
|
+
* For each operand, runs `bodySanitizer.sanitize('update', ...)` so that
|
|
1100
|
+
* system fields, systemManaged/readonly/immutable rules, AND field-level
|
|
1101
|
+
* write permissions are enforced. Without this, a tenant-scoped user could
|
|
1102
|
+
* pass `{ $set: { organizationId: 'org-b' } }` to move records across orgs.
|
|
1103
|
+
*
|
|
1104
|
+
* Returns the sanitized payload along with the list of stripped fields for
|
|
1105
|
+
* audit/error reporting.
|
|
1106
|
+
*/
|
|
1107
|
+
sanitizeBulkUpdateData(data, req, arcContext) {
|
|
1108
|
+
const stripped = /* @__PURE__ */ new Set();
|
|
1109
|
+
if (!Object.keys(data).some((k) => k.startsWith("$"))) {
|
|
1110
|
+
const before = new Set(Object.keys(data));
|
|
1111
|
+
const sanitized = this.bodySanitizer.sanitize(data, "update", req, arcContext);
|
|
1112
|
+
for (const key of before) if (!(key in sanitized)) stripped.add(key);
|
|
1113
|
+
return {
|
|
1114
|
+
sanitized,
|
|
1115
|
+
stripped: [...stripped]
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
const sanitized = {};
|
|
1119
|
+
for (const [op, operand] of Object.entries(data)) {
|
|
1120
|
+
if (!op.startsWith("$") || operand === null || typeof operand !== "object") {
|
|
1121
|
+
sanitized[op] = operand;
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
const operandObj = operand;
|
|
1125
|
+
const before = new Set(Object.keys(operandObj));
|
|
1126
|
+
const sanitizedOperand = this.bodySanitizer.sanitize(operandObj, "update", req, arcContext);
|
|
1127
|
+
for (const key of before) if (!(key in sanitizedOperand)) stripped.add(key);
|
|
1128
|
+
if (Object.keys(sanitizedOperand).length > 0) sanitized[op] = sanitizedOperand;
|
|
1129
|
+
}
|
|
1130
|
+
return {
|
|
1131
|
+
sanitized,
|
|
1132
|
+
stripped: [...stripped]
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1055
1135
|
async bulkUpdate(req) {
|
|
1056
1136
|
const repo = this.repository;
|
|
1057
1137
|
if (!repo.updateMany) return {
|
|
@@ -1077,12 +1157,41 @@ var BaseController = class {
|
|
|
1077
1157
|
details: { code: "ORG_CONTEXT_REQUIRED" },
|
|
1078
1158
|
status: 403
|
|
1079
1159
|
};
|
|
1160
|
+
const arcContext = this.meta(req);
|
|
1161
|
+
const { sanitized, stripped } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
|
|
1162
|
+
if (Object.keys(sanitized).length === 0) return {
|
|
1163
|
+
success: false,
|
|
1164
|
+
error: "Bulk update payload contained only protected fields",
|
|
1165
|
+
details: {
|
|
1166
|
+
code: "ALL_FIELDS_STRIPPED",
|
|
1167
|
+
stripped
|
|
1168
|
+
},
|
|
1169
|
+
status: 400
|
|
1170
|
+
};
|
|
1080
1171
|
return {
|
|
1081
1172
|
success: true,
|
|
1082
|
-
data: await repo.updateMany(scopedFilter,
|
|
1083
|
-
status: 200
|
|
1173
|
+
data: await repo.updateMany(scopedFilter, sanitized),
|
|
1174
|
+
status: 200,
|
|
1175
|
+
...stripped.length > 0 && { meta: { stripped } }
|
|
1084
1176
|
};
|
|
1085
1177
|
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Bulk delete by `filter` or `ids`.
|
|
1180
|
+
*
|
|
1181
|
+
* Body shape (one of):
|
|
1182
|
+
* - `{ filter: { status: 'archived' } }` — delete by query filter
|
|
1183
|
+
* - `{ ids: ['id1', 'id2', 'id3'] }` — delete specific docs by id
|
|
1184
|
+
*
|
|
1185
|
+
* The `ids` form translates to `{ [idField]: { $in: ids } }` using the
|
|
1186
|
+
* resource's `idField` (so it works with custom PKs like `slug`, `jobId`,
|
|
1187
|
+
* UUID, etc.). Tenant scope and policy filters are merged in either way,
|
|
1188
|
+
* so cross-tenant deletes are blocked at the controller layer.
|
|
1189
|
+
*
|
|
1190
|
+
* Both forms perform a single `repo.deleteMany()` DB call — no per-doc
|
|
1191
|
+
* fetch loop. Per-doc lifecycle hooks (`before:delete`/`after:delete`) do
|
|
1192
|
+
* NOT fire for bulk operations; use the single-doc `delete()` if you need
|
|
1193
|
+
* them, or subscribe to the bulk lifecycle event from the events plugin.
|
|
1194
|
+
*/
|
|
1086
1195
|
async bulkDelete(req) {
|
|
1087
1196
|
const repo = this.repository;
|
|
1088
1197
|
if (!repo.deleteMany) return {
|
|
@@ -1091,12 +1200,21 @@ var BaseController = class {
|
|
|
1091
1200
|
status: 501
|
|
1092
1201
|
};
|
|
1093
1202
|
const body = req.body;
|
|
1094
|
-
|
|
1203
|
+
let userFilter;
|
|
1204
|
+
if (body.ids && body.ids.length > 0) {
|
|
1205
|
+
if (body.filter && Object.keys(body.filter).length > 0) return {
|
|
1206
|
+
success: false,
|
|
1207
|
+
error: "Bulk delete accepts either `ids` or `filter`, not both",
|
|
1208
|
+
status: 400
|
|
1209
|
+
};
|
|
1210
|
+
userFilter = { [this.idField]: { $in: body.ids } };
|
|
1211
|
+
} else if (body.filter && Object.keys(body.filter).length > 0) userFilter = body.filter;
|
|
1212
|
+
else return {
|
|
1095
1213
|
success: false,
|
|
1096
|
-
error: "Bulk delete requires a non-empty filter",
|
|
1214
|
+
error: "Bulk delete requires a non-empty `filter` or `ids` array",
|
|
1097
1215
|
status: 400
|
|
1098
1216
|
};
|
|
1099
|
-
const scopedFilter = this.buildBulkFilter(
|
|
1217
|
+
const scopedFilter = this.buildBulkFilter(userFilter, req);
|
|
1100
1218
|
if (scopedFilter === null) return {
|
|
1101
1219
|
success: false,
|
|
1102
1220
|
error: "Organization context required for bulk delete",
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { a as RelationMetadata, c as ValidationResult, i as FieldMetadata, o as RepositoryLike, r as DataAdapter, s as SchemaMetadata, t as AdapterFactory } from "../interface-
|
|
2
|
-
import { a as PrismaQueryParserOptions, c as MongooseAdapterOptions, i as PrismaQueryParser, l as createMongooseAdapter, n as PrismaAdapterOptions, o as createPrismaAdapter, r as PrismaQueryOptions, s as MongooseAdapter, t as PrismaAdapter } from "../index-
|
|
1
|
+
import { a as RelationMetadata, c as ValidationResult, i as FieldMetadata, o as RepositoryLike, r as DataAdapter, s as SchemaMetadata, t as AdapterFactory } from "../interface-B91alUzq.mjs";
|
|
2
|
+
import { a as PrismaQueryParserOptions, c as MongooseAdapterOptions, i as PrismaQueryParser, l as createMongooseAdapter, n as PrismaAdapterOptions, o as createPrismaAdapter, r as PrismaQueryOptions, s as MongooseAdapter, t as PrismaAdapter } from "../index-C9eYNjGR.mjs";
|
|
3
3
|
export { AdapterFactory, DataAdapter, FieldMetadata, MongooseAdapter, MongooseAdapterOptions, PrismaAdapter, PrismaAdapterOptions, PrismaQueryOptions, PrismaQueryParser, PrismaQueryParserOptions, RelationMetadata, RepositoryLike, SchemaMetadata, ValidationResult, createMongooseAdapter, createPrismaAdapter };
|
package/dist/adapters/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as createMongooseAdapter, i as MongooseAdapter, n as PrismaQueryParser, r as createPrismaAdapter, t as PrismaAdapter } from "../adapters-
|
|
1
|
+
import { a as createMongooseAdapter, i as MongooseAdapter, n as PrismaQueryParser, r as createPrismaAdapter, t as PrismaAdapter } from "../adapters-BxGgSHjj.mjs";
|
|
2
2
|
export { MongooseAdapter, PrismaAdapter, PrismaQueryParser, createMongooseAdapter, createPrismaAdapter };
|
|
@@ -189,15 +189,7 @@ var MongooseAdapter = class {
|
|
|
189
189
|
else baseType.items = {};
|
|
190
190
|
break;
|
|
191
191
|
}
|
|
192
|
-
case "Mixed":
|
|
193
|
-
baseType.type = [
|
|
194
|
-
"string",
|
|
195
|
-
"number",
|
|
196
|
-
"boolean",
|
|
197
|
-
"object",
|
|
198
|
-
"array"
|
|
199
|
-
];
|
|
200
|
-
break;
|
|
192
|
+
case "Mixed": break;
|
|
201
193
|
case "Map":
|
|
202
194
|
baseType.type = "object";
|
|
203
195
|
baseType.additionalProperties = true;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/permissions/applyPermissionResult.ts
|
|
2
|
+
/**
|
|
3
|
+
* Normalize a permission check return value (`boolean | PermissionResult`)
|
|
4
|
+
* into a concrete `PermissionResult`. This is the only place in Arc that
|
|
5
|
+
* promotes booleans to results — keeps the type narrowing honest everywhere.
|
|
6
|
+
*/
|
|
7
|
+
function normalizePermissionResult(result) {
|
|
8
|
+
if (typeof result === "boolean") return { granted: result };
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Apply a granted `PermissionResult` to a Fastify request — merges row-level
|
|
13
|
+
* filters into `_policyFilters` and conditionally installs the scope.
|
|
14
|
+
*
|
|
15
|
+
* **Scope install rule:** only writes `scope` when the current request scope
|
|
16
|
+
* is absent or `public`. This prevents downgrading an already-authenticated
|
|
17
|
+
* request (e.g. Better Auth set `member`, then a permission check returns a
|
|
18
|
+
* narrower `service` scope — the original `member` wins because it came from
|
|
19
|
+
* a more authoritative source).
|
|
20
|
+
*
|
|
21
|
+
* Safe to call with a non-granted result — it simply no-ops. Callers should
|
|
22
|
+
* still check `result.granted` and send an error response before reaching here,
|
|
23
|
+
* but this function tolerates the misuse defensively.
|
|
24
|
+
*/
|
|
25
|
+
function applyPermissionResult(result, request) {
|
|
26
|
+
if (!result.granted) return;
|
|
27
|
+
if (result.filters) request._policyFilters = {
|
|
28
|
+
...request._policyFilters ?? {},
|
|
29
|
+
...result.filters
|
|
30
|
+
};
|
|
31
|
+
if (result.scope) {
|
|
32
|
+
const current = request.scope;
|
|
33
|
+
if (!current || current.kind === "public") request.scope = result.scope;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { normalizePermissionResult as n, applyPermissionResult as t };
|
package/dist/audit/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as AuditContext, c as AuditStore, i as AuditAction, l as AuditStoreOptions, n as MongoAuditStoreOptions, o as AuditEntry, r as MongoConnection, s as AuditQueryOptions, u as createAuditEntry } from "../mongodb-
|
|
1
|
+
import { a as AuditContext, c as AuditStore, i as AuditAction, l as AuditStoreOptions, n as MongoAuditStoreOptions, o as AuditEntry, r as MongoConnection, s as AuditQueryOptions, u as createAuditEntry } from "../mongodb-Cgu9F1Nd.mjs";
|
|
2
2
|
import { FastifyPluginAsync } from "fastify";
|
|
3
3
|
|
|
4
4
|
//#region src/audit/auditPlugin.d.ts
|
package/dist/audit/index.mjs
CHANGED
package/dist/audit/mongodb.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as MongoAuditStoreOptions, t as MongoAuditStore } from "../mongodb-
|
|
1
|
+
import { n as MongoAuditStoreOptions, t as MongoAuditStore } from "../mongodb-Cgu9F1Nd.mjs";
|
|
2
2
|
export { MongoAuditStore, type MongoAuditStoreOptions };
|
package/dist/audit/mongodb.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as MongoAuditStore } from "../mongodb-
|
|
1
|
+
import { t as MongoAuditStore } from "../mongodb-B7X7P1P8.mjs";
|
|
2
2
|
export { MongoAuditStore };
|
package/dist/auth/index.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { g as AuthPluginOptions, h as AuthHelpers } from "../interface-
|
|
2
|
-
import { t as PermissionCheck } from "../types-
|
|
3
|
-
import { t as ExternalOpenApiPaths } from "../externalPaths-
|
|
4
|
-
import { a as SessionManagerOptions, c as createSessionManager, i as SessionData, n as MemorySessionStoreOptions, o as SessionManagerResult, r as SessionCookieOptions, s as SessionStore, t as MemorySessionStore } from "../sessionManager-
|
|
1
|
+
import { g as AuthPluginOptions, h as AuthHelpers } from "../interface-B91alUzq.mjs";
|
|
2
|
+
import { t as PermissionCheck } from "../types-B4BNthET.mjs";
|
|
3
|
+
import { t as ExternalOpenApiPaths } from "../externalPaths-iba7jD3d.mjs";
|
|
4
|
+
import { a as SessionManagerOptions, c as createSessionManager, i as SessionData, n as MemorySessionStoreOptions, o as SessionManagerResult, r as SessionCookieOptions, s as SessionStore, t as MemorySessionStore } from "../sessionManager-CEo9jwPI.mjs";
|
|
5
5
|
import { FastifyPluginAsync, FastifyReply as FastifyReply$1, FastifyRequest as FastifyRequest$1 } from "fastify";
|
|
6
6
|
|
|
7
7
|
//#region src/auth/authPlugin.d.ts
|
package/dist/auth/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { n as normalizeRoles, t as getUserRoles } from "../types-ZUu_h0jp.mjs";
|
|
2
|
-
import { t as ArcError } from "../errors-
|
|
3
|
-
import {
|
|
4
|
-
import { n as extractBetterAuthOpenApi } from "../betterAuthOpenApi-
|
|
2
|
+
import { t as ArcError } from "../errors-Cg58SLNi.mjs";
|
|
3
|
+
import { h as requireTeamMembership, l as requireOrgMembership, u as requireOrgRole } from "../permissions-CH4cNwJi.mjs";
|
|
4
|
+
import { n as extractBetterAuthOpenApi } from "../betterAuthOpenApi-CCw3YX0g.mjs";
|
|
5
5
|
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
6
6
|
import fp from "fastify-plugin";
|
|
7
7
|
//#region src/auth/authPlugin.ts
|
|
@@ -588,10 +588,11 @@ function createBetterAuthAdapter(options) {
|
|
|
588
588
|
const teamsResponse = await auth.handler(teamsRequest);
|
|
589
589
|
if (teamsResponse.ok) {
|
|
590
590
|
const teamsData = await teamsResponse.json();
|
|
591
|
-
|
|
591
|
+
const teamsList = Array.isArray(teamsData) ? teamsData : teamsData?.teams;
|
|
592
|
+
teams = Array.isArray(teamsList) ? teamsList : [];
|
|
592
593
|
}
|
|
593
594
|
}
|
|
594
|
-
if (teams?.some((t) => t.id === activeTeamId)) scope.teamId = activeTeamId;
|
|
595
|
+
if (teams?.some((t) => normalizeId(t.id) === activeTeamId)) scope.teamId = activeTeamId;
|
|
595
596
|
}
|
|
596
597
|
req.scope = scope;
|
|
597
598
|
}
|
|
@@ -676,7 +677,7 @@ function createBetterAuthAdapter(options) {
|
|
|
676
677
|
if (!fastify.hasDecorator("authenticate")) fastify.decorate("authenticate", authenticate);
|
|
677
678
|
if (!fastify.hasDecorator("optionalAuthenticate")) fastify.decorate("optionalAuthenticate", optionalAuthenticate);
|
|
678
679
|
if (!extractedOpenApi && openapiOpt !== false && auth.api && typeof auth.api === "object") {
|
|
679
|
-
const { extractBetterAuthOpenApi } = await import("../betterAuthOpenApi-
|
|
680
|
+
const { extractBetterAuthOpenApi } = await import("../betterAuthOpenApi-CCw3YX0g.mjs").then((n) => n.t);
|
|
680
681
|
extractedOpenApi = extractBetterAuthOpenApi(auth.api, {
|
|
681
682
|
basePath,
|
|
682
683
|
userFields
|