@classytic/arc 1.0.0
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/LICENSE +21 -0
- package/README.md +900 -0
- package/bin/arc.js +344 -0
- package/dist/adapters/index.d.ts +237 -0
- package/dist/adapters/index.js +668 -0
- package/dist/arcCorePlugin-DTPWXcZN.d.ts +273 -0
- package/dist/audit/index.d.ts +195 -0
- package/dist/audit/index.js +319 -0
- package/dist/auth/index.d.ts +47 -0
- package/dist/auth/index.js +174 -0
- package/dist/cli/commands/docs.d.ts +11 -0
- package/dist/cli/commands/docs.js +474 -0
- package/dist/cli/commands/introspect.d.ts +8 -0
- package/dist/cli/commands/introspect.js +338 -0
- package/dist/cli/index.d.ts +43 -0
- package/dist/cli/index.js +520 -0
- package/dist/createApp-pzUAkzbz.d.ts +77 -0
- package/dist/docs/index.d.ts +166 -0
- package/dist/docs/index.js +650 -0
- package/dist/errors-8WIxGS_6.d.ts +122 -0
- package/dist/events/index.d.ts +117 -0
- package/dist/events/index.js +89 -0
- package/dist/factory/index.d.ts +38 -0
- package/dist/factory/index.js +1664 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.js +199 -0
- package/dist/idempotency/index.d.ts +323 -0
- package/dist/idempotency/index.js +500 -0
- package/dist/index-DkAW8BXh.d.ts +1302 -0
- package/dist/index.d.ts +331 -0
- package/dist/index.js +4734 -0
- package/dist/migrations/index.d.ts +185 -0
- package/dist/migrations/index.js +274 -0
- package/dist/org/index.d.ts +129 -0
- package/dist/org/index.js +220 -0
- package/dist/permissions/index.d.ts +144 -0
- package/dist/permissions/index.js +100 -0
- package/dist/plugins/index.d.ts +46 -0
- package/dist/plugins/index.js +1069 -0
- package/dist/policies/index.d.ts +398 -0
- package/dist/policies/index.js +196 -0
- package/dist/presets/index.d.ts +336 -0
- package/dist/presets/index.js +382 -0
- package/dist/presets/multiTenant.d.ts +39 -0
- package/dist/presets/multiTenant.js +112 -0
- package/dist/registry/index.d.ts +16 -0
- package/dist/registry/index.js +253 -0
- package/dist/testing/index.d.ts +618 -0
- package/dist/testing/index.js +48032 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.js +8 -0
- package/dist/types-0IPhH_NR.d.ts +143 -0
- package/dist/types-B99TBmFV.d.ts +76 -0
- package/dist/utils/index.d.ts +655 -0
- package/dist/utils/index.js +905 -0
- package/package.json +227 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { MultiTenantOptions } from './multiTenant.js';
|
|
2
|
+
export { default as multiTenantPreset } from './multiTenant.js';
|
|
3
|
+
import { P as PresetResult, j as IRequestContext, m as IControllerResponse, n as PaginatedResult, d as AnyRecord, o as ResourceConfig } from '../index-DkAW8BXh.js';
|
|
4
|
+
import 'mongoose';
|
|
5
|
+
import 'fastify';
|
|
6
|
+
import '../types-B99TBmFV.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Soft Delete Preset
|
|
10
|
+
*
|
|
11
|
+
* Adds routes for listing deleted items and restoring them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface SoftDeleteOptions {
|
|
15
|
+
deletedField?: string;
|
|
16
|
+
}
|
|
17
|
+
declare function softDeletePreset(options?: SoftDeleteOptions): PresetResult;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Slug Lookup Preset
|
|
21
|
+
*
|
|
22
|
+
* Adds a route to get resource by slug.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
interface SlugLookupOptions {
|
|
26
|
+
slugField?: string;
|
|
27
|
+
}
|
|
28
|
+
declare function slugLookupPreset(options?: SlugLookupOptions): PresetResult;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Owned By User Preset
|
|
32
|
+
*
|
|
33
|
+
* Adds ownership validation for update/delete operations.
|
|
34
|
+
*
|
|
35
|
+
* BEHAVIOR:
|
|
36
|
+
* - On update/remove, sets _ownershipCheck on request
|
|
37
|
+
* - BaseController enforces ownership before mutation
|
|
38
|
+
* - Users can only modify resources where ownerField matches their ID
|
|
39
|
+
*
|
|
40
|
+
* BYPASS:
|
|
41
|
+
* - Users with bypassRoles (default: ['admin', 'superadmin']) skip check
|
|
42
|
+
* - Resources without the ownerField are not checked
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* defineResource({
|
|
46
|
+
* name: 'post',
|
|
47
|
+
* presets: [{ name: 'ownedByUser', ownerField: 'authorId' }],
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* // User A cannot update/delete User B's posts
|
|
51
|
+
* // Admins can modify any post
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
interface OwnedByUserOptions {
|
|
55
|
+
ownerField?: string;
|
|
56
|
+
bypassRoles?: string[];
|
|
57
|
+
}
|
|
58
|
+
declare function ownedByUserPreset(options?: OwnedByUserOptions): PresetResult;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Tree Preset
|
|
62
|
+
*
|
|
63
|
+
* Adds routes for hierarchical tree structures.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
interface TreeOptions {
|
|
67
|
+
parentField?: string;
|
|
68
|
+
}
|
|
69
|
+
declare function treePreset(options?: TreeOptions): PresetResult;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Audited Preset
|
|
73
|
+
*
|
|
74
|
+
* Adds createdBy/updatedBy tracking to resources.
|
|
75
|
+
* Works with the audit plugin for full change tracking.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* defineResource({
|
|
79
|
+
* name: 'product',
|
|
80
|
+
* presets: ['audited'],
|
|
81
|
+
* // Fields createdBy, updatedBy auto-populated from user context
|
|
82
|
+
* });
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
interface AuditedPresetOptions {
|
|
86
|
+
/** Field name for creator (default: 'createdBy') */
|
|
87
|
+
createdByField?: string;
|
|
88
|
+
/** Field name for updater (default: 'updatedBy') */
|
|
89
|
+
updatedByField?: string;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Audited preset - adds createdBy/updatedBy tracking
|
|
93
|
+
*/
|
|
94
|
+
declare function auditedPreset(options?: AuditedPresetOptions): PresetResult;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Preset Type Interfaces
|
|
98
|
+
*
|
|
99
|
+
* TypeScript interfaces that document the controller methods required by each preset.
|
|
100
|
+
* These interfaces help with type safety when using presets.
|
|
101
|
+
*
|
|
102
|
+
* @example Using with custom controllers
|
|
103
|
+
* ```typescript
|
|
104
|
+
* import { BaseController } from '@classytic/arc';
|
|
105
|
+
* import type { ISoftDeleteController } from '@classytic/arc/presets';
|
|
106
|
+
*
|
|
107
|
+
* class ProductController extends BaseController<Product> implements ISoftDeleteController {
|
|
108
|
+
* // TypeScript now ensures you have getDeleted() and restore() methods
|
|
109
|
+
* }
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Soft Delete Preset Interface
|
|
115
|
+
*
|
|
116
|
+
* Required when using the `softDelete` preset.
|
|
117
|
+
* BaseController provides default implementations that delegate to repository methods.
|
|
118
|
+
*
|
|
119
|
+
* **Routes Added:**
|
|
120
|
+
* - `GET /deleted` → `getDeleted()`
|
|
121
|
+
* - `POST /:id/restore` → `restore()`
|
|
122
|
+
*
|
|
123
|
+
* **Repository Requirements:**
|
|
124
|
+
* Your repository must implement:
|
|
125
|
+
* - `getDeleted(options): Promise<PaginatedResult<T> | T[]>`
|
|
126
|
+
* - `restore(id): Promise<T | null>`
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```typescript
|
|
130
|
+
* defineResource({
|
|
131
|
+
* name: 'product',
|
|
132
|
+
* presets: ['softDelete'],
|
|
133
|
+
* adapter: createMongooseAdapter({
|
|
134
|
+
* model: ProductModel,
|
|
135
|
+
* repository: productRepository, // Must implement getDeleted/restore
|
|
136
|
+
* }),
|
|
137
|
+
* });
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
interface ISoftDeleteController<TDoc = unknown> {
|
|
141
|
+
/**
|
|
142
|
+
* Get all soft-deleted items
|
|
143
|
+
* Called by: GET /deleted
|
|
144
|
+
*/
|
|
145
|
+
getDeleted(context: IRequestContext): Promise<IControllerResponse<PaginatedResult<TDoc>>>;
|
|
146
|
+
/**
|
|
147
|
+
* Restore a soft-deleted item by ID
|
|
148
|
+
* Called by: POST /:id/restore
|
|
149
|
+
*/
|
|
150
|
+
restore(context: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Slug Lookup Preset Interface
|
|
154
|
+
*
|
|
155
|
+
* Required when using the `slugLookup` preset.
|
|
156
|
+
* BaseController provides default implementation that delegates to repository.
|
|
157
|
+
*
|
|
158
|
+
* **Routes Added:**
|
|
159
|
+
* - `GET /slug/:slug` → `getBySlug()`
|
|
160
|
+
*
|
|
161
|
+
* **Repository Requirements:**
|
|
162
|
+
* Your repository must implement:
|
|
163
|
+
* - `getBySlug(slug, options): Promise<T | null>`
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```typescript
|
|
167
|
+
* defineResource({
|
|
168
|
+
* name: 'product',
|
|
169
|
+
* presets: ['slugLookup'],
|
|
170
|
+
* adapter: createMongooseAdapter({
|
|
171
|
+
* model: ProductModel,
|
|
172
|
+
* repository: productRepository, // Must implement getBySlug
|
|
173
|
+
* }),
|
|
174
|
+
* });
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
interface ISlugLookupController<TDoc = unknown> {
|
|
178
|
+
/**
|
|
179
|
+
* Get a resource by its slug
|
|
180
|
+
* Called by: GET /slug/:slug
|
|
181
|
+
*/
|
|
182
|
+
getBySlug(context: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Tree Preset Interface
|
|
186
|
+
*
|
|
187
|
+
* Required when using the `tree` preset for hierarchical data structures.
|
|
188
|
+
* BaseController provides default implementations that delegate to repository.
|
|
189
|
+
*
|
|
190
|
+
* **Routes Added:**
|
|
191
|
+
* - `GET /tree` → `getTree()`
|
|
192
|
+
* - `GET /:parent/children` → `getChildren()`
|
|
193
|
+
*
|
|
194
|
+
* **Repository Requirements:**
|
|
195
|
+
* Your repository must implement:
|
|
196
|
+
* - `getTree(options): Promise<T[]>`
|
|
197
|
+
* - `getChildren(parentId, options): Promise<T[]>`
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```typescript
|
|
201
|
+
* defineResource({
|
|
202
|
+
* name: 'category',
|
|
203
|
+
* presets: [{ name: 'tree', parentField: 'parentId' }],
|
|
204
|
+
* adapter: createMongooseAdapter({
|
|
205
|
+
* model: CategoryModel,
|
|
206
|
+
* repository: categoryRepository, // Must implement getTree/getChildren
|
|
207
|
+
* }),
|
|
208
|
+
* });
|
|
209
|
+
* ```
|
|
210
|
+
*/
|
|
211
|
+
interface ITreeController<TDoc = unknown> {
|
|
212
|
+
/**
|
|
213
|
+
* Get the full hierarchical tree
|
|
214
|
+
* Called by: GET /tree
|
|
215
|
+
*/
|
|
216
|
+
getTree(context: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
217
|
+
/**
|
|
218
|
+
* Get direct children of a parent node
|
|
219
|
+
* Called by: GET /:parent/children
|
|
220
|
+
*/
|
|
221
|
+
getChildren(context: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Owned By User Preset
|
|
225
|
+
*
|
|
226
|
+
* This preset does NOT require controller methods - it adds middleware only.
|
|
227
|
+
* Middleware automatically enforces ownership checks on update/delete operations.
|
|
228
|
+
*
|
|
229
|
+
* **Behavior:**
|
|
230
|
+
* - Users can only update/delete resources where `ownerField` matches their user ID
|
|
231
|
+
* - Admins (configurable via `bypassRoles`) can modify any resource
|
|
232
|
+
*
|
|
233
|
+
* **No controller interface needed** - ownership is enforced via middleware.
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* ```typescript
|
|
237
|
+
* defineResource({
|
|
238
|
+
* name: 'post',
|
|
239
|
+
* presets: [{ name: 'ownedByUser', ownerField: 'authorId' }],
|
|
240
|
+
* });
|
|
241
|
+
* ```
|
|
242
|
+
*/
|
|
243
|
+
type IOwnedByUserPreset = never;
|
|
244
|
+
/**
|
|
245
|
+
* Multi-Tenant Preset
|
|
246
|
+
*
|
|
247
|
+
* This preset does NOT require controller methods - it adds middleware only.
|
|
248
|
+
* Middleware automatically filters resources by organization/tenant ID.
|
|
249
|
+
*
|
|
250
|
+
* **Behavior:**
|
|
251
|
+
* - All list/get operations are automatically filtered by `tenantField`
|
|
252
|
+
* - Create operations automatically inject the tenant ID
|
|
253
|
+
* - Superadmins (configurable via `bypassRoles`) can access all tenants
|
|
254
|
+
*
|
|
255
|
+
* **No controller interface needed** - tenant isolation is enforced via middleware.
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* ```typescript
|
|
259
|
+
* defineResource({
|
|
260
|
+
* name: 'invoice',
|
|
261
|
+
* presets: [{ name: 'multiTenant', tenantField: 'organizationId' }],
|
|
262
|
+
* });
|
|
263
|
+
* ```
|
|
264
|
+
*/
|
|
265
|
+
type IMultiTenantPreset = never;
|
|
266
|
+
/**
|
|
267
|
+
* Audited Preset
|
|
268
|
+
*
|
|
269
|
+
* This preset does NOT require controller methods - it adds middleware only.
|
|
270
|
+
* Middleware automatically populates `createdBy`/`updatedBy` fields from authenticated user.
|
|
271
|
+
*
|
|
272
|
+
* **Behavior:**
|
|
273
|
+
* - On create: Sets both `createdBy` and `updatedBy` to current user ID
|
|
274
|
+
* - On update: Sets `updatedBy` to current user ID
|
|
275
|
+
* - Fields are marked as `systemManaged` in schemas (excluded from user input)
|
|
276
|
+
*
|
|
277
|
+
* **No controller interface needed** - audit fields are managed via middleware.
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* ```typescript
|
|
281
|
+
* defineResource({
|
|
282
|
+
* name: 'product',
|
|
283
|
+
* presets: ['audited'], // Uses default fields: createdBy, updatedBy
|
|
284
|
+
* });
|
|
285
|
+
* ```
|
|
286
|
+
*/
|
|
287
|
+
type IAuditedPreset = never;
|
|
288
|
+
/**
|
|
289
|
+
* Combined type for controllers using multiple presets
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* ```typescript
|
|
293
|
+
* import type { IPresetController } from '@classytic/arc/presets';
|
|
294
|
+
*
|
|
295
|
+
* class ProductController
|
|
296
|
+
* extends BaseController<Product>
|
|
297
|
+
* implements IPresetController<Product, 'softDelete' | 'slugLookup' | 'tree'>
|
|
298
|
+
* {
|
|
299
|
+
* // TypeScript ensures all required methods are implemented
|
|
300
|
+
* }
|
|
301
|
+
* ```
|
|
302
|
+
*/
|
|
303
|
+
type IPresetController<TDoc = unknown, TPresets extends 'softDelete' | 'slugLookup' | 'tree' | never = never> = TPresets extends 'softDelete' ? ISoftDeleteController<TDoc> : TPresets extends 'slugLookup' ? ISlugLookupController<TDoc> : TPresets extends 'tree' ? ITreeController<TDoc> : unknown;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Convenience alias for multiTenantPreset with public list/get routes
|
|
307
|
+
* Equivalent to: multiTenantPreset({ allowPublic: ['list', 'get'] })
|
|
308
|
+
*/
|
|
309
|
+
declare const flexibleMultiTenantPreset: (options?: Omit<MultiTenantOptions, "allowPublic">) => PresetResult;
|
|
310
|
+
|
|
311
|
+
type PresetFactory = (options?: AnyRecord) => PresetResult;
|
|
312
|
+
/**
|
|
313
|
+
* Get preset by name with options
|
|
314
|
+
*/
|
|
315
|
+
declare function getPreset(nameOrConfig: string | {
|
|
316
|
+
name: string;
|
|
317
|
+
[key: string]: unknown;
|
|
318
|
+
}): PresetResult;
|
|
319
|
+
/**
|
|
320
|
+
* Register a custom preset
|
|
321
|
+
*/
|
|
322
|
+
declare function registerPreset(name: string, factory: PresetFactory): void;
|
|
323
|
+
/**
|
|
324
|
+
* Get all available preset names
|
|
325
|
+
*/
|
|
326
|
+
declare function getAvailablePresets(): string[];
|
|
327
|
+
type PresetInput = string | PresetResult | {
|
|
328
|
+
name: string;
|
|
329
|
+
[key: string]: unknown;
|
|
330
|
+
};
|
|
331
|
+
/**
|
|
332
|
+
* Apply presets to resource config
|
|
333
|
+
*/
|
|
334
|
+
declare function applyPresets<TDoc = AnyRecord>(config: ResourceConfig<TDoc>, presets?: PresetInput[]): ResourceConfig<TDoc>;
|
|
335
|
+
|
|
336
|
+
export { type AuditedPresetOptions, type IAuditedPreset, type IMultiTenantPreset, type IOwnedByUserPreset, type IPresetController, type ISlugLookupController, type ISoftDeleteController, type ITreeController, MultiTenantOptions, type OwnedByUserOptions, type SlugLookupOptions, type SoftDeleteOptions, type TreeOptions, applyPresets, auditedPreset, flexibleMultiTenantPreset, getAvailablePresets, getPreset, ownedByUserPreset, registerPreset, slugLookupPreset, softDeletePreset, treePreset };
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// src/permissions/index.ts
|
|
2
|
+
function allowPublic() {
|
|
3
|
+
const check = () => true;
|
|
4
|
+
check._isPublic = true;
|
|
5
|
+
return check;
|
|
6
|
+
}
|
|
7
|
+
function requireRoles(roles, options) {
|
|
8
|
+
return (ctx) => {
|
|
9
|
+
if (!ctx.user) {
|
|
10
|
+
return { granted: false, reason: "Authentication required" };
|
|
11
|
+
}
|
|
12
|
+
const userRoles = ctx.user.roles ?? [];
|
|
13
|
+
if (roles.some((r) => userRoles.includes(r))) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
granted: false,
|
|
18
|
+
reason: `Required roles: ${roles.join(", ")}`
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/presets/softDelete.ts
|
|
24
|
+
function softDeletePreset(options = {}) {
|
|
25
|
+
const { deletedField: _deletedField = "deletedAt" } = options;
|
|
26
|
+
return {
|
|
27
|
+
name: "softDelete",
|
|
28
|
+
additionalRoutes: (permissions) => [
|
|
29
|
+
{
|
|
30
|
+
method: "GET",
|
|
31
|
+
path: "/deleted",
|
|
32
|
+
handler: "getDeleted",
|
|
33
|
+
summary: "Get soft-deleted items",
|
|
34
|
+
permissions: permissions.list ?? requireRoles(["admin"]),
|
|
35
|
+
wrapHandler: true
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
method: "POST",
|
|
39
|
+
path: "/:id/restore",
|
|
40
|
+
handler: "restore",
|
|
41
|
+
summary: "Restore soft-deleted item",
|
|
42
|
+
permissions: permissions.update ?? requireRoles(["admin"]),
|
|
43
|
+
wrapHandler: true
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/presets/slugLookup.ts
|
|
50
|
+
function slugLookupPreset(options = {}) {
|
|
51
|
+
const { slugField = "slug" } = options;
|
|
52
|
+
return {
|
|
53
|
+
name: "slugLookup",
|
|
54
|
+
additionalRoutes: (permissions) => [
|
|
55
|
+
{
|
|
56
|
+
method: "GET",
|
|
57
|
+
path: `/slug/:${slugField}`,
|
|
58
|
+
handler: "getBySlug",
|
|
59
|
+
summary: "Get by slug",
|
|
60
|
+
permissions: permissions.get ?? allowPublic(),
|
|
61
|
+
wrapHandler: true
|
|
62
|
+
// Handler is a ControllerHandler
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
// Pass to controller so it knows which param to read
|
|
66
|
+
controllerOptions: {
|
|
67
|
+
slugField
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/presets/ownedByUser.ts
|
|
73
|
+
function createOwnershipCheck(ownerField, bypassRoles) {
|
|
74
|
+
return async (request, _reply) => {
|
|
75
|
+
const user = request.user;
|
|
76
|
+
if (!user) return;
|
|
77
|
+
const userWithRoles = user;
|
|
78
|
+
if (userWithRoles.roles && bypassRoles.some((r) => userWithRoles.roles.includes(r))) return;
|
|
79
|
+
const userWithId = user;
|
|
80
|
+
const userId = userWithId._id ?? userWithId.id;
|
|
81
|
+
if (userId) {
|
|
82
|
+
request._ownershipCheck = {
|
|
83
|
+
field: ownerField,
|
|
84
|
+
userId
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function ownedByUserPreset(options = {}) {
|
|
90
|
+
const {
|
|
91
|
+
ownerField = "userId",
|
|
92
|
+
bypassRoles = ["admin", "superadmin"]
|
|
93
|
+
} = options;
|
|
94
|
+
const ownershipMiddleware = createOwnershipCheck(ownerField, bypassRoles);
|
|
95
|
+
return {
|
|
96
|
+
name: "ownedByUser",
|
|
97
|
+
middlewares: {
|
|
98
|
+
update: [ownershipMiddleware],
|
|
99
|
+
delete: [ownershipMiddleware]
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/presets/multiTenant.ts
|
|
105
|
+
function defaultExtractOrganizationId(request) {
|
|
106
|
+
const context = request.context;
|
|
107
|
+
if (context?.organizationId) {
|
|
108
|
+
return context.organizationId;
|
|
109
|
+
}
|
|
110
|
+
const user = request.user;
|
|
111
|
+
if (user?.organizationId) {
|
|
112
|
+
return user.organizationId;
|
|
113
|
+
}
|
|
114
|
+
if (user?.organization) {
|
|
115
|
+
const org = user.organization;
|
|
116
|
+
return org._id || org.id || org;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
function createTenantFilter(tenantField, bypassRoles, extractOrganizationId) {
|
|
121
|
+
return async (request, reply) => {
|
|
122
|
+
const user = request.user;
|
|
123
|
+
if (!user) {
|
|
124
|
+
reply.code(401).send({
|
|
125
|
+
success: false,
|
|
126
|
+
error: "Unauthorized",
|
|
127
|
+
message: "Authentication required for multi-tenant resources"
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const userWithRoles = user;
|
|
132
|
+
if (userWithRoles.roles && bypassRoles.some((r) => userWithRoles.roles.includes(r))) return;
|
|
133
|
+
const orgId = extractOrganizationId(request);
|
|
134
|
+
if (!orgId) {
|
|
135
|
+
reply.code(403).send({
|
|
136
|
+
success: false,
|
|
137
|
+
error: "Forbidden",
|
|
138
|
+
message: "Organization context required for this operation"
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
request.query = request.query ?? {};
|
|
143
|
+
request.query._policyFilters = {
|
|
144
|
+
...request.query._policyFilters ?? {},
|
|
145
|
+
[tenantField]: orgId
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function createFlexibleTenantFilter(tenantField, bypassRoles, extractOrganizationId) {
|
|
150
|
+
return async (request, reply) => {
|
|
151
|
+
const user = request.user;
|
|
152
|
+
const orgId = extractOrganizationId(request);
|
|
153
|
+
if (!orgId) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!user) {
|
|
157
|
+
reply.code(401).send({
|
|
158
|
+
success: false,
|
|
159
|
+
error: "Unauthorized",
|
|
160
|
+
message: "Authentication required for organization-scoped data"
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const userWithRoles = user;
|
|
165
|
+
if (userWithRoles.roles && bypassRoles.some((r) => userWithRoles.roles.includes(r))) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
request.query = request.query ?? {};
|
|
169
|
+
request.query._policyFilters = {
|
|
170
|
+
...request.query._policyFilters ?? {},
|
|
171
|
+
[tenantField]: orgId
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function createTenantInjection(tenantField, extractOrganizationId) {
|
|
176
|
+
return async (request, reply) => {
|
|
177
|
+
const orgId = extractOrganizationId(request);
|
|
178
|
+
if (!orgId) {
|
|
179
|
+
reply.code(403).send({
|
|
180
|
+
success: false,
|
|
181
|
+
error: "Forbidden",
|
|
182
|
+
message: "Organization context required to create resources"
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (request.body) {
|
|
187
|
+
request.body[tenantField] = orgId;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function multiTenantPreset(options = {}) {
|
|
192
|
+
const {
|
|
193
|
+
tenantField = "organizationId",
|
|
194
|
+
bypassRoles = ["superadmin"],
|
|
195
|
+
extractOrganizationId = defaultExtractOrganizationId,
|
|
196
|
+
allowPublic: allowPublic2 = []
|
|
197
|
+
} = options;
|
|
198
|
+
const strictTenantFilter = createTenantFilter(tenantField, bypassRoles, extractOrganizationId);
|
|
199
|
+
const flexibleTenantFilter = createFlexibleTenantFilter(tenantField, bypassRoles, extractOrganizationId);
|
|
200
|
+
const tenantInjection = createTenantInjection(tenantField, extractOrganizationId);
|
|
201
|
+
const getFilter = (route) => allowPublic2.includes(route) ? flexibleTenantFilter : strictTenantFilter;
|
|
202
|
+
return {
|
|
203
|
+
name: "multiTenant",
|
|
204
|
+
middlewares: {
|
|
205
|
+
list: [getFilter("list")],
|
|
206
|
+
get: [getFilter("get")],
|
|
207
|
+
create: [tenantInjection],
|
|
208
|
+
update: [getFilter("update")],
|
|
209
|
+
delete: [getFilter("delete")]
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/presets/tree.ts
|
|
215
|
+
function treePreset(options = {}) {
|
|
216
|
+
const { parentField = "parent" } = options;
|
|
217
|
+
return {
|
|
218
|
+
name: "tree",
|
|
219
|
+
additionalRoutes: (permissions) => [
|
|
220
|
+
{
|
|
221
|
+
method: "GET",
|
|
222
|
+
path: "/tree",
|
|
223
|
+
handler: "getTree",
|
|
224
|
+
summary: "Get hierarchical tree",
|
|
225
|
+
permissions: permissions.list ?? allowPublic(),
|
|
226
|
+
wrapHandler: true
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
method: "GET",
|
|
230
|
+
path: `/:${parentField}/children`,
|
|
231
|
+
handler: "getChildren",
|
|
232
|
+
summary: "Get children of parent",
|
|
233
|
+
permissions: permissions.list ?? allowPublic(),
|
|
234
|
+
wrapHandler: true
|
|
235
|
+
}
|
|
236
|
+
],
|
|
237
|
+
// Pass to controller so it knows which param to read
|
|
238
|
+
controllerOptions: {
|
|
239
|
+
parentField
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/presets/audited.ts
|
|
245
|
+
function auditedPreset(options = {}) {
|
|
246
|
+
const { createdByField = "createdBy", updatedByField = "updatedBy" } = options;
|
|
247
|
+
const injectCreatedBy = async (request, _reply) => {
|
|
248
|
+
const userWithId = request.user;
|
|
249
|
+
if (userWithId?._id || userWithId?.id) {
|
|
250
|
+
const userId = userWithId._id ?? userWithId.id;
|
|
251
|
+
request.body[createdByField] = userId;
|
|
252
|
+
request.body[updatedByField] = userId;
|
|
253
|
+
}
|
|
254
|
+
return void 0;
|
|
255
|
+
};
|
|
256
|
+
const injectUpdatedBy = async (request, _reply) => {
|
|
257
|
+
const userWithId = request.user;
|
|
258
|
+
if (userWithId?._id || userWithId?.id) {
|
|
259
|
+
request.body[updatedByField] = userWithId._id ?? userWithId.id;
|
|
260
|
+
}
|
|
261
|
+
return void 0;
|
|
262
|
+
};
|
|
263
|
+
return {
|
|
264
|
+
name: "audited",
|
|
265
|
+
schemaOptions: {
|
|
266
|
+
fieldRules: {
|
|
267
|
+
[createdByField]: { systemManaged: true },
|
|
268
|
+
[updatedByField]: { systemManaged: true },
|
|
269
|
+
createdAt: { systemManaged: true },
|
|
270
|
+
updatedAt: { systemManaged: true }
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
middlewares: {
|
|
274
|
+
create: [injectCreatedBy],
|
|
275
|
+
update: [injectUpdatedBy]
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/presets/index.ts
|
|
281
|
+
var flexibleMultiTenantPreset = (options = {}) => multiTenantPreset({ ...options, allowPublic: ["list", "get"] });
|
|
282
|
+
var presetRegistry = {
|
|
283
|
+
softDelete: softDeletePreset,
|
|
284
|
+
slugLookup: slugLookupPreset,
|
|
285
|
+
ownedByUser: ownedByUserPreset,
|
|
286
|
+
multiTenant: multiTenantPreset,
|
|
287
|
+
tree: treePreset,
|
|
288
|
+
audited: auditedPreset
|
|
289
|
+
};
|
|
290
|
+
function getPreset(nameOrConfig) {
|
|
291
|
+
if (typeof nameOrConfig === "object" && nameOrConfig.name) {
|
|
292
|
+
const { name, ...options } = nameOrConfig;
|
|
293
|
+
return resolvePreset(name, options);
|
|
294
|
+
}
|
|
295
|
+
return resolvePreset(nameOrConfig);
|
|
296
|
+
}
|
|
297
|
+
function resolvePreset(name, options = {}) {
|
|
298
|
+
const factory = presetRegistry[name];
|
|
299
|
+
if (!factory) {
|
|
300
|
+
const available = Object.keys(presetRegistry).join(", ");
|
|
301
|
+
throw new Error(
|
|
302
|
+
`Unknown preset: '${name}'
|
|
303
|
+
Available presets: ${available}
|
|
304
|
+
Docs: https://github.com/classytic/arc#presets`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return factory(options);
|
|
308
|
+
}
|
|
309
|
+
function registerPreset(name, factory) {
|
|
310
|
+
if (presetRegistry[name]) {
|
|
311
|
+
throw new Error(`Preset '${name}' already exists`);
|
|
312
|
+
}
|
|
313
|
+
presetRegistry[name] = factory;
|
|
314
|
+
}
|
|
315
|
+
function getAvailablePresets() {
|
|
316
|
+
return Object.keys(presetRegistry);
|
|
317
|
+
}
|
|
318
|
+
function applyPresets(config, presets = []) {
|
|
319
|
+
let result = { ...config };
|
|
320
|
+
for (const preset of presets) {
|
|
321
|
+
const resolved = resolvePresetInput(preset);
|
|
322
|
+
result = mergePreset(result, resolved);
|
|
323
|
+
}
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
function resolvePresetInput(preset) {
|
|
327
|
+
if (typeof preset === "object" && ("middlewares" in preset || "additionalRoutes" in preset)) {
|
|
328
|
+
return preset;
|
|
329
|
+
}
|
|
330
|
+
if (typeof preset === "object" && "name" in preset) {
|
|
331
|
+
const { name, ...options } = preset;
|
|
332
|
+
return resolvePreset(name, options);
|
|
333
|
+
}
|
|
334
|
+
return resolvePreset(preset);
|
|
335
|
+
}
|
|
336
|
+
function mergePreset(config, preset) {
|
|
337
|
+
const result = { ...config };
|
|
338
|
+
if (preset.additionalRoutes) {
|
|
339
|
+
const routes = typeof preset.additionalRoutes === "function" ? preset.additionalRoutes(config.permissions ?? {}) : preset.additionalRoutes;
|
|
340
|
+
result.additionalRoutes = [
|
|
341
|
+
...result.additionalRoutes ?? [],
|
|
342
|
+
...routes
|
|
343
|
+
];
|
|
344
|
+
}
|
|
345
|
+
if (preset.middlewares) {
|
|
346
|
+
result.middlewares = result.middlewares ?? {};
|
|
347
|
+
for (const [op, mws] of Object.entries(preset.middlewares)) {
|
|
348
|
+
const key = op;
|
|
349
|
+
result.middlewares[key] = [
|
|
350
|
+
...result.middlewares[key] ?? [],
|
|
351
|
+
...mws ?? []
|
|
352
|
+
];
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (preset.schemaOptions) {
|
|
356
|
+
result.schemaOptions = {
|
|
357
|
+
...result.schemaOptions,
|
|
358
|
+
...preset.schemaOptions
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
if (preset.controllerOptions) {
|
|
362
|
+
result._controllerOptions = {
|
|
363
|
+
...result._controllerOptions,
|
|
364
|
+
...preset.controllerOptions
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
if (preset.hooks && preset.hooks.length > 0) {
|
|
368
|
+
result._hooks = result._hooks ?? [];
|
|
369
|
+
for (const hook of preset.hooks) {
|
|
370
|
+
result._hooks.push({
|
|
371
|
+
presetName: preset.name,
|
|
372
|
+
operation: hook.operation,
|
|
373
|
+
phase: hook.phase,
|
|
374
|
+
handler: hook.handler,
|
|
375
|
+
priority: hook.priority
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export { applyPresets, auditedPreset, flexibleMultiTenantPreset, getAvailablePresets, getPreset, multiTenantPreset, ownedByUserPreset, registerPreset, slugLookupPreset, softDeletePreset, treePreset };
|