@classytic/arc 2.8.5 → 2.10.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 +50 -38
- package/dist/{BaseController-DAGGc5Xn.mjs → BaseController-CbKKIflT.mjs} +193 -143
- package/dist/EventTransport-CUw5NNWe.d.mts +293 -0
- package/dist/{ResourceRegistry-C6uXlWe3.mjs → ResourceRegistry-BPd6NQDm.mjs} +1 -1
- package/dist/adapters/index.d.mts +3 -3
- package/dist/adapters/index.mjs +2 -2
- package/dist/{adapters-BBqAVvPK.mjs → adapters-BXY4i-hw.mjs} +210 -41
- package/dist/audit/index.d.mts +135 -11
- package/dist/audit/index.mjs +107 -20
- package/dist/auth/index.d.mts +17 -9
- package/dist/auth/index.mjs +14 -7
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi-BuUcUEJq.mjs → betterAuthOpenApi-BBRVhjQN.mjs} +1 -1
- package/dist/cache/index.d.mts +17 -15
- package/dist/cache/index.mjs +15 -14
- package/dist/{caching-IMuYVjTL.mjs → caching-CBpK_SCM.mjs} +8 -3
- package/dist/cli/commands/describe.mjs +1 -1
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +1 -1
- package/dist/cli/commands/init.mjs +1 -1
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -6
- package/dist/{defineResource-tcgySDo1.mjs → core-CcR01lup.mjs} +58 -61
- package/dist/{createActionRouter-BORM8f17.mjs → createActionRouter-Bp_5c_2b.mjs} +3 -3
- package/dist/{createApp-B1EY8zxa.mjs → createApp-BuvPma24.mjs} +15 -14
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-DtFxrG0s.mjs → elevation-C7hgL_aI.mjs} +22 -8
- package/dist/{errorHandler-f869_8PQ.mjs → errorHandler-Bb49BvPD.mjs} +59 -7
- package/dist/{errorHandler-Bah5JhBd.d.mts → errorHandler-DRQ3EqfL.d.mts} +37 -2
- package/dist/{eventPlugin-D9DKB2zM.d.mts → eventPlugin-CxWgpd6K.d.mts} +14 -2
- package/dist/{eventPlugin-CDjVTM82.mjs → eventPlugin-DCUjuiQT.mjs} +83 -5
- package/dist/events/index.d.mts +150 -36
- package/dist/events/index.mjs +355 -101
- 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 +2 -2
- package/dist/{types-DZi1aYhm.d.mts → fields-Lo1VUDpt.d.mts} +121 -1
- package/dist/{fields-ipsbIRPK.mjs → fields-bxkeltzz.mjs} +18 -5
- package/dist/{filesUpload-C7r7HIeA.mjs → filesUpload-t21LS-py.mjs} +65 -7
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +32 -5
- package/dist/idempotency/index.mjs +119 -12
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-DtDzOBn8.d.mts → index-8qw4y6ff.d.mts} +4 -135
- package/dist/{index-BLXBmWud.d.mts → index-ChIw3776.d.mts} +283 -408
- package/dist/{interface-CMRutPfe.d.mts → index-Cl0uoKd5.d.mts} +1758 -2506
- package/dist/{index-C1meYuDn.d.mts → index-DStwgFUK.d.mts} +81 -7
- package/dist/index.d.mts +7 -8
- package/dist/index.mjs +11 -12
- 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 +26 -8
- package/dist/integrations/mcp/index.mjs +96 -17
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/webhooks.d.mts +5 -0
- package/dist/integrations/webhooks.mjs +6 -0
- package/dist/interface-D218ikEo.d.mts +77 -0
- package/dist/{memory-Cp7_cAko.mjs → memory-B5Amv9A1.mjs} +23 -8
- package/dist/{openapi-CbKUJY_m.mjs → openapi-B5F8AddX.mjs} +3 -3
- package/dist/org/index.d.mts +2 -2
- package/dist/permissions/index.d.mts +3 -4
- package/dist/permissions/index.mjs +5 -5
- package/dist/{permissions-CH4cNwJi.mjs → permissions-Dk6mshja.mjs} +315 -397
- package/dist/plugins/index.d.mts +7 -7
- package/dist/plugins/index.mjs +14 -16
- package/dist/plugins/response-cache.mjs +2 -2
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +27 -5
- package/dist/presets/filesUpload.mjs +1 -1
- package/dist/presets/index.d.mts +3 -2
- package/dist/presets/index.mjs +4 -3
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +2 -2
- package/dist/presets/search.d.mts +178 -0
- package/dist/presets/search.mjs +150 -0
- package/dist/{presets-C2xgzW6x.mjs → presets-fLJVXdVn.mjs} +1 -1
- package/dist/{queryCachePlugin-BJJGBTlu.d.mts → queryCachePlugin-BKbWjgDG.d.mts} +1 -1
- package/dist/{queryCachePlugin-BH-fidlv.mjs → queryCachePlugin-DQCEfJis.mjs} +9 -9
- package/dist/{queryParser-CgCtsjti.mjs → queryParser-DBqBB6AC.mjs} +1 -1
- package/dist/{redis-BM00zaPB.d.mts → redis-DqyeggCa.d.mts} +1 -1
- package/dist/{redis-stream-CrsfUmPt.d.mts → redis-stream-CakIQmwR.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{resourceToTools-8s-EsCCe.mjs → resourceToTools-BElv3xPT.mjs} +65 -48
- package/dist/{schemaConverter-Y7nCYaLJ.mjs → schemaConverter-BxFDdtXu.mjs} +1 -1
- package/dist/scope/index.d.mts +1 -1
- package/dist/scope/index.mjs +2 -2
- package/dist/{sse-Ad7ypl9e.mjs → sse-yBCgOLGu.mjs} +1 -1
- package/dist/store-helpers-ZCSMJJAX.mjs +57 -0
- package/dist/testing/index.d.mts +9 -17
- package/dist/testing/index.mjs +27 -83
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/index.mjs +1 -31
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-BsbNMEDR.d.mts → types-Btdda02s.d.mts} +1 -1
- package/dist/{types-Ch9pTQbf.d.mts → types-Co8k3NyS.d.mts} +11 -9
- package/dist/types-Csi3FLfq.mjs +27 -0
- package/dist/utils/index.d.mts +208 -4
- package/dist/utils/index.mjs +5 -6
- package/dist/{utils-yYT3HDXt.mjs → utils-B2fNOD_i.mjs} +285 -2
- package/dist/{versioning-CDugduqI.mjs → versioning-C2U_bLY0.mjs} +3 -5
- package/package.json +20 -26
- package/skills/arc/SKILL.md +97 -23
- package/skills/arc/references/auth.md +94 -0
- package/skills/arc/references/events.md +200 -12
- package/skills/arc/references/mcp.md +4 -17
- package/skills/arc/references/multi-tenancy.md +43 -0
- package/skills/arc/references/production.md +34 -60
- package/dist/EventTransport-BXja8NOc.d.mts +0 -135
- package/dist/audit/mongodb.d.mts +0 -2
- package/dist/audit/mongodb.mjs +0 -2
- package/dist/circuitBreaker-cmi5XDv5.mjs +0 -284
- package/dist/circuitBreaker-dTtG-UyS.d.mts +0 -206
- package/dist/core-F0QoWBt2.mjs +0 -34
- package/dist/dynamic/index.d.mts +0 -93
- package/dist/dynamic/index.mjs +0 -122
- package/dist/fields-DpZQa_Q3.d.mts +0 -109
- package/dist/idempotency/mongodb.d.mts +0 -2
- package/dist/idempotency/mongodb.mjs +0 -123
- package/dist/interface-4y979v99.d.mts +0 -54
- package/dist/mongodb-BsP-WbhN.d.mts +0 -127
- package/dist/mongodb-CTcp0hQZ.d.mts +0 -80
- package/dist/mongodb-Utc5k_-0.mjs +0 -90
- package/dist/policies/index.d.mts +0 -432
- package/dist/policies/index.mjs +0 -318
- package/dist/rpc/index.d.mts +0 -90
- package/dist/rpc/index.mjs +0 -248
- /package/dist/{HookSystem-HprTmvVY.mjs → HookSystem-BNYKnrXF.mjs} +0 -0
- /package/dist/{applyPermissionResult-D6GPMsvh.mjs → applyPermissionResult-QhV1Pa-g.mjs} +0 -0
- /package/dist/{constants-Cxde4rpC.mjs → constants-BhY1OHoH.mjs} +0 -0
- /package/dist/{elevation-B6S5csVA.d.mts → elevation-C5SwtkAn.d.mts} +0 -0
- /package/dist/{errors-Ck2h67pm.d.mts → errors-CCSsMpXE.d.mts} +0 -0
- /package/dist/{errors-BF2bIOIS.mjs → errors-D5c-5BJL.mjs} +0 -0
- /package/dist/{externalPaths-BnkYrNzp.d.mts → externalPaths-BQ8QijNH.d.mts} +0 -0
- /package/dist/{interface-DfLGcus7.d.mts → interface-CSbZdv_3.d.mts} +0 -0
- /package/dist/{loadResources-PWd0OCpV.mjs → loadResources-BAzJItAJ.mjs} +0 -0
- /package/dist/{logger-D1YrIImS.mjs → logger-DLg8-Ueg.mjs} +0 -0
- /package/dist/{metrics-B-PU4-Yu.mjs → metrics-DuhiSEZI.mjs} +0 -0
- /package/dist/{pluralize-CWP6MB39.mjs → pluralize-A0tWEl1K.mjs} +0 -0
- /package/dist/{registry-BiTKT1Dg.mjs → registry-B3lRFBWo.mjs} +0 -0
- /package/dist/{replyHelpers-CxkYGT81.mjs → replyHelpers-CXtJDAZ0.mjs} +0 -0
- /package/dist/{requestContext-DYvHl113.mjs → requestContext-xHIKedG6.mjs} +0 -0
- /package/dist/{sessionManager-DDCmiNIo.d.mts → sessionManager-BkzVU8h2.d.mts} +0 -0
- /package/dist/{storage-Dfzt4VTl.d.mts → storage-CVk_SEn2.d.mts} +0 -0
- /package/dist/{tracing-DdN2-wHJ.d.mts → tracing-65B51Dw3.d.mts} +0 -0
- /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-Cj5Rgvlg.mjs} +0 -0
- /package/dist/{types-ZUu_h0jp.mjs → types-DV9WDfeg.mjs} +0 -0
|
@@ -1,8 +1,112 @@
|
|
|
1
1
|
import { r as RequestScope } from "./types-BD85MlEK.mjs";
|
|
2
|
-
import { n as FieldPermissionMap } from "./fields-
|
|
3
|
-
import {
|
|
2
|
+
import { c as PermissionCheck, d as UserBase, n as FieldPermissionMap } from "./fields-Lo1VUDpt.mjs";
|
|
3
|
+
import { n as DomainEvent } from "./EventTransport-CUw5NNWe.mjs";
|
|
4
4
|
import { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest, RouteHandlerMethod, RouteHandlerMethod as RouteHandlerMethod$1 } from "fastify";
|
|
5
|
+
import * as _$_classytic_repo_core_repository0 from "@classytic/repo-core/repository";
|
|
6
|
+
import { MinimalRepo, QueryOptions, StandardRepo } from "@classytic/repo-core/repository";
|
|
7
|
+
import { KeysetPaginationResult, OffsetPaginationResult } from "@classytic/repo-core/pagination";
|
|
5
8
|
|
|
9
|
+
//#region src/types/base.d.ts
|
|
10
|
+
declare module "fastify" {
|
|
11
|
+
interface FastifyRequest {
|
|
12
|
+
/** Request scope — set by auth adapter, read by permissions/presets/guards */
|
|
13
|
+
scope: RequestScope;
|
|
14
|
+
/**
|
|
15
|
+
* Current user — set by auth adapter (Better Auth, JWT, custom).
|
|
16
|
+
* `undefined` on public routes (`auth: false`) or unauthenticated requests.
|
|
17
|
+
* Guard with `if (request.user)` on routes that allow anonymous access.
|
|
18
|
+
*
|
|
19
|
+
* Kept as required (not `user?`) because `@fastify/jwt` declares it
|
|
20
|
+
* as required — declaration merges must have identical modifiers.
|
|
21
|
+
* The `| undefined` in the type achieves the same DX.
|
|
22
|
+
*/
|
|
23
|
+
user: Record<string, unknown> | undefined;
|
|
24
|
+
/** Policy-injected query filters (e.g. ownership, org-scoping) */
|
|
25
|
+
_policyFilters?: Record<string, unknown>;
|
|
26
|
+
/** Field mask — fields to include/exclude in responses */
|
|
27
|
+
fieldMask?: {
|
|
28
|
+
include?: string[];
|
|
29
|
+
exclude?: string[];
|
|
30
|
+
};
|
|
31
|
+
/** Arbitrary policy metadata for downstream consumers */
|
|
32
|
+
policyMetadata?: Record<string, unknown>;
|
|
33
|
+
/** Document loaded by policy middleware for ownership checks */
|
|
34
|
+
document?: unknown;
|
|
35
|
+
/** Ownership check context (field name + user field) */
|
|
36
|
+
_ownershipCheck?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
type AnyRecord = Record<string, unknown>;
|
|
40
|
+
/** MongoDB ObjectId — accepts string or any object with a `toString()` (e.g. mongoose ObjectId). */
|
|
41
|
+
type ObjectId = string | {
|
|
42
|
+
toString(): string;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Flexible user type that accepts any object with id/_id properties.
|
|
46
|
+
* The actual user structure is defined by your app's auth system.
|
|
47
|
+
*/
|
|
48
|
+
type UserLike = UserBase & {
|
|
49
|
+
/** User email (optional) */email?: string;
|
|
50
|
+
};
|
|
51
|
+
/** Extract user ID from a user object (supports both id and _id). */
|
|
52
|
+
declare function getUserId(user: UserLike | null | undefined): string | undefined;
|
|
53
|
+
interface UserOrganization {
|
|
54
|
+
userId: string;
|
|
55
|
+
organizationId: string;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
}
|
|
58
|
+
interface JWTPayload {
|
|
59
|
+
sub: string;
|
|
60
|
+
[key: string]: unknown;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Standard API response envelope — `{ success, data?, error?, message?, meta? }`.
|
|
64
|
+
* Used by Arc's default response shape.
|
|
65
|
+
*/
|
|
66
|
+
interface ApiResponse<T = unknown> {
|
|
67
|
+
success: boolean;
|
|
68
|
+
data?: T;
|
|
69
|
+
error?: string;
|
|
70
|
+
message?: string;
|
|
71
|
+
meta?: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Typed Fastify request with Arc decorations. Use in `raw: true` handlers
|
|
75
|
+
* instead of `(req as any).user`.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```typescript
|
|
79
|
+
* import type { ArcRequest } from '@classytic/arc';
|
|
80
|
+
*
|
|
81
|
+
* handler: async (req: ArcRequest, reply) => {
|
|
82
|
+
* req.user?.id; // typed
|
|
83
|
+
* req.scope.organizationId; // typed (when member)
|
|
84
|
+
* req.signal; // AbortSignal (Fastify 5)
|
|
85
|
+
* }
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
type ArcRequest = FastifyRequest & {
|
|
89
|
+
scope: RequestScope;
|
|
90
|
+
user: Record<string, unknown> | undefined;
|
|
91
|
+
signal: AbortSignal;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Wrap data in Arc's standard `{ success: true, data }` envelope.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```typescript
|
|
98
|
+
* handler: async (req, reply) => {
|
|
99
|
+
* const data = await getResults();
|
|
100
|
+
* return envelope(data); // → { success: true, data }
|
|
101
|
+
* }
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
declare function envelope<T>(data: T, meta?: Record<string, unknown>): {
|
|
105
|
+
success: true;
|
|
106
|
+
data: T;
|
|
107
|
+
[key: string]: unknown;
|
|
108
|
+
};
|
|
109
|
+
//#endregion
|
|
6
110
|
//#region src/hooks/HookSystem.d.ts
|
|
7
111
|
type HookPhase = "before" | "around" | "after";
|
|
8
112
|
type HookOperation = "create" | "update" | "delete" | "restore" | "read" | "list";
|
|
@@ -254,6 +358,183 @@ declare function beforeDelete<T = AnyRecord>(hooks: HookSystem, resource: string
|
|
|
254
358
|
*/
|
|
255
359
|
declare function afterDelete<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
256
360
|
//#endregion
|
|
361
|
+
//#region src/types/query.d.ts
|
|
362
|
+
/**
|
|
363
|
+
* Request-shaped context object passed to controller methods. Apps and
|
|
364
|
+
* adapters extend it freely via the index signature.
|
|
365
|
+
*/
|
|
366
|
+
interface RequestContext {
|
|
367
|
+
operation?: string;
|
|
368
|
+
user?: unknown;
|
|
369
|
+
filters?: Record<string, unknown>;
|
|
370
|
+
[key: string]: unknown;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Internal metadata shape injected by Arc's Fastify adapter. Extends
|
|
374
|
+
* RequestContext with known internal fields so controllers can access
|
|
375
|
+
* them without `as AnyRecord` casts.
|
|
376
|
+
*/
|
|
377
|
+
interface ArcInternalMetadata extends RequestContext {
|
|
378
|
+
/** Policy filters from permission middleware */
|
|
379
|
+
_policyFilters?: Record<string, unknown>;
|
|
380
|
+
/** Request scope from scope resolution */
|
|
381
|
+
_scope?: RequestScope;
|
|
382
|
+
/** Ownership check config from ownedByUser preset */
|
|
383
|
+
_ownershipCheck?: {
|
|
384
|
+
field: string;
|
|
385
|
+
userId: string;
|
|
386
|
+
};
|
|
387
|
+
/** Arc instance references (hooks, field permissions, etc.) */
|
|
388
|
+
arc?: {
|
|
389
|
+
hooks?: HookSystem;
|
|
390
|
+
fields?: FieldPermissionMap;
|
|
391
|
+
[key: string]: unknown;
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Controller-level query options — parsed from request query string.
|
|
396
|
+
* Includes pagination, filtering, populate/lookup, and context data.
|
|
397
|
+
*/
|
|
398
|
+
interface ControllerQueryOptions {
|
|
399
|
+
page?: number;
|
|
400
|
+
limit?: number;
|
|
401
|
+
sort?: string | Record<string, 1 | -1>;
|
|
402
|
+
/** Simple populate (comma-separated string or array) */
|
|
403
|
+
populate?: string | string[] | Record<string, unknown>;
|
|
404
|
+
/**
|
|
405
|
+
* Advanced populate options (Mongoose-compatible). When set, takes
|
|
406
|
+
* precedence over simple `populate`.
|
|
407
|
+
*/
|
|
408
|
+
populateOptions?: PopulateOption[];
|
|
409
|
+
/**
|
|
410
|
+
* Lookup/join options (database-agnostic). MongoKit maps these to
|
|
411
|
+
* `$lookup`; future SQL adapters would map to JOINs.
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* URL: ?lookup[category][from]=categories&lookup[category][localField]=categorySlug&lookup[category][foreignField]=slug
|
|
415
|
+
*/
|
|
416
|
+
lookups?: LookupOption[];
|
|
417
|
+
select?: string | string[] | Record<string, 0 | 1>;
|
|
418
|
+
filters?: Record<string, unknown>;
|
|
419
|
+
search?: string;
|
|
420
|
+
lean?: boolean;
|
|
421
|
+
after?: string;
|
|
422
|
+
user?: unknown;
|
|
423
|
+
context?: Record<string, unknown>;
|
|
424
|
+
[key: string]: unknown;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Database-agnostic lookup/join option. Parsed from URL:
|
|
428
|
+
* `?lookup[alias][from]=...&lookup[alias][localField]=...&lookup[alias][foreignField]=...`
|
|
429
|
+
*/
|
|
430
|
+
interface LookupOption {
|
|
431
|
+
/** Source collection/table to join from */
|
|
432
|
+
from: string;
|
|
433
|
+
/** Local field to match on */
|
|
434
|
+
localField: string;
|
|
435
|
+
/** Foreign field to match on */
|
|
436
|
+
foreignField: string;
|
|
437
|
+
/** Alias for the joined data (defaults to the lookup key) */
|
|
438
|
+
as?: string;
|
|
439
|
+
/** Return a single object instead of array (default: false) */
|
|
440
|
+
single?: boolean;
|
|
441
|
+
/** Field selection on the joined collection */
|
|
442
|
+
select?: string | Record<string, 0 | 1>;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Mongoose-compatible populate option for advanced field selection.
|
|
446
|
+
*
|
|
447
|
+
* @example
|
|
448
|
+
* ```typescript
|
|
449
|
+
* // URL: ?populate[author][select]=name,email
|
|
450
|
+
* // Generates: { path: 'author', select: 'name email' }
|
|
451
|
+
* ```
|
|
452
|
+
*/
|
|
453
|
+
interface PopulateOption {
|
|
454
|
+
/** Field path to populate */
|
|
455
|
+
path: string;
|
|
456
|
+
/** Fields to select (space-separated) */
|
|
457
|
+
select?: string;
|
|
458
|
+
/** Filter conditions for populated documents */
|
|
459
|
+
match?: Record<string, unknown>;
|
|
460
|
+
/** Query options (limit, sort, skip) */
|
|
461
|
+
options?: {
|
|
462
|
+
limit?: number;
|
|
463
|
+
sort?: Record<string, 1 | -1>;
|
|
464
|
+
skip?: number;
|
|
465
|
+
};
|
|
466
|
+
/** Nested populate configuration */
|
|
467
|
+
populate?: PopulateOption;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Parsed query result from QueryParser. The index signature lets custom
|
|
471
|
+
* parsers (MongoKit, PrismaKit) add fields without breaking Arc's types.
|
|
472
|
+
*/
|
|
473
|
+
interface ParsedQuery {
|
|
474
|
+
filters?: Record<string, unknown>;
|
|
475
|
+
limit?: number;
|
|
476
|
+
sort?: string | Record<string, 1 | -1>;
|
|
477
|
+
populate?: string | string[] | Record<string, unknown>;
|
|
478
|
+
populateOptions?: PopulateOption[];
|
|
479
|
+
lookups?: LookupOption[];
|
|
480
|
+
search?: string;
|
|
481
|
+
page?: number;
|
|
482
|
+
after?: string;
|
|
483
|
+
select?: string | string[] | Record<string, 0 | 1>;
|
|
484
|
+
[key: string]: unknown;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Query Parser interface. Implement to create custom query parsers.
|
|
488
|
+
*
|
|
489
|
+
* @example MongoKit
|
|
490
|
+
* ```typescript
|
|
491
|
+
* import { QueryParser } from '@classytic/mongokit';
|
|
492
|
+
* const queryParser = new QueryParser();
|
|
493
|
+
* ```
|
|
494
|
+
*/
|
|
495
|
+
interface QueryParserInterface {
|
|
496
|
+
parse(query: Record<string, unknown> | null | undefined): ParsedQuery;
|
|
497
|
+
/** Optional: Export OpenAPI schema for query parameters. */
|
|
498
|
+
getQuerySchema?(): {
|
|
499
|
+
type: "object";
|
|
500
|
+
properties: Record<string, unknown>;
|
|
501
|
+
required?: string[];
|
|
502
|
+
};
|
|
503
|
+
/**
|
|
504
|
+
* Optional: Allowed filter fields whitelist. MCP auto-derives
|
|
505
|
+
* `filterableFields` from this if `schemaOptions.filterableFields`
|
|
506
|
+
* is not explicitly configured.
|
|
507
|
+
*/
|
|
508
|
+
allowedFilterFields?: readonly string[];
|
|
509
|
+
/**
|
|
510
|
+
* Optional: Allowed filter operators whitelist. Used by MCP to enrich
|
|
511
|
+
* list-tool descriptions. Values are human-readable keys: 'eq', 'ne',
|
|
512
|
+
* 'gt', 'gte', 'lt', 'lte', 'in', 'nin', etc.
|
|
513
|
+
*/
|
|
514
|
+
allowedOperators?: readonly string[];
|
|
515
|
+
/**
|
|
516
|
+
* Optional: Allowed sort fields whitelist. Used by MCP to describe
|
|
517
|
+
* available sort options in list-tool descriptions.
|
|
518
|
+
*/
|
|
519
|
+
allowedSortFields?: readonly string[];
|
|
520
|
+
}
|
|
521
|
+
/** Ownership-check config used by `ownedByUser` preset / middleware. */
|
|
522
|
+
interface OwnershipCheck {
|
|
523
|
+
field: string;
|
|
524
|
+
userField?: string;
|
|
525
|
+
}
|
|
526
|
+
/** Service-layer context — passed to repository / service calls. */
|
|
527
|
+
interface ServiceContext {
|
|
528
|
+
user?: unknown;
|
|
529
|
+
requestId?: string;
|
|
530
|
+
/** Field projection for responses */
|
|
531
|
+
select?: string[] | Record<string, 0 | 1>;
|
|
532
|
+
/** Relations to populate */
|
|
533
|
+
populate?: string | string[];
|
|
534
|
+
/** Return plain objects */
|
|
535
|
+
lean?: boolean;
|
|
536
|
+
}
|
|
537
|
+
//#endregion
|
|
257
538
|
//#region src/pipeline/types.d.ts
|
|
258
539
|
/**
|
|
259
540
|
* Pipeline context passed to guards, transforms, and interceptors.
|
|
@@ -316,1701 +597,735 @@ type PipelineConfig = PipelineStep[] | {
|
|
|
316
597
|
[operation: string]: PipelineStep[] | undefined;
|
|
317
598
|
};
|
|
318
599
|
//#endregion
|
|
319
|
-
//#region src/
|
|
600
|
+
//#region src/adapters/interface.d.ts
|
|
320
601
|
/**
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* including the reference implementation at `@classytic/mongokit` and any
|
|
326
|
-
* future `prismakit` / `pgkit` / `sqlitekit` that mirrors it.
|
|
327
|
-
*
|
|
328
|
-
* ## Design
|
|
329
|
-
*
|
|
330
|
-
* The interface is tiered so a minimal adapter can ship with five methods
|
|
331
|
-
* while a mature one (mongokit 3.6+) can opt into the full surface without
|
|
332
|
-
* type assertions:
|
|
333
|
-
*
|
|
334
|
-
* 1. **Required** — `getAll`, `getById`, `create`, `update`, `delete`.
|
|
335
|
-
* Every resource needs these; arc's BaseController assumes they exist.
|
|
336
|
-
*
|
|
337
|
-
* 2. **Recommended** — `getOne` / `getByQuery`. Used by AccessControl to
|
|
338
|
-
* enforce compound filters (idField + org scope + policy). Without them,
|
|
339
|
-
* arc falls back to `getById` + post-fetch checks, which is slower and
|
|
340
|
-
* produces wrong 404s on custom idFields.
|
|
341
|
-
*
|
|
342
|
-
* 3. **Optional capabilities** — batch ops, soft delete, aggregation,
|
|
343
|
-
* transactions, etc. Declared as optional so kits implement only what
|
|
344
|
-
* their underlying DB supports. arc feature-detects at runtime.
|
|
345
|
-
*
|
|
346
|
-
* All options/results are named types so custom kits can import and
|
|
347
|
-
* implement them directly:
|
|
348
|
-
*
|
|
349
|
-
* ```ts
|
|
350
|
-
* import type {
|
|
351
|
-
* CrudRepository,
|
|
352
|
-
* DeleteOptions,
|
|
353
|
-
* DeleteResult,
|
|
354
|
-
* PaginationResult,
|
|
355
|
-
* UpdateManyResult,
|
|
356
|
-
* BulkWriteOperation,
|
|
357
|
-
* BulkWriteResult,
|
|
358
|
-
* } from '@classytic/arc';
|
|
359
|
-
*
|
|
360
|
-
* class PgRepository<TDoc> implements CrudRepository<TDoc> { … }
|
|
361
|
-
* ```
|
|
602
|
+
* Arc's structural repository contract: the repo-core minimum plus any
|
|
603
|
+
* standard-repo methods a given kit implements. All optional methods are
|
|
604
|
+
* feature-detected at call sites — arc never assumes capabilities it
|
|
605
|
+
* hasn't probed.
|
|
362
606
|
*
|
|
363
|
-
* @example Reference implementation
|
|
364
607
|
* ```ts
|
|
365
|
-
*
|
|
366
|
-
*
|
|
608
|
+
* const adapter: DataAdapter<Product> = {
|
|
609
|
+
* repository: myRepo, // any MinimalRepo<Product> — kit-agnostic
|
|
610
|
+
* type: 'drizzle', // or 'mongoose' | 'prisma' | 'custom'
|
|
611
|
+
* name: 'products',
|
|
612
|
+
* };
|
|
613
|
+
* defineResource({ adapter, ... });
|
|
367
614
|
* ```
|
|
368
|
-
*
|
|
369
|
-
* ## Contract gotchas (learned from mongokit 3.6 integration)
|
|
370
|
-
*
|
|
371
|
-
* If you build a custom kit that implements this contract, these are the
|
|
372
|
-
* behaviors arc's tests specifically verify. Align your kit here and
|
|
373
|
-
* arc's `BaseController` + presets will work out of the box:
|
|
374
|
-
*
|
|
375
|
-
* 1. **`getById` / `getOne` miss semantics** — MAY return `null` or throw a
|
|
376
|
-
* 404-style error whose message contains "not found". Arc handles both.
|
|
377
|
-
* Pick one and document it in your kit.
|
|
378
|
-
*
|
|
379
|
-
* 2. **`deleteMany` with soft-delete** — if your kit intercepts
|
|
380
|
-
* `deleteMany` and rewrites it to `updateMany`, the returned
|
|
381
|
-
* `deletedCount` may be `0` even when N docs were soft-deleted. The
|
|
382
|
-
* authoritative count comes from a follow-up query. Consumers shouldn't
|
|
383
|
-
* rely on `deletedCount` reflecting soft-delete work unless your kit
|
|
384
|
-
* promises it.
|
|
385
|
-
*
|
|
386
|
-
* 3. **Lifecycle hooks are shared with plugins** — never use
|
|
387
|
-
* `removeAllListeners(event)` to clean up test hooks. That silently
|
|
388
|
-
* removes soft-delete, cascade, multi-tenant, and audit plugin
|
|
389
|
-
* listeners too, which then makes subsequent operations misbehave
|
|
390
|
-
* (e.g. a soft-delete becomes a hard delete). Always use
|
|
391
|
-
* `.off(event, fn)` with the specific handler reference you registered.
|
|
392
|
-
*
|
|
393
|
-
* 4. **Hard-delete mode** — `delete(id, { mode: 'hard' })` and
|
|
394
|
-
* `deleteMany(q, { mode: 'hard' })` MUST bypass soft-delete
|
|
395
|
-
* interception while still running policy / multi-tenant / cascade /
|
|
396
|
-
* audit hooks. Kits without soft-delete should accept and ignore the
|
|
397
|
-
* flag.
|
|
398
|
-
*
|
|
399
|
-
* 5. **Keyset pagination auto-detection** — `getAll({ sort, limit })`
|
|
400
|
-
* without `page` SHOULD return a `KeysetPaginatedResult` with
|
|
401
|
-
* `method: "keyset"`. Kits that only offer offset pagination can return
|
|
402
|
-
* the legacy offset shape; arc's types still satisfy.
|
|
403
|
-
*
|
|
404
|
-
* 6. **`idField` identity** — kits that key on anything other than `"_id"`
|
|
405
|
-
* MUST set `readonly idField` on the repository so arc's BaseController
|
|
406
|
-
* passes route params straight through to `update`/`delete`/`restore`
|
|
407
|
-
* without translating them.
|
|
408
|
-
*
|
|
409
|
-
* 7. **`before:restore` / `after:restore` hooks** — if you implement
|
|
410
|
-
* `restore`, fire these hooks symmetrically with `before:delete` /
|
|
411
|
-
* `after:delete` so hosts can wire cascade-restore flows.
|
|
412
|
-
*
|
|
413
|
-
* See `tests/core/repository-contract-mongokit.test.ts` for a runnable
|
|
414
|
-
* reference against mongokit 3.6. Copy it, swap in your kit's repository,
|
|
415
|
-
* and make it pass — if everything's green, arc will work against your
|
|
416
|
-
* kit.
|
|
417
|
-
*/
|
|
418
|
-
/**
|
|
419
|
-
* Opaque transaction session. Adapters bind this to their own type
|
|
420
|
-
* (Mongoose `ClientSession`, Prisma transaction client, `pg.Client`, …).
|
|
421
615
|
*/
|
|
422
|
-
type
|
|
423
|
-
|
|
424
|
-
* Query options for read operations. Extended ad-hoc by adapters via the
|
|
425
|
-
* index signature — kit authors should namespace custom flags (e.g.
|
|
426
|
-
* `__pgHint`) to avoid collisions.
|
|
427
|
-
*/
|
|
428
|
-
interface QueryOptions {
|
|
429
|
-
/** Transaction session — adapter-specific concrete type */
|
|
430
|
-
session?: RepositorySession;
|
|
431
|
-
/** Return plain objects instead of driver documents */
|
|
432
|
-
lean?: boolean;
|
|
433
|
-
/** Include soft-deleted docs in reads (honored by soft-delete plugin) */
|
|
434
|
-
includeDeleted?: boolean;
|
|
435
|
-
/** Forwarded to policy/tenant hooks */
|
|
436
|
-
user?: Record<string, unknown>;
|
|
437
|
-
/** Arc request-scoped metadata (orgId, roles, requestId, …) */
|
|
438
|
-
context?: Record<string, unknown>;
|
|
616
|
+
type RepositoryLike<TDoc = unknown> = MinimalRepo<TDoc> & Partial<StandardRepo<TDoc>>;
|
|
617
|
+
interface DataAdapter<TDoc = unknown> {
|
|
439
618
|
/**
|
|
440
|
-
*
|
|
441
|
-
* `
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
* `PopulateOptions[]`, prisma uses `{ include: {...} }`, pgkit uses SQL
|
|
445
|
-
* JOIN hints, etc. Typing them as (say) `string | Record<string, unknown>`
|
|
446
|
-
* would REJECT the narrower shapes real kits actually expose, breaking
|
|
447
|
-
* structural assignability of `Repository<T> → CrudRepository<T>`.
|
|
619
|
+
* Repository implementing CRUD operations. Accepts the typed
|
|
620
|
+
* `StandardRepo<TDoc>` (repo-core's standard contract) or the structural
|
|
621
|
+
* `RepositoryLike` (minimum + optionals). Arc feature-detects optional
|
|
622
|
+
* methods at runtime — kits only declare what they support.
|
|
448
623
|
*/
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
*/
|
|
467
|
-
|
|
624
|
+
repository: StandardRepo<TDoc> | RepositoryLike<TDoc>;
|
|
625
|
+
/** Adapter identifier for introspection */
|
|
626
|
+
readonly type: "mongoose" | "prisma" | "drizzle" | "typeorm" | "custom";
|
|
627
|
+
/** Human-readable name */
|
|
628
|
+
readonly name: string;
|
|
629
|
+
/**
|
|
630
|
+
* Generate OpenAPI schemas for CRUD operations. Each adapter produces
|
|
631
|
+
* schemas appropriate to its ORM/database (mongoose kits use mongokit's
|
|
632
|
+
* `buildCrudSchemasFromModel`; SQL kits introspect columns).
|
|
633
|
+
*
|
|
634
|
+
* @param options - Schema generation options (field rules, populate hints)
|
|
635
|
+
* @param context - Resource-level context (idField for params schema, name for logs).
|
|
636
|
+
* Adapters should honor `context.idField` when producing the params
|
|
637
|
+
* schema (e.g., skip the ObjectId pattern when idField is a custom
|
|
638
|
+
* string field).
|
|
639
|
+
*/
|
|
640
|
+
generateSchemas?(options?: RouteSchemaOptions, context?: AdapterSchemaContext): OpenApiSchemas | Record<string, unknown> | null;
|
|
641
|
+
/** Extract schema metadata for OpenAPI/introspection. */
|
|
642
|
+
getSchemaMetadata?(): SchemaMetadata | null;
|
|
643
|
+
/** Validate data against schema before persistence. */
|
|
644
|
+
validate?(data: unknown): Promise<ValidationResult$1> | ValidationResult$1;
|
|
645
|
+
/** Health check for database connection. */
|
|
646
|
+
healthCheck?(): Promise<boolean>;
|
|
468
647
|
/**
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
648
|
+
* Custom filter matching for in-memory policy enforcement. Falls back
|
|
649
|
+
* to arc's built-in shallow matcher when omitted. Override for SQL
|
|
650
|
+
* adapters, non-Mongo operators, or kits that compile Filter IR.
|
|
472
651
|
*/
|
|
473
|
-
|
|
652
|
+
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
653
|
+
/** Close / cleanup resources. */
|
|
654
|
+
close?(): Promise<void>;
|
|
474
655
|
}
|
|
475
656
|
/**
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
* `soft` and `count`. Arc's BaseController uses the `success` flag to decide
|
|
480
|
-
* whether to return 200 or 404.
|
|
657
|
+
* Context passed to `adapter.generateSchemas()` so adapters shape output
|
|
658
|
+
* to match resource-level configuration. All fields optional — adapters
|
|
659
|
+
* that ignore this still work; arc applies safety-net normalization.
|
|
481
660
|
*/
|
|
482
|
-
interface
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
661
|
+
interface AdapterSchemaContext {
|
|
662
|
+
/** The idField configured on the resource. Defaults to "_id". */
|
|
663
|
+
idField?: string;
|
|
664
|
+
/** Resource name (for error messages / logging). */
|
|
665
|
+
resourceName?: string;
|
|
666
|
+
}
|
|
667
|
+
interface SchemaMetadata {
|
|
668
|
+
name: string;
|
|
669
|
+
fields: Record<string, FieldMetadata>;
|
|
670
|
+
indexes?: Array<{
|
|
671
|
+
fields: string[];
|
|
672
|
+
unique?: boolean;
|
|
673
|
+
sparse?: boolean;
|
|
674
|
+
}>;
|
|
675
|
+
relations?: Record<string, RelationMetadata>;
|
|
676
|
+
}
|
|
677
|
+
interface FieldMetadata {
|
|
678
|
+
type: "string" | "number" | "boolean" | "date" | "object" | "array" | "objectId" | "enum";
|
|
679
|
+
required?: boolean;
|
|
680
|
+
unique?: boolean;
|
|
681
|
+
default?: unknown;
|
|
682
|
+
enum?: Array<string | number>;
|
|
683
|
+
min?: number;
|
|
684
|
+
max?: number;
|
|
685
|
+
minLength?: number;
|
|
686
|
+
maxLength?: number;
|
|
687
|
+
pattern?: string;
|
|
688
|
+
description?: string;
|
|
689
|
+
ref?: string;
|
|
690
|
+
array?: boolean;
|
|
691
|
+
}
|
|
692
|
+
interface RelationMetadata {
|
|
693
|
+
type: "one-to-one" | "one-to-many" | "many-to-many";
|
|
694
|
+
target: string;
|
|
695
|
+
foreignKey?: string;
|
|
696
|
+
through?: string;
|
|
697
|
+
}
|
|
698
|
+
interface ValidationResult$1 {
|
|
699
|
+
valid: boolean;
|
|
700
|
+
errors?: Array<{
|
|
701
|
+
field: string;
|
|
702
|
+
message: string;
|
|
703
|
+
code?: string;
|
|
704
|
+
}>;
|
|
491
705
|
}
|
|
706
|
+
type AdapterFactory<TDoc> = (config: unknown) => DataAdapter<TDoc>;
|
|
707
|
+
//#endregion
|
|
708
|
+
//#region src/types/handlers.d.ts
|
|
492
709
|
/**
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
* **Soft-delete gotcha** — when a soft-delete plugin intercepts
|
|
497
|
-
* `deleteMany` by rewriting it to `updateMany` internally (mongokit 3.6
|
|
498
|
-
* does this in `before:deleteMany`), the `deletedCount` returned here may
|
|
499
|
-
* be `0` because the underlying `Model.deleteMany` was never called. The
|
|
500
|
-
* affected-row count lives inside the hook's `updateMany` result and is
|
|
501
|
-
* not surfaced to the caller. Consumers that need the exact soft-deleted
|
|
502
|
-
* count should run a follow-up query (`repo.count({ deletedAt: { $ne:
|
|
503
|
-
* null }, ...filter })`). 3rd-party kits with soft-delete should document
|
|
504
|
-
* which convention they follow.
|
|
710
|
+
* Minimal server accessor — exposes safe, read-only server decorators.
|
|
711
|
+
* Allows controller handlers to publish events, log, and audit
|
|
712
|
+
* without switching to `raw: true`.
|
|
505
713
|
*/
|
|
506
|
-
interface
|
|
507
|
-
/**
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
* Number of documents removed. May be 0 when soft-delete intercepts;
|
|
511
|
-
* see the "Soft-delete gotcha" note above.
|
|
512
|
-
*/
|
|
513
|
-
deletedCount: number;
|
|
514
|
-
/** True when a soft-delete plugin intercepted and did `updateMany` instead */
|
|
515
|
-
soft?: boolean;
|
|
516
|
-
}
|
|
517
|
-
/** Result of a bulk update operation. Matches MongoDB driver shape. */
|
|
518
|
-
interface UpdateManyResult {
|
|
519
|
-
acknowledged?: boolean;
|
|
520
|
-
matchedCount: number;
|
|
521
|
-
modifiedCount: number;
|
|
522
|
-
upsertedCount?: number;
|
|
523
|
-
upsertedId?: unknown;
|
|
524
|
-
}
|
|
525
|
-
/** Shape of a single operation passed to `bulkWrite`. */
|
|
526
|
-
type BulkWriteOperation<TDoc = unknown> = {
|
|
527
|
-
insertOne: {
|
|
528
|
-
document: Partial<TDoc>;
|
|
529
|
-
};
|
|
530
|
-
} | {
|
|
531
|
-
updateOne: {
|
|
532
|
-
filter: Record<string, unknown>;
|
|
533
|
-
update: Record<string, unknown>;
|
|
534
|
-
upsert?: boolean;
|
|
535
|
-
};
|
|
536
|
-
} | {
|
|
537
|
-
updateMany: {
|
|
538
|
-
filter: Record<string, unknown>;
|
|
539
|
-
update: Record<string, unknown>;
|
|
540
|
-
upsert?: boolean;
|
|
714
|
+
interface ServerAccessor {
|
|
715
|
+
/** Event bus — publish domain events from any handler */
|
|
716
|
+
events?: {
|
|
717
|
+
publish: <T>(type: string, payload: T, meta?: Partial<Record<string, unknown>>) => Promise<void>;
|
|
541
718
|
};
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
719
|
+
/** Audit logger — log custom audit entries */
|
|
720
|
+
audit?: {
|
|
721
|
+
create: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
722
|
+
update: (resource: string, documentId: string, before: Record<string, unknown>, after: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
723
|
+
delete: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
724
|
+
custom: (resource: string, documentId: string, action: string, data?: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
545
725
|
};
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
726
|
+
/** Logger — structured logging */
|
|
727
|
+
log?: {
|
|
728
|
+
info: (...args: unknown[]) => void;
|
|
729
|
+
warn: (...args: unknown[]) => void;
|
|
730
|
+
error: (...args: unknown[]) => void;
|
|
731
|
+
debug: (...args: unknown[]) => void;
|
|
549
732
|
};
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
733
|
+
/** QueryCache — stale-while-revalidate data cache */
|
|
734
|
+
queryCache?: {
|
|
735
|
+
get: <T>(key: string) => Promise<{
|
|
736
|
+
data: T;
|
|
737
|
+
status: 'fresh' | 'stale' | 'miss';
|
|
738
|
+
}>;
|
|
739
|
+
set: <T>(key: string, data: T, config: {
|
|
740
|
+
staleTime?: number;
|
|
741
|
+
gcTime?: number;
|
|
742
|
+
tags?: string[];
|
|
743
|
+
}) => Promise<void>;
|
|
744
|
+
getResourceVersion: (resource: string) => Promise<number>;
|
|
745
|
+
bumpResourceVersion: (resource: string) => Promise<void>;
|
|
555
746
|
};
|
|
556
|
-
};
|
|
557
|
-
/** Result of a heterogeneous bulk write. */
|
|
558
|
-
interface BulkWriteResult {
|
|
559
|
-
ok?: number;
|
|
560
|
-
insertedCount?: number;
|
|
561
|
-
matchedCount?: number;
|
|
562
|
-
modifiedCount?: number;
|
|
563
|
-
deletedCount?: number;
|
|
564
|
-
upsertedCount?: number;
|
|
565
|
-
insertedIds?: Record<number, unknown>;
|
|
566
|
-
upsertedIds?: Record<number, unknown>;
|
|
567
|
-
}
|
|
568
|
-
/**
|
|
569
|
-
* Pagination parameters for list operations.
|
|
570
|
-
*
|
|
571
|
-
* Supports three modes, auto-detected by the adapter:
|
|
572
|
-
* - **Offset** — pass `page` + `limit`.
|
|
573
|
-
* - **Keyset** — pass `sort` + `limit` (+ optional `after` cursor). Required
|
|
574
|
-
* for infinite scroll on large collections; O(1) per page.
|
|
575
|
-
* - **Raw** — pass neither; adapter returns all matching docs.
|
|
576
|
-
*/
|
|
577
|
-
interface PaginationParams<TDoc = unknown> {
|
|
578
|
-
/** Filter criteria */
|
|
579
|
-
filters?: Partial<TDoc> & Record<string, unknown>;
|
|
580
|
-
/** Sort spec — string (`"-createdAt"`) or object (`{ createdAt: -1 }`) */
|
|
581
|
-
sort?: string | Record<string, 1 | -1>;
|
|
582
|
-
/** Page number (1-indexed) — triggers offset pagination */
|
|
583
|
-
page?: number;
|
|
584
|
-
/** Items per page */
|
|
585
|
-
limit?: number;
|
|
586
|
-
/** Opaque cursor from a prior `next` field — triggers keyset pagination */
|
|
587
|
-
after?: string;
|
|
588
|
-
/** Allow additional options (select, populate, search, …) */
|
|
589
|
-
[key: string]: unknown;
|
|
590
747
|
}
|
|
591
748
|
/**
|
|
592
|
-
*
|
|
749
|
+
* Request context passed to controller handlers.
|
|
593
750
|
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
limit: number;
|
|
603
|
-
total: number;
|
|
604
|
-
pages: number;
|
|
605
|
-
hasNext: boolean;
|
|
606
|
-
hasPrev: boolean;
|
|
607
|
-
}
|
|
608
|
-
/**
|
|
609
|
-
* Keyset-based paginated result (returned when `sort` is provided without
|
|
610
|
-
* `page`). Ideal for infinite scroll — no `count()` query, O(1) per page.
|
|
611
|
-
*/
|
|
612
|
-
interface KeysetPaginatedResult<TDoc> {
|
|
613
|
-
/** Discriminator — always `"keyset"` */
|
|
614
|
-
method: "keyset";
|
|
615
|
-
docs: TDoc[];
|
|
616
|
-
limit: number;
|
|
617
|
-
hasMore: boolean;
|
|
618
|
-
/** Opaque cursor token for the next page, or `null` at the end */
|
|
619
|
-
next: string | null;
|
|
620
|
-
}
|
|
621
|
-
/**
|
|
622
|
-
* Discriminated union of all pagination result shapes.
|
|
623
|
-
* Consumers narrow on the `method` discriminator.
|
|
751
|
+
* **Generic parameters** (all default to safe permissive types so existing code keeps working):
|
|
752
|
+
* - `TBody` — request body shape (default: `unknown`)
|
|
753
|
+
* - `TParams` — route param shape (default: `Record<string, string>`)
|
|
754
|
+
* - `TQuery` — query string shape (default: `Record<string, unknown>`)
|
|
755
|
+
* - `TUser` — authenticated user shape (default: `UserBase`)
|
|
756
|
+
* - `TMetadata` — internal metadata shape (default: `Record<string, unknown>`;
|
|
757
|
+
* override with `ArcInternalMetadata` or your own augmentation when you
|
|
758
|
+
* need typed access to `_scope`, `_policyFilters`, custom hook context, etc.)
|
|
624
759
|
*
|
|
625
760
|
* @example
|
|
626
|
-
* ```
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
* // result.page, result.total, result.pages
|
|
761
|
+
* ```typescript
|
|
762
|
+
* // Untyped (default) — req.body is `unknown`, must be narrowed
|
|
763
|
+
* async create(req: IRequestContext) {
|
|
764
|
+
* const data = req.body as Partial<Product>;
|
|
765
|
+
* return { success: true, data: await productRepo.create(data) };
|
|
632
766
|
* }
|
|
633
|
-
* ```
|
|
634
|
-
*/
|
|
635
|
-
type PaginationResult<TDoc> = OffsetPaginatedResult<TDoc> | KeysetPaginatedResult<TDoc>;
|
|
636
|
-
/**
|
|
637
|
-
* Legacy alias. Existing code typed as `PaginatedResult<TDoc>` continues
|
|
638
|
-
* to work unchanged — it resolves to the offset shape, which is the most
|
|
639
|
-
* common. New code should prefer `PaginationResult<TDoc>` for the full
|
|
640
|
-
* discriminated union.
|
|
641
|
-
*/
|
|
642
|
-
type PaginatedResult<TDoc> = OffsetPaginatedResult<TDoc>;
|
|
643
|
-
/**
|
|
644
|
-
* Standard CRUD Repository Interface
|
|
645
|
-
*
|
|
646
|
-
* The canonical contract arc consumes. Tiered so minimal adapters only
|
|
647
|
-
* implement the required five methods; richer kits declare the optional
|
|
648
|
-
* capabilities they support.
|
|
649
767
|
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
768
|
+
* // Typed body — req.body is `CreateProductInput`, narrowing not needed
|
|
769
|
+
* async create(req: IRequestContext<CreateProductInput>) {
|
|
770
|
+
* return { success: true, data: await productRepo.create(req.body) };
|
|
771
|
+
* }
|
|
652
772
|
*
|
|
653
|
-
*
|
|
773
|
+
* // Fully typed — body, route params, query, and metadata
|
|
774
|
+
* async update(
|
|
775
|
+
* req: IRequestContext<
|
|
776
|
+
* Partial<Product>,
|
|
777
|
+
* { id: string },
|
|
778
|
+
* { fields?: string },
|
|
779
|
+
* ArcInternalMetadata
|
|
780
|
+
* >,
|
|
781
|
+
* ) {
|
|
782
|
+
* const fields = req.query.fields?.split(',');
|
|
783
|
+
* const orgId = req.metadata?._scope ? getOrgId(req.metadata._scope) : undefined;
|
|
784
|
+
* return { success: true, data: await productRepo.update(req.params.id, req.body) };
|
|
785
|
+
* }
|
|
786
|
+
* ```
|
|
654
787
|
*/
|
|
655
|
-
interface
|
|
788
|
+
interface IRequestContext<TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>, TUser extends UserBase = UserBase, TMetadata extends Record<string, unknown> = Record<string, unknown>> {
|
|
789
|
+
/** Route parameters (e.g., { id: '123' }) */
|
|
790
|
+
params: TParams;
|
|
791
|
+
/** Query string parameters */
|
|
792
|
+
query: TQuery;
|
|
793
|
+
/** Request body */
|
|
794
|
+
body: TBody;
|
|
795
|
+
/** Authenticated user or null */
|
|
796
|
+
user: TUser | null;
|
|
797
|
+
/** Request headers */
|
|
798
|
+
headers: Record<string, string | undefined>;
|
|
799
|
+
/** Organization ID (for multi-tenant apps) */
|
|
800
|
+
organizationId?: string;
|
|
801
|
+
/** Team ID (for team-scoped resources) */
|
|
802
|
+
teamId?: string;
|
|
656
803
|
/**
|
|
657
|
-
*
|
|
804
|
+
* Organization/auth context from middleware.
|
|
805
|
+
* Contains orgRoles, orgScope, organizationId, and any custom fields
|
|
806
|
+
* set by the auth adapter or org-scope plugin.
|
|
658
807
|
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
808
|
+
* @example
|
|
809
|
+
* ```typescript
|
|
810
|
+
* async create(req: IRequestContext) {
|
|
811
|
+
* const roles = req.context?.orgRoles ?? [];
|
|
812
|
+
* if (roles.includes('manager')) { ... }
|
|
813
|
+
* }
|
|
814
|
+
* ```
|
|
664
815
|
*/
|
|
665
|
-
|
|
816
|
+
context?: RequestContext;
|
|
666
817
|
/**
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
* Return shapes (all valid under the contract):
|
|
671
|
-
* - `OffsetPaginatedResult<TDoc>` — when `page` is given
|
|
672
|
-
* - `KeysetPaginatedResult<TDoc>` — when `sort` + optional `after` are given
|
|
673
|
-
* - `TDoc[]` — raw array, when neither `page` nor `sort` drives pagination
|
|
674
|
-
*
|
|
675
|
-
* Arc's BaseController narrows the union before returning to clients.
|
|
818
|
+
* Internal metadata (includes context + Arc internals like `_policyFilters`,
|
|
819
|
+
* `_scope`, `log`). Type as `ArcInternalMetadata` for typed access to Arc's
|
|
820
|
+
* built-in fields, or supply your own interface to layer custom fields.
|
|
676
821
|
*/
|
|
677
|
-
|
|
822
|
+
metadata?: TMetadata;
|
|
678
823
|
/**
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
* **Miss semantics — kits may EITHER return `null` OR throw a 404-style
|
|
682
|
-
* error.** Arc's `BaseController` handles both: `AccessControl.fetchWith
|
|
683
|
-
* AccessControl` catches errors whose message contains "not found" and
|
|
684
|
-
* converts them to null. 3rd-party kit authors: pick one convention and
|
|
685
|
-
* document it. mongokit 3.6 throws by default; pass
|
|
686
|
-
* `{ throwOnNotFound: false }` to get null. A SQL kit that returns null
|
|
687
|
-
* directly is equally valid.
|
|
688
|
-
*/
|
|
689
|
-
getById(id: string, options?: QueryOptions): Promise<TDoc | null>;
|
|
690
|
-
/** Insert a single document. */
|
|
691
|
-
create(data: Partial<TDoc>, options?: WriteOptions): Promise<TDoc>;
|
|
692
|
-
/** Update a document by primary key. Returns the updated doc or null. */
|
|
693
|
-
update(id: string, data: Partial<TDoc>, options?: WriteOptions): Promise<TDoc | null>;
|
|
694
|
-
/**
|
|
695
|
-
* Delete a document by primary key. Pass `{ mode: 'hard' }` to bypass
|
|
696
|
-
* soft-delete interception.
|
|
697
|
-
*/
|
|
698
|
-
delete(id: string, options?: DeleteOptions): Promise<DeleteResult>;
|
|
699
|
-
/**
|
|
700
|
-
* Find a single doc by a compound filter. Used by arc's AccessControl to
|
|
701
|
-
* combine `idField + orgId + policy` in one query. Without it, arc falls
|
|
702
|
-
* back to `getById` + post-fetch scope checks (slower; 404s on custom
|
|
703
|
-
* idFields if the doc lives outside the user's scope).
|
|
824
|
+
* Fastify server accessor — publish events, log, and audit
|
|
825
|
+
* from any handler without switching to `raw: true`.
|
|
704
826
|
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
/**
|
|
714
|
-
* Cheap existence check. Kits may return `boolean` or `{ _id }` — arc
|
|
715
|
-
* coerces to boolean at the call site.
|
|
716
|
-
*/
|
|
717
|
-
exists?(filter: Record<string, unknown>, options?: QueryOptions): Promise<boolean | {
|
|
718
|
-
_id: unknown;
|
|
719
|
-
} | null>;
|
|
720
|
-
/** Return the distinct values of a field matching the filter. */
|
|
721
|
-
distinct?<T = unknown>(field: string, filter?: Record<string, unknown>, options?: QueryOptions): Promise<T[]>;
|
|
722
|
-
/** Return all matching docs as a raw array (no pagination metadata). */
|
|
723
|
-
findAll?(filter?: Record<string, unknown>, options?: QueryOptions): Promise<TDoc[]>;
|
|
724
|
-
/**
|
|
725
|
-
* Atomic "find or create" — return the doc matching the filter, or
|
|
726
|
-
* insert `data` and return it if none exists. MAY return `null` when
|
|
727
|
-
* neither path produces a document (e.g. race loss + validation error
|
|
728
|
-
* handling — mongokit returns null in this window).
|
|
729
|
-
*/
|
|
730
|
-
getOrCreate?(filter: Record<string, unknown>, data: Partial<TDoc>, options?: WriteOptions): Promise<TDoc | null>;
|
|
731
|
-
/** Insert multiple documents in one call. */
|
|
732
|
-
createMany?(items: Array<Partial<TDoc>>, options?: WriteOptions): Promise<TDoc[]>;
|
|
733
|
-
/**
|
|
734
|
-
* Update all documents matching `filter`. Should reject empty filters
|
|
735
|
-
* to prevent accidental mass updates (mongokit does this).
|
|
736
|
-
*/
|
|
737
|
-
updateMany?(filter: Record<string, unknown>, data: Record<string, unknown>, options?: WriteOptions): Promise<UpdateManyResult>;
|
|
738
|
-
/**
|
|
739
|
-
* Delete all documents matching `filter`. Soft-deletes when a soft-delete
|
|
740
|
-
* plugin is wired; pass `{ mode: 'hard' }` to force physical removal.
|
|
827
|
+
* @example
|
|
828
|
+
* ```typescript
|
|
829
|
+
* async reschedule(req: IRequestContext) {
|
|
830
|
+
* const result = await repo.reschedule(req.params.id, req.body);
|
|
831
|
+
* await req.server?.events?.publish('interview.rescheduled', { data: result });
|
|
832
|
+
* return { success: true, data: result };
|
|
833
|
+
* }
|
|
834
|
+
* ```
|
|
741
835
|
*/
|
|
742
|
-
|
|
743
|
-
/**
|
|
744
|
-
* Heterogeneous bulk write (insertOne / updateOne / deleteMany / …).
|
|
745
|
-
*
|
|
746
|
-
* Structurally typed as `unknown` because each kit uses its own operation
|
|
747
|
-
* shape — mongoose uses `AnyBulkWriteOperation[]`, prisma builds these
|
|
748
|
-
* from its client-extension API, pgkit uses SQL primitives. Arc does
|
|
749
|
-
* not call `bulkWrite` internally, so the exact shape is kit-specific.
|
|
750
|
-
* See `BulkWriteOperation<TDoc>` (exported from arc) for a reference
|
|
751
|
-
* shape you can use when implementing your own kit; mongokit-compatible
|
|
752
|
-
* callers should import its own operation types.
|
|
753
|
-
*/
|
|
754
|
-
bulkWrite?: unknown;
|
|
755
|
-
/** Restore a soft-deleted document. Should fire `before:restore` hooks. */
|
|
756
|
-
restore?(id: string, options?: QueryOptions): Promise<TDoc | null>;
|
|
757
|
-
/** Paginated list of soft-deleted documents. */
|
|
758
|
-
getDeleted?(params?: PaginationParams<TDoc>, options?: QueryOptions): Promise<PaginationResult<TDoc> | TDoc[]>;
|
|
759
|
-
/**
|
|
760
|
-
* Run an aggregation pipeline.
|
|
761
|
-
*
|
|
762
|
-
* Structurally typed as `unknown` because each kit uses a different
|
|
763
|
-
* stage type (mongoose's `PipelineStage`, prisma's client-extension
|
|
764
|
-
* builders, pgkit's query-builder primitives, …). Arc does not call
|
|
765
|
-
* `aggregate` internally — it's a capability consumers use directly on
|
|
766
|
-
* the repo. Cast or re-declare at the call site using your kit's types.
|
|
767
|
-
*/
|
|
768
|
-
aggregate?: unknown;
|
|
769
|
-
/**
|
|
770
|
-
* Paginated aggregation. Same kit-specificity reasoning as `aggregate`
|
|
771
|
-
* — structurally `unknown`, type-safe at the call site.
|
|
772
|
-
*/
|
|
773
|
-
aggregatePaginate?: unknown;
|
|
774
|
-
/**
|
|
775
|
-
* Run `callback` inside a transaction. Adapters should auto-retry on
|
|
776
|
-
* transient transaction errors and expose a `session` the callback can
|
|
777
|
-
* forward to subsequent repo calls.
|
|
778
|
-
*/
|
|
779
|
-
withTransaction?<T>(callback: (session: RepositorySession) => Promise<T>, options?: Record<string, unknown>): Promise<T>;
|
|
780
|
-
/** slugLookup preset — fetch by a business slug. */
|
|
781
|
-
getBySlug?(slug: string, options?: QueryOptions): Promise<TDoc | null>;
|
|
782
|
-
/** tree preset — return the full hierarchy. */
|
|
783
|
-
getTree?(options?: QueryOptions): Promise<TDoc[]>;
|
|
784
|
-
/** tree preset — return direct children of a node. */
|
|
785
|
-
getChildren?(parentId: string, options?: QueryOptions): Promise<TDoc[]>;
|
|
786
|
-
[key: string]: unknown;
|
|
836
|
+
server?: ServerAccessor;
|
|
787
837
|
}
|
|
788
838
|
/**
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
* @example
|
|
792
|
-
* ```ts
|
|
793
|
-
* type UserDoc = InferDoc<typeof userRepository>;
|
|
794
|
-
* ```
|
|
839
|
+
* Standard response from controller handlers
|
|
795
840
|
*/
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
841
|
+
interface IControllerResponse<T = unknown> {
|
|
842
|
+
/** Operation success status */
|
|
843
|
+
success: boolean;
|
|
844
|
+
/** Response data */
|
|
845
|
+
data?: T;
|
|
846
|
+
/** Error message (when success is false) */
|
|
847
|
+
error?: string;
|
|
848
|
+
/** HTTP status code (default: 200 for success, 400 for error) */
|
|
849
|
+
status?: number;
|
|
850
|
+
/** Additional metadata */
|
|
851
|
+
meta?: Record<string, unknown>;
|
|
852
|
+
/** Error details (for debugging) */
|
|
853
|
+
details?: Record<string, unknown>;
|
|
854
|
+
/** Custom response headers (e.g., X-Total-Count, Link, ETag) */
|
|
855
|
+
headers?: Record<string, string>;
|
|
856
|
+
}
|
|
799
857
|
/**
|
|
800
|
-
*
|
|
858
|
+
* Controller handler — Arc's standard pattern.
|
|
801
859
|
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*/
|
|
805
|
-
declare function defineResource<TDoc = AnyRecord>(config: ResourceConfig<TDoc>): ResourceDefinition<TDoc>;
|
|
806
|
-
interface ResolvedResourceConfig<TDoc = AnyRecord> extends ResourceConfig<TDoc> {
|
|
807
|
-
_appliedPresets?: string[];
|
|
808
|
-
_controllerOptions?: {
|
|
809
|
-
slugField?: string;
|
|
810
|
-
parentField?: string;
|
|
811
|
-
[key: string]: unknown;
|
|
812
|
-
};
|
|
813
|
-
_pendingHooks?: Array<{
|
|
814
|
-
operation: "create" | "update" | "delete" | "read" | "list";
|
|
815
|
-
phase: "before" | "after";
|
|
816
|
-
handler: (ctx: AnyRecord) => unknown;
|
|
817
|
-
priority: number;
|
|
818
|
-
}>;
|
|
819
|
-
}
|
|
820
|
-
declare class ResourceDefinition<TDoc = AnyRecord> {
|
|
821
|
-
readonly name: string;
|
|
822
|
-
readonly displayName: string;
|
|
823
|
-
readonly tag: string;
|
|
824
|
-
readonly prefix: string;
|
|
825
|
-
readonly adapter?: DataAdapter<TDoc>;
|
|
826
|
-
readonly controller?: IController<TDoc>;
|
|
827
|
-
readonly schemaOptions: RouteSchemaOptions;
|
|
828
|
-
readonly customSchemas: CrudSchemas;
|
|
829
|
-
readonly permissions: ResourcePermissions;
|
|
830
|
-
readonly additionalRoutes: AdditionalRoute[];
|
|
831
|
-
/**
|
|
832
|
-
* Original v2.8 `routes` declaration — retained for downstream consumers
|
|
833
|
-
* (OpenAPI, MCP, registry, CLI introspect). Preserves fields dropped during
|
|
834
|
-
* normalization to `additionalRoutes` (notably `mcp`, `description`,
|
|
835
|
-
* `annotations`). Undefined when the resource was defined with the legacy
|
|
836
|
-
* `additionalRoutes` shape.
|
|
837
|
-
*
|
|
838
|
-
* Added in 2.8.1 — the source-of-truth fix for "canonical resource manifest".
|
|
839
|
-
*/
|
|
840
|
-
readonly routes?: readonly RouteDefinition[];
|
|
841
|
-
readonly middlewares: MiddlewareConfig;
|
|
842
|
-
readonly routeGuards?: RouteHandlerMethod$1[];
|
|
843
|
-
readonly disableDefaultRoutes: boolean;
|
|
844
|
-
readonly disabledRoutes: CrudRouteKey[];
|
|
845
|
-
readonly actions?: ActionsMap;
|
|
846
|
-
readonly actionPermissions?: PermissionCheck;
|
|
847
|
-
readonly events: Record<string, EventDefinition>;
|
|
848
|
-
readonly rateLimit?: RateLimitConfig | false;
|
|
849
|
-
readonly audit?: boolean | {
|
|
850
|
-
operations?: ("create" | "update" | "delete")[];
|
|
851
|
-
};
|
|
852
|
-
readonly updateMethod?: "PUT" | "PATCH" | "both";
|
|
853
|
-
readonly pipe?: PipelineConfig;
|
|
854
|
-
readonly fields?: FieldPermissionMap;
|
|
855
|
-
readonly cache?: ResourceCacheConfig;
|
|
856
|
-
readonly skipGlobalPrefix: boolean;
|
|
857
|
-
readonly tenantField?: string | false;
|
|
858
|
-
readonly idField?: string;
|
|
859
|
-
readonly queryParser?: QueryParserInterface;
|
|
860
|
-
readonly _appliedPresets: string[];
|
|
861
|
-
_pendingHooks: Array<{
|
|
862
|
-
operation: "create" | "update" | "delete" | "read" | "list";
|
|
863
|
-
phase: "before" | "after";
|
|
864
|
-
handler: (ctx: AnyRecord) => unknown;
|
|
865
|
-
priority: number;
|
|
866
|
-
}>;
|
|
867
|
-
_registryMeta?: RegisterOptions;
|
|
868
|
-
constructor(config: ResolvedResourceConfig<TDoc>);
|
|
869
|
-
/** Get repository from adapter (if available) */
|
|
870
|
-
get repository(): RepositoryLike | CrudRepository<TDoc> | undefined;
|
|
871
|
-
_validateControllerMethods(): void;
|
|
872
|
-
toPlugin(): FastifyPluginAsync;
|
|
873
|
-
/**
|
|
874
|
-
* Get event definitions for registry
|
|
875
|
-
*/
|
|
876
|
-
getEvents(): Array<{
|
|
877
|
-
name: string;
|
|
878
|
-
module: string;
|
|
879
|
-
schema?: AnyRecord;
|
|
880
|
-
description?: string;
|
|
881
|
-
}>;
|
|
882
|
-
/**
|
|
883
|
-
* Get resource metadata
|
|
884
|
-
*/
|
|
885
|
-
getMetadata(): ResourceMetadata;
|
|
886
|
-
}
|
|
887
|
-
//#endregion
|
|
888
|
-
//#region src/registry/ResourceRegistry.d.ts
|
|
889
|
-
interface RegisterOptions {
|
|
890
|
-
module?: string;
|
|
891
|
-
/** Pre-generated OpenAPI schemas */
|
|
892
|
-
openApiSchemas?: OpenApiSchemas;
|
|
893
|
-
}
|
|
894
|
-
declare class ResourceRegistry {
|
|
895
|
-
private _resources;
|
|
896
|
-
private _frozen;
|
|
897
|
-
constructor();
|
|
898
|
-
/**
|
|
899
|
-
* Register a resource
|
|
900
|
-
*/
|
|
901
|
-
register(resource: ResourceDefinition<unknown>, options?: RegisterOptions): this;
|
|
902
|
-
/**
|
|
903
|
-
* Get resource by name
|
|
904
|
-
*/
|
|
905
|
-
get(name: string): RegistryEntry | undefined;
|
|
906
|
-
/**
|
|
907
|
-
* Get all resources
|
|
908
|
-
*/
|
|
909
|
-
getAll(): RegistryEntry[];
|
|
910
|
-
/**
|
|
911
|
-
* Get resources by module
|
|
912
|
-
*/
|
|
913
|
-
getByModule(moduleName: string): RegistryEntry[];
|
|
914
|
-
/**
|
|
915
|
-
* Get resources by preset
|
|
916
|
-
*/
|
|
917
|
-
getByPreset(presetName: string): RegistryEntry[];
|
|
918
|
-
/**
|
|
919
|
-
* Check if resource exists
|
|
920
|
-
*/
|
|
921
|
-
has(name: string): boolean;
|
|
922
|
-
/**
|
|
923
|
-
* Get registry statistics
|
|
924
|
-
*/
|
|
925
|
-
getStats(): RegistryStats;
|
|
926
|
-
/**
|
|
927
|
-
* Get full introspection data
|
|
928
|
-
*/
|
|
929
|
-
getIntrospection(): IntrospectionData;
|
|
930
|
-
/**
|
|
931
|
-
* Freeze registry (prevent further registrations)
|
|
932
|
-
*/
|
|
933
|
-
freeze(): void;
|
|
934
|
-
/**
|
|
935
|
-
* Check if frozen
|
|
936
|
-
*/
|
|
937
|
-
isFrozen(): boolean;
|
|
938
|
-
/**
|
|
939
|
-
* Unfreeze registry (allow new registrations)
|
|
940
|
-
*/
|
|
941
|
-
unfreeze(): void;
|
|
942
|
-
/**
|
|
943
|
-
* Reset registry — clear all resources and unfreeze
|
|
944
|
-
*/
|
|
945
|
-
reset(): void;
|
|
946
|
-
/** @internal Alias for unfreeze() */
|
|
947
|
-
_unfreeze(): void;
|
|
948
|
-
/** @internal Alias for reset() */
|
|
949
|
-
_clear(): void;
|
|
950
|
-
/**
|
|
951
|
-
* Group by key
|
|
952
|
-
*/
|
|
953
|
-
private _groupBy;
|
|
954
|
-
}
|
|
955
|
-
//#endregion
|
|
956
|
-
//#region src/types/handlers.d.ts
|
|
957
|
-
/**
|
|
958
|
-
* Minimal server accessor — exposes safe, read-only server decorators.
|
|
959
|
-
* Allows controller handlers to publish events, log, and audit
|
|
960
|
-
* without switching to `raw: true`.
|
|
961
|
-
*/
|
|
962
|
-
interface ServerAccessor {
|
|
963
|
-
/** Event bus — publish domain events from any handler */
|
|
964
|
-
events?: {
|
|
965
|
-
publish: <T>(type: string, payload: T, meta?: Partial<Record<string, unknown>>) => Promise<void>;
|
|
966
|
-
};
|
|
967
|
-
/** Audit logger — log custom audit entries */
|
|
968
|
-
audit?: {
|
|
969
|
-
create: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
970
|
-
update: (resource: string, documentId: string, before: Record<string, unknown>, after: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
971
|
-
delete: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
972
|
-
custom: (resource: string, documentId: string, action: string, data?: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
973
|
-
};
|
|
974
|
-
/** Logger — structured logging */
|
|
975
|
-
log?: {
|
|
976
|
-
info: (...args: unknown[]) => void;
|
|
977
|
-
warn: (...args: unknown[]) => void;
|
|
978
|
-
error: (...args: unknown[]) => void;
|
|
979
|
-
debug: (...args: unknown[]) => void;
|
|
980
|
-
};
|
|
981
|
-
/** QueryCache — stale-while-revalidate data cache */
|
|
982
|
-
queryCache?: {
|
|
983
|
-
get: <T>(key: string) => Promise<{
|
|
984
|
-
data: T;
|
|
985
|
-
status: 'fresh' | 'stale' | 'miss';
|
|
986
|
-
}>;
|
|
987
|
-
set: <T>(key: string, data: T, config: {
|
|
988
|
-
staleTime?: number;
|
|
989
|
-
gcTime?: number;
|
|
990
|
-
tags?: string[];
|
|
991
|
-
}) => Promise<void>;
|
|
992
|
-
getResourceVersion: (resource: string) => Promise<number>;
|
|
993
|
-
bumpResourceVersion: (resource: string) => Promise<void>;
|
|
994
|
-
};
|
|
995
|
-
}
|
|
996
|
-
/**
|
|
997
|
-
* Request context passed to controller handlers.
|
|
998
|
-
*
|
|
999
|
-
* **Generic parameters** (all default to safe permissive types so existing code keeps working):
|
|
1000
|
-
* - `TBody` — request body shape (default: `unknown`)
|
|
1001
|
-
* - `TParams` — route param shape (default: `Record<string, string>`)
|
|
1002
|
-
* - `TQuery` — query string shape (default: `Record<string, unknown>`)
|
|
1003
|
-
* - `TUser` — authenticated user shape (default: `UserBase`)
|
|
1004
|
-
* - `TMetadata` — internal metadata shape (default: `Record<string, unknown>`;
|
|
1005
|
-
* override with `ArcInternalMetadata` or your own augmentation when you
|
|
1006
|
-
* need typed access to `_scope`, `_policyFilters`, custom hook context, etc.)
|
|
1007
|
-
*
|
|
1008
|
-
* @example
|
|
1009
|
-
* ```typescript
|
|
1010
|
-
* // Untyped (default) — req.body is `unknown`, must be narrowed
|
|
1011
|
-
* async create(req: IRequestContext) {
|
|
1012
|
-
* const data = req.body as Partial<Product>;
|
|
1013
|
-
* return { success: true, data: await productRepo.create(data) };
|
|
1014
|
-
* }
|
|
1015
|
-
*
|
|
1016
|
-
* // Typed body — req.body is `CreateProductInput`, narrowing not needed
|
|
1017
|
-
* async create(req: IRequestContext<CreateProductInput>) {
|
|
1018
|
-
* return { success: true, data: await productRepo.create(req.body) };
|
|
1019
|
-
* }
|
|
1020
|
-
*
|
|
1021
|
-
* // Fully typed — body, route params, query, and metadata
|
|
1022
|
-
* async update(
|
|
1023
|
-
* req: IRequestContext<
|
|
1024
|
-
* Partial<Product>,
|
|
1025
|
-
* { id: string },
|
|
1026
|
-
* { fields?: string },
|
|
1027
|
-
* ArcInternalMetadata
|
|
1028
|
-
* >,
|
|
1029
|
-
* ) {
|
|
1030
|
-
* const fields = req.query.fields?.split(',');
|
|
1031
|
-
* const orgId = req.metadata?._scope ? getOrgId(req.metadata._scope) : undefined;
|
|
1032
|
-
* return { success: true, data: await productRepo.update(req.params.id, req.body) };
|
|
1033
|
-
* }
|
|
1034
|
-
* ```
|
|
1035
|
-
*/
|
|
1036
|
-
interface IRequestContext<TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>, TUser extends UserBase = UserBase, TMetadata extends Record<string, unknown> = Record<string, unknown>> {
|
|
1037
|
-
/** Route parameters (e.g., { id: '123' }) */
|
|
1038
|
-
params: TParams;
|
|
1039
|
-
/** Query string parameters */
|
|
1040
|
-
query: TQuery;
|
|
1041
|
-
/** Request body */
|
|
1042
|
-
body: TBody;
|
|
1043
|
-
/** Authenticated user or null */
|
|
1044
|
-
user: TUser | null;
|
|
1045
|
-
/** Request headers */
|
|
1046
|
-
headers: Record<string, string | undefined>;
|
|
1047
|
-
/** Organization ID (for multi-tenant apps) */
|
|
1048
|
-
organizationId?: string;
|
|
1049
|
-
/** Team ID (for team-scoped resources) */
|
|
1050
|
-
teamId?: string;
|
|
1051
|
-
/**
|
|
1052
|
-
* Organization/auth context from middleware.
|
|
1053
|
-
* Contains orgRoles, orgScope, organizationId, and any custom fields
|
|
1054
|
-
* set by the auth adapter or org-scope plugin.
|
|
1055
|
-
*
|
|
1056
|
-
* @example
|
|
1057
|
-
* ```typescript
|
|
1058
|
-
* async create(req: IRequestContext) {
|
|
1059
|
-
* const roles = req.context?.orgRoles ?? [];
|
|
1060
|
-
* if (roles.includes('manager')) { ... }
|
|
1061
|
-
* }
|
|
1062
|
-
* ```
|
|
1063
|
-
*/
|
|
1064
|
-
context?: RequestContext;
|
|
1065
|
-
/**
|
|
1066
|
-
* Internal metadata (includes context + Arc internals like `_policyFilters`,
|
|
1067
|
-
* `_scope`, `log`). Type as `ArcInternalMetadata` for typed access to Arc's
|
|
1068
|
-
* built-in fields, or supply your own interface to layer custom fields.
|
|
1069
|
-
*/
|
|
1070
|
-
metadata?: TMetadata;
|
|
1071
|
-
/**
|
|
1072
|
-
* Fastify server accessor — publish events, log, and audit
|
|
1073
|
-
* from any handler without switching to `raw: true`.
|
|
1074
|
-
*
|
|
1075
|
-
* @example
|
|
1076
|
-
* ```typescript
|
|
1077
|
-
* async reschedule(req: IRequestContext) {
|
|
1078
|
-
* const result = await repo.reschedule(req.params.id, req.body);
|
|
1079
|
-
* await req.server?.events?.publish('interview.rescheduled', { data: result });
|
|
1080
|
-
* return { success: true, data: result };
|
|
1081
|
-
* }
|
|
1082
|
-
* ```
|
|
1083
|
-
*/
|
|
1084
|
-
server?: ServerAccessor;
|
|
1085
|
-
}
|
|
1086
|
-
/**
|
|
1087
|
-
* Standard response from controller handlers
|
|
1088
|
-
*/
|
|
1089
|
-
interface IControllerResponse<T = unknown> {
|
|
1090
|
-
/** Operation success status */
|
|
1091
|
-
success: boolean;
|
|
1092
|
-
/** Response data */
|
|
1093
|
-
data?: T;
|
|
1094
|
-
/** Error message (when success is false) */
|
|
1095
|
-
error?: string;
|
|
1096
|
-
/** HTTP status code (default: 200 for success, 400 for error) */
|
|
1097
|
-
status?: number;
|
|
1098
|
-
/** Additional metadata */
|
|
1099
|
-
meta?: Record<string, unknown>;
|
|
1100
|
-
/** Error details (for debugging) */
|
|
1101
|
-
details?: Record<string, unknown>;
|
|
1102
|
-
/** Custom response headers (e.g., X-Total-Count, Link, ETag) */
|
|
1103
|
-
headers?: Record<string, string>;
|
|
1104
|
-
}
|
|
1105
|
-
/**
|
|
1106
|
-
* Controller handler — Arc's standard pattern.
|
|
1107
|
-
*
|
|
1108
|
-
* Receives a request context object, returns IControllerResponse.
|
|
1109
|
-
* Use with `raw: false` in routes.
|
|
1110
|
-
*
|
|
1111
|
-
* **Generic parameters:**
|
|
1112
|
-
* - `TResponse` — shape of `IControllerResponse.data` (default: `unknown`)
|
|
1113
|
-
* - `TBody` — shape of `req.body` (default: `unknown`)
|
|
1114
|
-
* - `TParams` — shape of `req.params` (default: `Record<string, string>`)
|
|
1115
|
-
* - `TQuery` — shape of `req.query` (default: `Record<string, unknown>`)
|
|
1116
|
-
*
|
|
1117
|
-
* Backward-compatible: `ControllerHandler<Product>` still works (only the
|
|
1118
|
-
* response data is typed); add more generics as needed when you want
|
|
1119
|
-
* type-safe access to the request body, params, or query string.
|
|
1120
|
-
*
|
|
1121
|
-
* @example
|
|
1122
|
-
* ```typescript
|
|
1123
|
-
* // Untyped req — body is unknown, must be narrowed
|
|
1124
|
-
* const createProduct: ControllerHandler<Product> = async (req) => {
|
|
1125
|
-
* const product = await productRepo.create(req.body as Partial<Product>);
|
|
1126
|
-
* return { success: true, data: product, status: 201 };
|
|
1127
|
-
* };
|
|
1128
|
-
*
|
|
1129
|
-
* // Fully typed — body, params, query, and response all inferred
|
|
1130
|
-
* const updateProduct: ControllerHandler<
|
|
1131
|
-
* Product,
|
|
1132
|
-
* Partial<Product>,
|
|
1133
|
-
* { id: string },
|
|
1134
|
-
* { upsert?: string }
|
|
1135
|
-
* > = async (req) => {
|
|
1136
|
-
* const upsert = req.query.upsert === "true";
|
|
1137
|
-
* const product = await productRepo.update(req.params.id, req.body, { upsert });
|
|
1138
|
-
* return { success: true, data: product };
|
|
1139
|
-
* };
|
|
1140
|
-
*
|
|
1141
|
-
* routes: [{
|
|
1142
|
-
* method: 'POST',
|
|
1143
|
-
* path: '/products',
|
|
1144
|
-
* handler: createProduct,
|
|
1145
|
-
* permissions: requireAuth(),
|
|
1146
|
-
* raw: false, // Arc wraps this into Fastify handler
|
|
1147
|
-
* }]
|
|
1148
|
-
* ```
|
|
1149
|
-
*/
|
|
1150
|
-
type ControllerHandler<TResponse = unknown, TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>> = (req: IRequestContext<TBody, TParams, TQuery>) => Promise<IControllerResponse<TResponse>>;
|
|
1151
|
-
/**
|
|
1152
|
-
* Fastify native handler
|
|
1153
|
-
*
|
|
1154
|
-
* Standard Fastify request/reply pattern.
|
|
1155
|
-
* Use with `raw: true` in routes.
|
|
1156
|
-
*
|
|
1157
|
-
* @example
|
|
1158
|
-
* ```typescript
|
|
1159
|
-
* const downloadFile: FastifyHandler = async (request, reply) => {
|
|
1160
|
-
* const file = await getFile(request.params.id);
|
|
1161
|
-
* reply.header('Content-Type', file.mimeType);
|
|
1162
|
-
* return reply.send(file.buffer);
|
|
1163
|
-
* };
|
|
1164
|
-
*
|
|
1165
|
-
* routes: [{
|
|
1166
|
-
* method: 'GET',
|
|
1167
|
-
* path: '/files/:id/download',
|
|
1168
|
-
* handler: downloadFile,
|
|
1169
|
-
* permissions: requireAuth(),
|
|
1170
|
-
* raw: true, // Use as-is, no wrapping
|
|
1171
|
-
* }]
|
|
1172
|
-
* ```
|
|
1173
|
-
*/
|
|
1174
|
-
type FastifyHandler<RouteGeneric extends Record<string, unknown> = Record<string, unknown>> = (request: FastifyRequest<RouteGeneric>, reply: FastifyReply) => Promise<unknown> | unknown;
|
|
1175
|
-
/**
|
|
1176
|
-
* Union type for route handlers
|
|
1177
|
-
*/
|
|
1178
|
-
type RouteHandler = ControllerHandler | FastifyHandler;
|
|
1179
|
-
/**
|
|
1180
|
-
* Controller interface for CRUD operations (strict)
|
|
1181
|
-
*/
|
|
1182
|
-
interface IController<TDoc = unknown> {
|
|
1183
|
-
list(req: IRequestContext): Promise<IControllerResponse<{
|
|
1184
|
-
docs: TDoc[];
|
|
1185
|
-
total: number;
|
|
1186
|
-
}>>;
|
|
1187
|
-
get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1188
|
-
create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1189
|
-
update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1190
|
-
delete(req: IRequestContext): Promise<IControllerResponse<{
|
|
1191
|
-
message: string;
|
|
1192
|
-
}>>;
|
|
1193
|
-
}
|
|
1194
|
-
/**
|
|
1195
|
-
* Flexible controller interface - accepts controllers with any handler style
|
|
1196
|
-
* Use this when your controller uses Fastify native handlers
|
|
1197
|
-
*/
|
|
1198
|
-
interface ControllerLike {
|
|
1199
|
-
list?: unknown;
|
|
1200
|
-
get?: unknown;
|
|
1201
|
-
create?: unknown;
|
|
1202
|
-
update?: unknown;
|
|
1203
|
-
delete?: unknown;
|
|
1204
|
-
[key: string]: unknown;
|
|
1205
|
-
}
|
|
1206
|
-
//#endregion
|
|
1207
|
-
//#region src/core/AccessControl.d.ts
|
|
1208
|
-
interface AccessControlConfig {
|
|
1209
|
-
/** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable org filtering. */
|
|
1210
|
-
tenantField: string | false;
|
|
1211
|
-
/** Primary key field name (default: '_id') */
|
|
1212
|
-
idField: string;
|
|
1213
|
-
/**
|
|
1214
|
-
* Custom filter matching for policy enforcement.
|
|
1215
|
-
* Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
|
|
1216
|
-
* Falls back to built-in MongoDB-style matching if not provided.
|
|
1217
|
-
*/
|
|
1218
|
-
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
1219
|
-
}
|
|
1220
|
-
/** Minimal repository interface for access-controlled fetch operations */
|
|
1221
|
-
interface AccessControlRepository {
|
|
1222
|
-
getById(id: string, options?: QueryOptions): Promise<unknown>;
|
|
1223
|
-
getOne?: (filter: AnyRecord, options?: QueryOptions) => Promise<unknown>;
|
|
1224
|
-
}
|
|
1225
|
-
declare class AccessControl {
|
|
1226
|
-
private readonly tenantField;
|
|
1227
|
-
private readonly idField;
|
|
1228
|
-
private readonly _adapterMatchesFilter?;
|
|
1229
|
-
/** Patterns that indicate dangerous regex (nested quantifiers, excessive backtracking).
|
|
1230
|
-
* Uses [^...] character classes instead of .+ to avoid backtracking in the detector itself. */
|
|
1231
|
-
private static readonly DANGEROUS_REGEX;
|
|
1232
|
-
/** Forbidden paths that could lead to prototype pollution */
|
|
1233
|
-
private static readonly FORBIDDEN_PATHS;
|
|
1234
|
-
constructor(config: AccessControlConfig);
|
|
1235
|
-
/**
|
|
1236
|
-
* Build filter for single-item operations (get/update/delete)
|
|
1237
|
-
* Combines ID filter with policy/org filters for proper security enforcement
|
|
1238
|
-
*/
|
|
1239
|
-
buildIdFilter(id: string, req: IRequestContext): AnyRecord;
|
|
1240
|
-
/**
|
|
1241
|
-
* Check if item matches policy filters (for get/update/delete operations)
|
|
1242
|
-
* Validates that fetched item satisfies all policy constraints
|
|
1243
|
-
*
|
|
1244
|
-
* Delegates to adapter-provided matchesFilter if available (for SQL, etc.),
|
|
1245
|
-
* otherwise falls back to built-in MongoDB-style matching.
|
|
1246
|
-
*/
|
|
1247
|
-
checkPolicyFilters(item: AnyRecord, req: IRequestContext): boolean;
|
|
1248
|
-
/**
|
|
1249
|
-
* Check org/tenant scope for a document — uses configurable tenantField.
|
|
1250
|
-
*
|
|
1251
|
-
* SECURITY: When org scope is active (orgId present), documents that are
|
|
1252
|
-
* missing the tenant field are DENIED by default. This prevents legacy or
|
|
1253
|
-
* unscoped records from leaking across tenants.
|
|
1254
|
-
*/
|
|
1255
|
-
checkOrgScope(item: AnyRecord | null, arcContext: ArcInternalMetadata | RequestContext | undefined): boolean;
|
|
1256
|
-
/** Check ownership for update/delete (ownedByUser preset) */
|
|
1257
|
-
checkOwnership(item: AnyRecord | null, req: IRequestContext): boolean;
|
|
1258
|
-
/**
|
|
1259
|
-
* Fetch a single document with full access control enforcement.
|
|
1260
|
-
* Combines compound DB filter (ID + org + policy) with post-hoc fallback.
|
|
1261
|
-
*
|
|
1262
|
-
* Takes repository as a parameter to avoid coupling.
|
|
1263
|
-
*
|
|
1264
|
-
* Replaces the duplicated pattern in get/update/delete:
|
|
1265
|
-
* buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
|
|
1266
|
-
*/
|
|
1267
|
-
fetchWithAccessControl<TDoc>(id: string, req: IRequestContext, repository: AccessControlRepository, queryOptions?: QueryOptions): Promise<TDoc | null>;
|
|
1268
|
-
/**
|
|
1269
|
-
* Post-fetch access control validation for items fetched by non-ID queries
|
|
1270
|
-
* (e.g., getBySlug, restore). Applies org scope, policy filters, and
|
|
1271
|
-
* ownership checks — the same guarantees as fetchWithAccessControl.
|
|
1272
|
-
*/
|
|
1273
|
-
validateItemAccess(item: AnyRecord | null, req: IRequestContext): boolean;
|
|
1274
|
-
/** Extract typed Arc internal metadata from request */
|
|
1275
|
-
private _meta;
|
|
1276
|
-
/**
|
|
1277
|
-
* Check if a value matches a MongoDB query operator
|
|
1278
|
-
*/
|
|
1279
|
-
private matchesOperator;
|
|
1280
|
-
/**
|
|
1281
|
-
* Check if item matches a single filter condition
|
|
1282
|
-
* Supports nested paths (e.g., "owner.id", "metadata.status")
|
|
1283
|
-
*/
|
|
1284
|
-
private matchesFilter;
|
|
1285
|
-
/**
|
|
1286
|
-
* Built-in MongoDB-style policy filter matching.
|
|
1287
|
-
* Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
|
|
1288
|
-
*/
|
|
1289
|
-
private defaultMatchesPolicyFilters;
|
|
1290
|
-
/**
|
|
1291
|
-
* Get nested value from object using dot notation (e.g., "owner.id")
|
|
1292
|
-
* Security: Validates path against forbidden patterns to prevent prototype pollution
|
|
1293
|
-
*/
|
|
1294
|
-
private getNestedValue;
|
|
1295
|
-
/**
|
|
1296
|
-
* Create a safe RegExp from a string, guarding against ReDoS.
|
|
1297
|
-
* Returns null if the pattern is invalid or dangerous.
|
|
1298
|
-
*/
|
|
1299
|
-
private static safeRegex;
|
|
1300
|
-
}
|
|
1301
|
-
//#endregion
|
|
1302
|
-
//#region src/core/BodySanitizer.d.ts
|
|
1303
|
-
interface BodySanitizerConfig {
|
|
1304
|
-
/** Schema options for field sanitization */
|
|
1305
|
-
schemaOptions: RouteSchemaOptions;
|
|
1306
|
-
}
|
|
1307
|
-
declare class BodySanitizer {
|
|
1308
|
-
private schemaOptions;
|
|
1309
|
-
constructor(config: BodySanitizerConfig);
|
|
1310
|
-
/**
|
|
1311
|
-
* Strip readonly and system-managed fields from request body.
|
|
1312
|
-
* Prevents clients from overwriting _id, timestamps, __v, etc.
|
|
1313
|
-
*
|
|
1314
|
-
* Also applies field-level write permissions when the request has
|
|
1315
|
-
* field permission metadata.
|
|
1316
|
-
*/
|
|
1317
|
-
sanitize(body: AnyRecord, _operation: "create" | "update", req?: IRequestContext, meta?: ArcInternalMetadata): AnyRecord;
|
|
1318
|
-
}
|
|
1319
|
-
//#endregion
|
|
1320
|
-
//#region src/core/QueryResolver.d.ts
|
|
1321
|
-
interface QueryResolverConfig {
|
|
1322
|
-
/** Query parser instance (default: Arc built-in parser) */
|
|
1323
|
-
queryParser?: QueryParserInterface;
|
|
1324
|
-
/** Maximum limit for pagination (default: 100) */
|
|
1325
|
-
maxLimit?: number;
|
|
1326
|
-
/** Default limit for pagination (default: 20) */
|
|
1327
|
-
defaultLimit?: number;
|
|
1328
|
-
/** Default sort field (default: '-createdAt') */
|
|
1329
|
-
defaultSort?: string;
|
|
1330
|
-
/** Schema options for field sanitization */
|
|
1331
|
-
schemaOptions?: RouteSchemaOptions;
|
|
1332
|
-
/** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable. */
|
|
1333
|
-
tenantField?: string | false;
|
|
1334
|
-
}
|
|
1335
|
-
declare class QueryResolver {
|
|
1336
|
-
private queryParser;
|
|
1337
|
-
private maxLimit;
|
|
1338
|
-
private defaultLimit;
|
|
1339
|
-
private defaultSort;
|
|
1340
|
-
private schemaOptions;
|
|
1341
|
-
private tenantField;
|
|
1342
|
-
constructor(config?: QueryResolverConfig);
|
|
1343
|
-
/**
|
|
1344
|
-
* Resolve a request into parsed query options -- ONE parse per request.
|
|
1345
|
-
* Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
|
|
1346
|
-
*/
|
|
1347
|
-
resolve(req: IRequestContext, meta?: ArcInternalMetadata): ControllerQueryOptions;
|
|
1348
|
-
/**
|
|
1349
|
-
* Sanitize select — preserves the input format (string, array, or object).
|
|
1350
|
-
* This is critical for db-agnostic support: MongoKit returns object projections,
|
|
1351
|
-
* Mongoose uses space-separated strings, SQL adapters may use arrays.
|
|
1352
|
-
*/
|
|
1353
|
-
private sanitizeSelectAny;
|
|
1354
|
-
/** Sanitize populate fields */
|
|
1355
|
-
private sanitizePopulate;
|
|
1356
|
-
/** Sanitize advanced populate options against allowedPopulate */
|
|
1357
|
-
private sanitizePopulateOptions;
|
|
1358
|
-
/**
|
|
1359
|
-
* Sanitize lookup/join options.
|
|
1360
|
-
* If schemaOptions.query.allowedLookups is set, only those collections are allowed.
|
|
1361
|
-
* Validates lookup structure to prevent injection.
|
|
1362
|
-
*/
|
|
1363
|
-
private sanitizeLookups;
|
|
1364
|
-
/** Get blocked fields from schema options */
|
|
1365
|
-
private getBlockedFields;
|
|
1366
|
-
}
|
|
1367
|
-
//#endregion
|
|
1368
|
-
//#region src/core/BaseController.d.ts
|
|
1369
|
-
interface BaseControllerOptions {
|
|
1370
|
-
/** Schema options for field sanitization */
|
|
1371
|
-
schemaOptions?: RouteSchemaOptions;
|
|
1372
|
-
/**
|
|
1373
|
-
* Query parser instance.
|
|
1374
|
-
* Default: Arc built-in query parser (adapter-agnostic).
|
|
1375
|
-
* Swap in MongoKit QueryParser, pgkit parser, etc.
|
|
1376
|
-
*/
|
|
1377
|
-
queryParser?: QueryParserInterface;
|
|
1378
|
-
/** Maximum limit for pagination (default: 100) */
|
|
1379
|
-
maxLimit?: number;
|
|
1380
|
-
/** Default limit for pagination (default: 20) */
|
|
1381
|
-
defaultLimit?: number;
|
|
1382
|
-
/** Default sort field (default: '-createdAt') */
|
|
1383
|
-
defaultSort?: string;
|
|
1384
|
-
/** Resource name for hook execution (e.g., 'product' -> 'product.created') */
|
|
1385
|
-
resourceName?: string;
|
|
1386
|
-
/**
|
|
1387
|
-
* Field name used for multi-tenant scoping (default: 'organizationId').
|
|
1388
|
-
* Override to match your schema: 'workspaceId', 'tenantId', 'teamId', etc.
|
|
1389
|
-
* Set to `false` to disable org filtering for platform-universal resources.
|
|
1390
|
-
*/
|
|
1391
|
-
tenantField?: string | false;
|
|
1392
|
-
/**
|
|
1393
|
-
* Primary key field name (default: '_id').
|
|
1394
|
-
*
|
|
1395
|
-
* If not set, the controller auto-derives it from the repository's own
|
|
1396
|
-
* `idField` property (e.g. MongoKit's `Repository({ idField: 'id' })`),
|
|
1397
|
-
* so you only need to configure it in one place.
|
|
1398
|
-
*
|
|
1399
|
-
* Set explicitly to override the repo's setting (e.g. `'_id'` to opt out
|
|
1400
|
-
* of native pass-through and force the slug-translation path).
|
|
1401
|
-
*
|
|
1402
|
-
* Override for non-MongoDB adapters (e.g., 'id' for SQL databases).
|
|
1403
|
-
*/
|
|
1404
|
-
idField?: string;
|
|
1405
|
-
/**
|
|
1406
|
-
* Custom filter matching for policy enforcement.
|
|
1407
|
-
* Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
|
|
1408
|
-
* Falls back to built-in MongoDB-style matching if not provided.
|
|
1409
|
-
*/
|
|
1410
|
-
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
1411
|
-
/** Cache configuration for the resource */
|
|
1412
|
-
cache?: ResourceCacheConfig;
|
|
1413
|
-
/** Internal preset fields map (slug, tree, etc.) */
|
|
1414
|
-
presetFields?: {
|
|
1415
|
-
slugField?: string;
|
|
1416
|
-
parentField?: string;
|
|
1417
|
-
};
|
|
1418
|
-
}
|
|
1419
|
-
/**
|
|
1420
|
-
* Framework-agnostic base controller implementing IController.
|
|
1421
|
-
*
|
|
1422
|
-
* Composes AccessControl, BodySanitizer, and QueryResolver for clean
|
|
1423
|
-
* separation of concerns. CRUD methods delegate directly to these
|
|
1424
|
-
* composed classes — no intermediate wrapper methods.
|
|
860
|
+
* Receives a request context object, returns IControllerResponse.
|
|
861
|
+
* Use with `raw: false` in routes.
|
|
1425
862
|
*
|
|
1426
|
-
*
|
|
1427
|
-
*
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
protected schemaOptions: RouteSchemaOptions;
|
|
1432
|
-
protected queryParser: QueryParserInterface;
|
|
1433
|
-
protected maxLimit: number;
|
|
1434
|
-
protected defaultLimit: number;
|
|
1435
|
-
protected defaultSort: string;
|
|
1436
|
-
protected resourceName?: string;
|
|
1437
|
-
protected tenantField: string | false;
|
|
1438
|
-
protected idField: string;
|
|
1439
|
-
/** Composable access control (ID filtering, policy checks, org scope, ownership) */
|
|
1440
|
-
readonly accessControl: AccessControl;
|
|
1441
|
-
/** Composable body sanitization (field permissions, system fields) */
|
|
1442
|
-
readonly bodySanitizer: BodySanitizer;
|
|
1443
|
-
/** Composable query resolution (parsing, pagination, sort, select/populate) */
|
|
1444
|
-
readonly queryResolver: QueryResolver;
|
|
1445
|
-
private _matchesFilter?;
|
|
1446
|
-
private _presetFields;
|
|
1447
|
-
private _cacheConfig?;
|
|
1448
|
-
constructor(repository: TRepository, options?: BaseControllerOptions);
|
|
1449
|
-
/**
|
|
1450
|
-
* Get the tenant field name if multi-tenant scoping is enabled.
|
|
1451
|
-
* Returns `undefined` when `tenantField` is `false` (platform-universal mode).
|
|
1452
|
-
*
|
|
1453
|
-
* Use this in subclass overrides instead of accessing `this.tenantField` directly
|
|
1454
|
-
* to avoid TypeScript indexing errors with `string | false`.
|
|
1455
|
-
*/
|
|
1456
|
-
protected getTenantField(): string | undefined;
|
|
1457
|
-
/** Extract typed Arc internal metadata from request */
|
|
1458
|
-
private meta;
|
|
1459
|
-
/** Get hook system from request context (instance-scoped) */
|
|
1460
|
-
private getHooks;
|
|
1461
|
-
/**
|
|
1462
|
-
* Resolve the repository primary key for mutation calls (update/delete/restore).
|
|
1463
|
-
*
|
|
1464
|
-
* When the resource declares a custom `idField` (e.g. `slug`, `jobId`, UUID),
|
|
1465
|
-
* the default behavior is to translate the route id → the fetched doc's `_id`
|
|
1466
|
-
* because most Mongo repositories key their mutation methods off `_id`.
|
|
1467
|
-
*
|
|
1468
|
-
* Exception: if the repository itself exposes a matching `idField` property
|
|
1469
|
-
* (e.g. MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
|
|
1470
|
-
* repository already knows how to look up by that field — so we pass the
|
|
1471
|
-
* route id through unchanged and skip the translation.
|
|
1472
|
-
*
|
|
1473
|
-
* This makes `defineResource({ idField: 'id' })` work end-to-end with repos
|
|
1474
|
-
* that natively support custom primary keys, without breaking the slug-style
|
|
1475
|
-
* aliasing that Arc 2.6.3 introduced for repos keyed on `_id`.
|
|
1476
|
-
*/
|
|
1477
|
-
private resolveRepoId;
|
|
1478
|
-
/** Resolve cache config for a specific operation, merging per-op overrides */
|
|
1479
|
-
private resolveCacheConfig;
|
|
1480
|
-
/**
|
|
1481
|
-
* Extract user/org IDs from request for cache key scoping.
|
|
1482
|
-
* Only includes orgId when this resource uses tenant-scoped data (tenantField is set).
|
|
1483
|
-
* Universal resources (tenantField: false) get shared cache keys to avoid fragmentation.
|
|
1484
|
-
*/
|
|
1485
|
-
private cacheScope;
|
|
1486
|
-
list(req: IRequestContext): Promise<IControllerResponse<PaginatedResult<TDoc>>>;
|
|
1487
|
-
/** Execute list query through hooks (extracted for cache revalidation) */
|
|
1488
|
-
private executeListQuery;
|
|
1489
|
-
get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1490
|
-
/** Execute get query through hooks (extracted for cache revalidation) */
|
|
1491
|
-
private executeGetQuery;
|
|
1492
|
-
create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1493
|
-
update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1494
|
-
delete(req: IRequestContext): Promise<IControllerResponse<{
|
|
1495
|
-
message: string;
|
|
1496
|
-
id?: string;
|
|
1497
|
-
soft?: boolean;
|
|
1498
|
-
}>>;
|
|
1499
|
-
getBySlug(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1500
|
-
getDeleted(req: IRequestContext): Promise<IControllerResponse<PaginationResult<TDoc>>>;
|
|
1501
|
-
restore(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1502
|
-
getTree(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
1503
|
-
getChildren(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
1504
|
-
bulkCreate(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
1505
|
-
/**
|
|
1506
|
-
* Build a tenant-scoped filter for bulk update/delete.
|
|
1507
|
-
*
|
|
1508
|
-
* Mirrors `AccessControl.buildIdFilter` semantics for single-doc ops:
|
|
1509
|
-
* - Always merge `_policyFilters` (from permission middleware)
|
|
1510
|
-
* - When `tenantField` is set AND a `member` scope is present, add the
|
|
1511
|
-
* org filter so cross-tenant data can't be touched.
|
|
1512
|
-
* - When the scope is `elevated` (platform admin), no org filter is
|
|
1513
|
-
* applied — admins can bulk-update across orgs intentionally.
|
|
1514
|
-
* - When the scope is `public` on a tenant-scoped resource, deny.
|
|
1515
|
-
* - When NO scope is present at all (e.g., direct controller calls in
|
|
1516
|
-
* unit tests, or app routes without auth middleware), the controller
|
|
1517
|
-
* stays lenient — it's the middleware layer's job to fail-close.
|
|
1518
|
-
* Apps that want fail-close on bulk routes should run the multi-tenant
|
|
1519
|
-
* preset middleware (or equivalent) ahead of these handlers.
|
|
1520
|
-
*
|
|
1521
|
-
* Returns the merged filter, or `null` when access must be denied.
|
|
1522
|
-
*/
|
|
1523
|
-
private buildBulkFilter;
|
|
1524
|
-
/**
|
|
1525
|
-
* Sanitize a bulk update data payload through the same write-permission
|
|
1526
|
-
* pipeline as single-doc update(). Handles both shapes:
|
|
1527
|
-
*
|
|
1528
|
-
* - Flat: `{ name: 'x', status: 'y' }`
|
|
1529
|
-
* - Mongo operator: `{ $set: { name: 'x' }, $inc: { views: 1 }, $unset: { tag: '' } }`
|
|
1530
|
-
*
|
|
1531
|
-
* For each operand, runs `bodySanitizer.sanitize('update', ...)` so that
|
|
1532
|
-
* system fields, systemManaged/readonly/immutable rules, AND field-level
|
|
1533
|
-
* write permissions are enforced. Without this, a tenant-scoped user could
|
|
1534
|
-
* pass `{ $set: { organizationId: 'org-b' } }` to move records across orgs.
|
|
1535
|
-
*
|
|
1536
|
-
* Returns the sanitized payload along with the list of stripped fields for
|
|
1537
|
-
* audit/error reporting.
|
|
1538
|
-
*/
|
|
1539
|
-
private sanitizeBulkUpdateData;
|
|
1540
|
-
bulkUpdate(req: IRequestContext): Promise<IControllerResponse<{
|
|
1541
|
-
matchedCount: number;
|
|
1542
|
-
modifiedCount: number;
|
|
1543
|
-
}>>;
|
|
1544
|
-
/**
|
|
1545
|
-
* Bulk delete by `filter` or `ids`.
|
|
1546
|
-
*
|
|
1547
|
-
* Body shape (one of):
|
|
1548
|
-
* - `{ filter: { status: 'archived' } }` — delete by query filter
|
|
1549
|
-
* - `{ ids: ['id1', 'id2', 'id3'] }` — delete specific docs by id
|
|
1550
|
-
*
|
|
1551
|
-
* The `ids` form translates to `{ [idField]: { $in: ids } }` using the
|
|
1552
|
-
* resource's `idField` (so it works with custom PKs like `slug`, `jobId`,
|
|
1553
|
-
* UUID, etc.). Tenant scope and policy filters are merged in either way,
|
|
1554
|
-
* so cross-tenant deletes are blocked at the controller layer.
|
|
1555
|
-
*
|
|
1556
|
-
* Both forms perform a single `repo.deleteMany()` DB call — no per-doc
|
|
1557
|
-
* fetch loop. Per-doc lifecycle hooks (`before:delete`/`after:delete`) do
|
|
1558
|
-
* NOT fire for bulk operations; use the single-doc `delete()` if you need
|
|
1559
|
-
* them, or subscribe to the bulk lifecycle event from the events plugin.
|
|
1560
|
-
*/
|
|
1561
|
-
bulkDelete(req: IRequestContext): Promise<IControllerResponse<{
|
|
1562
|
-
deletedCount: number;
|
|
1563
|
-
}>>;
|
|
1564
|
-
}
|
|
1565
|
-
//#endregion
|
|
1566
|
-
//#region src/types/index.d.ts
|
|
1567
|
-
declare module 'fastify' {
|
|
1568
|
-
interface FastifyRequest {
|
|
1569
|
-
/** Request scope — set by auth adapter, read by permissions/presets/guards */
|
|
1570
|
-
scope: RequestScope;
|
|
1571
|
-
/**
|
|
1572
|
-
* Current user — set by auth adapter (Better Auth, JWT, custom).
|
|
1573
|
-
* `undefined` on public routes (`auth: false`) or unauthenticated requests.
|
|
1574
|
-
* Guard with `if (request.user)` on routes that allow anonymous access.
|
|
1575
|
-
*
|
|
1576
|
-
* Note: kept as required (not `user?`) because `@fastify/jwt` declares it
|
|
1577
|
-
* as required — declaration merges must have identical modifiers.
|
|
1578
|
-
* The `| undefined` in the type achieves the same DX: TypeScript will
|
|
1579
|
-
* flag unguarded access like `request.user.id` as possibly undefined.
|
|
1580
|
-
*/
|
|
1581
|
-
user: Record<string, unknown> | undefined;
|
|
1582
|
-
/** Policy-injected query filters (e.g. ownership, org-scoping) */
|
|
1583
|
-
_policyFilters?: Record<string, unknown>;
|
|
1584
|
-
/** Field mask — fields to include/exclude in responses */
|
|
1585
|
-
fieldMask?: {
|
|
1586
|
-
include?: string[];
|
|
1587
|
-
exclude?: string[];
|
|
1588
|
-
};
|
|
1589
|
-
/** Arbitrary policy metadata for downstream consumers */
|
|
1590
|
-
policyMetadata?: Record<string, unknown>;
|
|
1591
|
-
/** Document loaded by policy middleware for ownership checks */
|
|
1592
|
-
document?: unknown;
|
|
1593
|
-
/** Ownership check context (field name + user field) */
|
|
1594
|
-
_ownershipCheck?: Record<string, unknown>;
|
|
1595
|
-
}
|
|
1596
|
-
}
|
|
1597
|
-
/**
|
|
1598
|
-
* Typed Fastify request with Arc decorations.
|
|
863
|
+
* **Generic parameters:**
|
|
864
|
+
* - `TResponse` — shape of `IControllerResponse.data` (default: `unknown`)
|
|
865
|
+
* - `TBody` — shape of `req.body` (default: `unknown`)
|
|
866
|
+
* - `TParams` — shape of `req.params` (default: `Record<string, string>`)
|
|
867
|
+
* - `TQuery` — shape of `req.query` (default: `Record<string, unknown>`)
|
|
1599
868
|
*
|
|
1600
|
-
*
|
|
869
|
+
* Backward-compatible: `ControllerHandler<Product>` still works (only the
|
|
870
|
+
* response data is typed); add more generics as needed when you want
|
|
871
|
+
* type-safe access to the request body, params, or query string.
|
|
1601
872
|
*
|
|
1602
873
|
* @example
|
|
1603
874
|
* ```typescript
|
|
1604
|
-
*
|
|
875
|
+
* // Untyped req — body is unknown, must be narrowed
|
|
876
|
+
* const createProduct: ControllerHandler<Product> = async (req) => {
|
|
877
|
+
* const product = await productRepo.create(req.body as Partial<Product>);
|
|
878
|
+
* return { success: true, data: product, status: 201 };
|
|
879
|
+
* };
|
|
1605
880
|
*
|
|
1606
|
-
*
|
|
1607
|
-
*
|
|
1608
|
-
*
|
|
1609
|
-
*
|
|
1610
|
-
* }
|
|
881
|
+
* // Fully typed — body, params, query, and response all inferred
|
|
882
|
+
* const updateProduct: ControllerHandler<
|
|
883
|
+
* Product,
|
|
884
|
+
* Partial<Product>,
|
|
885
|
+
* { id: string },
|
|
886
|
+
* { upsert?: string }
|
|
887
|
+
* > = async (req) => {
|
|
888
|
+
* const upsert = req.query.upsert === "true";
|
|
889
|
+
* const product = await productRepo.update(req.params.id, req.body, { upsert });
|
|
890
|
+
* return { success: true, data: product };
|
|
891
|
+
* };
|
|
892
|
+
*
|
|
893
|
+
* routes: [{
|
|
894
|
+
* method: 'POST',
|
|
895
|
+
* path: '/products',
|
|
896
|
+
* handler: createProduct,
|
|
897
|
+
* permissions: requireAuth(),
|
|
898
|
+
* raw: false, // Arc wraps this into Fastify handler
|
|
899
|
+
* }]
|
|
1611
900
|
* ```
|
|
1612
901
|
*/
|
|
1613
|
-
type
|
|
1614
|
-
scope: RequestScope;
|
|
1615
|
-
user: Record<string, unknown> | undefined;
|
|
1616
|
-
signal: AbortSignal;
|
|
1617
|
-
};
|
|
902
|
+
type ControllerHandler<TResponse = unknown, TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>> = (req: IRequestContext<TBody, TParams, TQuery>) => Promise<IControllerResponse<TResponse>>;
|
|
1618
903
|
/**
|
|
1619
|
-
*
|
|
904
|
+
* Fastify native handler
|
|
905
|
+
*
|
|
906
|
+
* Standard Fastify request/reply pattern.
|
|
907
|
+
* Use with `raw: true` in routes.
|
|
1620
908
|
*
|
|
1621
909
|
* @example
|
|
1622
910
|
* ```typescript
|
|
1623
|
-
*
|
|
911
|
+
* const downloadFile: FastifyHandler = async (request, reply) => {
|
|
912
|
+
* const file = await getFile(request.params.id);
|
|
913
|
+
* reply.header('Content-Type', file.mimeType);
|
|
914
|
+
* return reply.send(file.buffer);
|
|
915
|
+
* };
|
|
1624
916
|
*
|
|
1625
|
-
*
|
|
1626
|
-
*
|
|
1627
|
-
*
|
|
1628
|
-
*
|
|
1629
|
-
*
|
|
917
|
+
* routes: [{
|
|
918
|
+
* method: 'GET',
|
|
919
|
+
* path: '/files/:id/download',
|
|
920
|
+
* handler: downloadFile,
|
|
921
|
+
* permissions: requireAuth(),
|
|
922
|
+
* raw: true, // Use as-is, no wrapping
|
|
923
|
+
* }]
|
|
1630
924
|
* ```
|
|
1631
925
|
*/
|
|
1632
|
-
|
|
1633
|
-
success: true;
|
|
1634
|
-
data: T;
|
|
1635
|
-
[key: string]: unknown;
|
|
1636
|
-
};
|
|
1637
|
-
type AnyRecord = Record<string, unknown>;
|
|
1638
|
-
/** MongoDB ObjectId — accepts string or any object with a `toString()` (e.g. mongoose ObjectId). */
|
|
1639
|
-
type ObjectId = string | {
|
|
1640
|
-
toString(): string;
|
|
1641
|
-
};
|
|
926
|
+
type FastifyHandler<RouteGeneric extends Record<string, unknown> = Record<string, unknown>> = (request: FastifyRequest<RouteGeneric>, reply: FastifyReply) => Promise<unknown> | unknown;
|
|
1642
927
|
/**
|
|
1643
|
-
*
|
|
1644
|
-
* Use this instead of `any` when dealing with user objects.
|
|
1645
|
-
* Re-exports UserBase from permissions module for convenience.
|
|
1646
|
-
* The actual user structure is defined by your app's auth system.
|
|
928
|
+
* Union type for route handlers
|
|
1647
929
|
*/
|
|
1648
|
-
type
|
|
1649
|
-
/** User email (optional) */email?: string;
|
|
1650
|
-
};
|
|
930
|
+
type RouteHandler = ControllerHandler | FastifyHandler;
|
|
1651
931
|
/**
|
|
1652
|
-
*
|
|
932
|
+
* Controller interface for CRUD operations (strict)
|
|
1653
933
|
*/
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
organizationId: string;
|
|
1666
|
-
[key: string]: unknown;
|
|
1667
|
-
}
|
|
1668
|
-
interface JWTPayload {
|
|
1669
|
-
sub: string;
|
|
1670
|
-
[key: string]: unknown;
|
|
934
|
+
interface IController<TDoc = unknown> {
|
|
935
|
+
list(req: IRequestContext): Promise<IControllerResponse<{
|
|
936
|
+
docs: TDoc[];
|
|
937
|
+
total: number;
|
|
938
|
+
}>>;
|
|
939
|
+
get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
940
|
+
create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
941
|
+
update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
942
|
+
delete(req: IRequestContext): Promise<IControllerResponse<{
|
|
943
|
+
message: string;
|
|
944
|
+
}>>;
|
|
1671
945
|
}
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
946
|
+
/**
|
|
947
|
+
* Flexible controller interface - accepts controllers with any handler style
|
|
948
|
+
* Use this when your controller uses Fastify native handlers
|
|
949
|
+
*/
|
|
950
|
+
interface ControllerLike {
|
|
951
|
+
list?: unknown;
|
|
952
|
+
get?: unknown;
|
|
953
|
+
create?: unknown;
|
|
954
|
+
update?: unknown;
|
|
955
|
+
delete?: unknown;
|
|
1676
956
|
[key: string]: unknown;
|
|
1677
957
|
}
|
|
958
|
+
//#endregion
|
|
959
|
+
//#region src/types/resource.d.ts
|
|
960
|
+
/** Standard controller type alias for CRUD operations. */
|
|
961
|
+
type CrudController<TDoc> = IController<TDoc>;
|
|
1678
962
|
/**
|
|
1679
|
-
*
|
|
1680
|
-
*
|
|
1681
|
-
*
|
|
963
|
+
* Per-resource cache configuration for QueryCache. Enables
|
|
964
|
+
* stale-while-revalidate, auto-invalidation on mutations, and
|
|
965
|
+
* cross-resource tag-based invalidation.
|
|
1682
966
|
*/
|
|
1683
|
-
interface
|
|
1684
|
-
/**
|
|
1685
|
-
|
|
1686
|
-
/**
|
|
1687
|
-
|
|
1688
|
-
/**
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
967
|
+
interface ResourceCacheConfig {
|
|
968
|
+
/** Seconds data is "fresh" (no revalidation). Default: 0 */
|
|
969
|
+
staleTime?: number;
|
|
970
|
+
/** Seconds stale data stays cached (SWR window). Default: 60 */
|
|
971
|
+
gcTime?: number;
|
|
972
|
+
/** Per-operation overrides */
|
|
973
|
+
list?: {
|
|
974
|
+
staleTime?: number;
|
|
975
|
+
gcTime?: number;
|
|
1692
976
|
};
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
fields?: FieldPermissionMap;
|
|
1697
|
-
[key: string]: unknown;
|
|
977
|
+
byId?: {
|
|
978
|
+
staleTime?: number;
|
|
979
|
+
gcTime?: number;
|
|
1698
980
|
};
|
|
981
|
+
/** Tags for cross-resource invalidation grouping */
|
|
982
|
+
tags?: string[];
|
|
983
|
+
/**
|
|
984
|
+
* Cross-resource invalidation: event pattern → tag targets.
|
|
985
|
+
* @example { 'category.*': ['catalog'] }
|
|
986
|
+
*/
|
|
987
|
+
invalidateOn?: Record<string, string[]>;
|
|
988
|
+
/** Disable caching for this resource */
|
|
989
|
+
disabled?: boolean;
|
|
1699
990
|
}
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
*/
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
991
|
+
interface RateLimitConfig {
|
|
992
|
+
/** Maximum number of requests allowed within the time window */
|
|
993
|
+
max: number;
|
|
994
|
+
/** Time window for rate limiting (e.g., '1 minute', '15 seconds') */
|
|
995
|
+
timeWindow: string;
|
|
996
|
+
}
|
|
997
|
+
interface RouteSchemaOptions {
|
|
998
|
+
hiddenFields?: string[];
|
|
999
|
+
readonlyFields?: string[];
|
|
1000
|
+
requiredFields?: string[];
|
|
1001
|
+
optionalFields?: string[];
|
|
1002
|
+
excludeFields?: string[];
|
|
1710
1003
|
/**
|
|
1711
|
-
*
|
|
1712
|
-
*
|
|
1004
|
+
* Fields allowed for filtering in list operations. MCP auto-derives
|
|
1005
|
+
* from `QueryParser.allowedFilterFields` when not set explicitly.
|
|
1713
1006
|
*/
|
|
1714
|
-
|
|
1007
|
+
filterableFields?: string[];
|
|
1008
|
+
fieldRules?: Record<string, {
|
|
1009
|
+
systemManaged?: boolean;
|
|
1010
|
+
hidden?: boolean;
|
|
1011
|
+
immutable?: boolean;
|
|
1012
|
+
immutableAfterCreate?: boolean;
|
|
1013
|
+
optional?: boolean; /** String minimum length — auto-maps to OpenAPI `minLength` and MCP tool schema */
|
|
1014
|
+
minLength?: number; /** String maximum length — auto-maps to OpenAPI `maxLength` and MCP tool schema */
|
|
1015
|
+
maxLength?: number; /** Number minimum — auto-maps to OpenAPI `minimum` and MCP tool schema */
|
|
1016
|
+
min?: number; /** Number maximum — auto-maps to OpenAPI `maximum` and MCP tool schema */
|
|
1017
|
+
max?: number; /** Regex pattern — auto-maps to OpenAPI `pattern` and MCP tool schema */
|
|
1018
|
+
pattern?: string; /** Allowed values — auto-maps to OpenAPI `enum` and MCP tool schema */
|
|
1019
|
+
enum?: ReadonlyArray<string | number>; /** Human-readable description — auto-maps to OpenAPI `description` */
|
|
1020
|
+
description?: string;
|
|
1021
|
+
[key: string]: unknown;
|
|
1022
|
+
}>;
|
|
1023
|
+
/** Query parameter schema for OpenAPI */
|
|
1024
|
+
query?: Record<string, unknown>;
|
|
1715
1025
|
/**
|
|
1716
|
-
*
|
|
1717
|
-
*
|
|
1718
|
-
*
|
|
1719
|
-
*
|
|
1720
|
-
*
|
|
1721
|
-
*
|
|
1026
|
+
* When `true`, emitted CRUD body schemas set `additionalProperties: false`
|
|
1027
|
+
* so AJV rejects unknown fields on create / update. Honored by kit schema
|
|
1028
|
+
* generators that receive this options bag (sqlitekit's
|
|
1029
|
+
* `buildCrudSchemasFromTable`, pgkit's equivalent). Mongoose-based
|
|
1030
|
+
* generators may ignore it — Mongoose schemas are inherently strict at
|
|
1031
|
+
* the model level.
|
|
1722
1032
|
*/
|
|
1723
|
-
|
|
1724
|
-
select?: string | string[] | Record<string, 0 | 1>;
|
|
1725
|
-
filters?: Record<string, unknown>;
|
|
1726
|
-
search?: string;
|
|
1727
|
-
lean?: boolean;
|
|
1728
|
-
after?: string;
|
|
1729
|
-
user?: unknown;
|
|
1730
|
-
context?: Record<string, unknown>;
|
|
1731
|
-
/** Allow additional options */
|
|
1732
|
-
[key: string]: unknown;
|
|
1033
|
+
strictAdditionalProperties?: boolean;
|
|
1733
1034
|
}
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
* Future adapters would map to SQL JOINs or Prisma includes.
|
|
1740
|
-
*/
|
|
1741
|
-
interface LookupOption {
|
|
1742
|
-
/** Source collection/table to join from */
|
|
1743
|
-
from: string;
|
|
1744
|
-
/** Local field to match on */
|
|
1745
|
-
localField: string;
|
|
1746
|
-
/** Foreign field to match on */
|
|
1747
|
-
foreignField: string;
|
|
1748
|
-
/** Alias for the joined data (defaults to the lookup key) */
|
|
1749
|
-
as?: string;
|
|
1750
|
-
/** Return a single object instead of array (default: false) */
|
|
1751
|
-
single?: boolean;
|
|
1752
|
-
/** Field selection on the joined collection (comma-separated string or projection object) */
|
|
1753
|
-
select?: string | Record<string, 0 | 1>;
|
|
1035
|
+
interface FieldRule {
|
|
1036
|
+
field: string;
|
|
1037
|
+
required?: boolean;
|
|
1038
|
+
readonly?: boolean;
|
|
1039
|
+
hidden?: boolean;
|
|
1754
1040
|
}
|
|
1755
1041
|
/**
|
|
1756
|
-
*
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
1759
|
-
*
|
|
1760
|
-
* ```typescript
|
|
1761
|
-
* // URL: ?populate[author][select]=name,email
|
|
1762
|
-
* // Generates: { path: 'author', select: 'name email' }
|
|
1763
|
-
* ```
|
|
1042
|
+
* CRUD route schemas (Fastify native format). Each slot accepts a plain
|
|
1043
|
+
* JSON Schema object **or** a Zod v4 schema — Arc's `convertRouteSchema`
|
|
1044
|
+
* feature-detects at runtime. Slot values are typed `unknown` so
|
|
1045
|
+
* class-based Zod schemas assign without casts.
|
|
1764
1046
|
*/
|
|
1765
|
-
interface
|
|
1766
|
-
/**
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
/**
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1047
|
+
interface CrudSchemas {
|
|
1048
|
+
/** GET / — list */
|
|
1049
|
+
list?: {
|
|
1050
|
+
querystring?: unknown;
|
|
1051
|
+
response?: Record<number, unknown>;
|
|
1052
|
+
[key: string]: unknown;
|
|
1053
|
+
};
|
|
1054
|
+
/** GET /:id — get one */
|
|
1055
|
+
get?: {
|
|
1056
|
+
params?: unknown;
|
|
1057
|
+
response?: Record<number, unknown>;
|
|
1058
|
+
[key: string]: unknown;
|
|
1059
|
+
};
|
|
1060
|
+
/** POST / — create */
|
|
1061
|
+
create?: {
|
|
1062
|
+
body?: unknown;
|
|
1063
|
+
response?: Record<number, unknown>;
|
|
1064
|
+
[key: string]: unknown;
|
|
1777
1065
|
};
|
|
1778
|
-
/**
|
|
1779
|
-
|
|
1066
|
+
/** PATCH /:id — update */
|
|
1067
|
+
update?: {
|
|
1068
|
+
params?: unknown;
|
|
1069
|
+
body?: unknown;
|
|
1070
|
+
response?: Record<number, unknown>;
|
|
1071
|
+
[key: string]: unknown;
|
|
1072
|
+
};
|
|
1073
|
+
/** DELETE /:id — delete */
|
|
1074
|
+
delete?: {
|
|
1075
|
+
params?: unknown;
|
|
1076
|
+
response?: Record<number, unknown>;
|
|
1077
|
+
[key: string]: unknown;
|
|
1078
|
+
};
|
|
1079
|
+
[key: string]: unknown;
|
|
1780
1080
|
}
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
*/
|
|
1788
|
-
interface ParsedQuery {
|
|
1789
|
-
filters?: Record<string, unknown>;
|
|
1790
|
-
limit?: number;
|
|
1791
|
-
sort?: string | Record<string, 1 | -1>;
|
|
1792
|
-
/** Simple populate (comma-separated string or array) */
|
|
1793
|
-
populate?: string | string[] | Record<string, unknown>;
|
|
1794
|
-
/**
|
|
1795
|
-
* Advanced populate options (Mongoose-compatible)
|
|
1796
|
-
* When set, takes precedence over simple `populate`
|
|
1797
|
-
* @example [{ path: 'author', select: 'name email' }]
|
|
1798
|
-
*/
|
|
1799
|
-
populateOptions?: PopulateOption[];
|
|
1081
|
+
interface OpenApiSchemas {
|
|
1082
|
+
entity?: unknown;
|
|
1083
|
+
createBody?: unknown;
|
|
1084
|
+
updateBody?: unknown;
|
|
1085
|
+
params?: unknown;
|
|
1086
|
+
listQuery?: unknown;
|
|
1800
1087
|
/**
|
|
1801
|
-
*
|
|
1802
|
-
*
|
|
1088
|
+
* Explicit response schema for OpenAPI documentation. Auto-generated
|
|
1089
|
+
* from `createBody` if omitted. Does NOT affect Fastify serialization.
|
|
1803
1090
|
*/
|
|
1804
|
-
|
|
1805
|
-
search?: string;
|
|
1806
|
-
page?: number;
|
|
1807
|
-
after?: string;
|
|
1808
|
-
select?: string | string[] | Record<string, 0 | 1>;
|
|
1809
|
-
/** Allow additional fields from custom query parsers */
|
|
1091
|
+
response?: unknown;
|
|
1810
1092
|
[key: string]: unknown;
|
|
1811
1093
|
}
|
|
1094
|
+
type CrudRouteKey = "list" | "get" | "create" | "update" | "delete";
|
|
1095
|
+
interface MiddlewareConfig {
|
|
1096
|
+
list?: MiddlewareHandler[];
|
|
1097
|
+
get?: MiddlewareHandler[];
|
|
1098
|
+
create?: MiddlewareHandler[];
|
|
1099
|
+
update?: MiddlewareHandler[];
|
|
1100
|
+
delete?: MiddlewareHandler[];
|
|
1101
|
+
[key: string]: MiddlewareHandler[] | undefined;
|
|
1102
|
+
}
|
|
1103
|
+
/** HTTP methods for custom routes. */
|
|
1104
|
+
type RouteMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
1105
|
+
/** MCP tool configuration for a route or action. */
|
|
1106
|
+
interface RouteMcpConfig {
|
|
1107
|
+
/** Override auto-generated tool description */
|
|
1108
|
+
readonly description?: string;
|
|
1109
|
+
/** MCP tool annotations */
|
|
1110
|
+
readonly annotations?: {
|
|
1111
|
+
readonly readOnlyHint?: boolean;
|
|
1112
|
+
readonly destructiveHint?: boolean;
|
|
1113
|
+
readonly idempotentHint?: boolean;
|
|
1114
|
+
readonly openWorldHint?: boolean;
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1812
1117
|
/**
|
|
1813
|
-
*
|
|
1814
|
-
* Implement this to create custom query parsers
|
|
1118
|
+
* Route definition — single custom-route shape (user-facing + internal).
|
|
1815
1119
|
*
|
|
1816
|
-
*
|
|
1817
|
-
*
|
|
1818
|
-
*
|
|
1819
|
-
* const queryParser = new QueryParser();
|
|
1820
|
-
* ```
|
|
1120
|
+
* - `handler: 'string'` → controller method → full Arc pipeline + MCP tool
|
|
1121
|
+
* - `handler: function` → inline handler → full Arc pipeline + MCP tool
|
|
1122
|
+
* - `raw: true` → raw Fastify handler → no pipeline, no MCP by default
|
|
1821
1123
|
*/
|
|
1822
|
-
interface
|
|
1823
|
-
|
|
1124
|
+
interface RouteDefinition {
|
|
1125
|
+
readonly method: RouteMethod;
|
|
1126
|
+
/** Path relative to resource prefix */
|
|
1127
|
+
readonly path: string;
|
|
1824
1128
|
/**
|
|
1825
|
-
*
|
|
1826
|
-
*
|
|
1129
|
+
* Route handler.
|
|
1130
|
+
* - String: controller method name (Arc pipeline)
|
|
1131
|
+
* - Function without `raw: true`: receives IRequestContext, returns IControllerResponse (Arc pipeline)
|
|
1132
|
+
* - Function with `raw: true`: raw Fastify handler `(request, reply)`
|
|
1827
1133
|
*/
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
required?: string[];
|
|
1832
|
-
};
|
|
1134
|
+
readonly handler: string | ControllerHandler | RouteHandlerMethod | ((request: FastifyRequest<Record<string, unknown>>, reply: FastifyReply) => unknown);
|
|
1135
|
+
/** Permission check — REQUIRED */
|
|
1136
|
+
readonly permissions: PermissionCheck;
|
|
1833
1137
|
/**
|
|
1834
|
-
*
|
|
1835
|
-
*
|
|
1836
|
-
* if `schemaOptions.filterableFields` is not explicitly configured.
|
|
1138
|
+
* Raw mode — bypasses Arc pipeline. Handler receives raw Fastify
|
|
1139
|
+
* request/reply. Default: false.
|
|
1837
1140
|
*/
|
|
1838
|
-
|
|
1141
|
+
readonly raw?: boolean;
|
|
1142
|
+
/** Logical operation name (pipeline keys, MCP tool naming). */
|
|
1143
|
+
readonly operation?: string;
|
|
1144
|
+
/** OpenAPI summary */
|
|
1145
|
+
readonly summary?: string;
|
|
1146
|
+
/** OpenAPI description */
|
|
1147
|
+
readonly description?: string;
|
|
1148
|
+
/** OpenAPI tags */
|
|
1149
|
+
readonly tags?: string[];
|
|
1150
|
+
/** Route-level middleware */
|
|
1151
|
+
readonly preHandler?: RouteHandlerMethod[] | ((fastify: FastifyInstance) => RouteHandlerMethod[]);
|
|
1152
|
+
/** Pre-auth handlers (run before authentication) */
|
|
1153
|
+
readonly preAuth?: RouteHandlerMethod[];
|
|
1154
|
+
/** SSE streaming mode */
|
|
1155
|
+
readonly streamResponse?: boolean;
|
|
1839
1156
|
/**
|
|
1840
|
-
*
|
|
1841
|
-
*
|
|
1842
|
-
*
|
|
1157
|
+
* Fastify route schema. Each slot (`body`, `querystring`, `params`,
|
|
1158
|
+
* `headers`, `response[status]`) accepts a plain JSON Schema object
|
|
1159
|
+
* **or** a Zod v4 schema — Arc auto-converts via `convertRouteSchema`.
|
|
1843
1160
|
*/
|
|
1844
|
-
|
|
1161
|
+
readonly schema?: {
|
|
1162
|
+
body?: unknown;
|
|
1163
|
+
querystring?: unknown;
|
|
1164
|
+
params?: unknown;
|
|
1165
|
+
headers?: unknown;
|
|
1166
|
+
response?: Record<number | string, unknown>;
|
|
1167
|
+
[key: string]: unknown;
|
|
1168
|
+
};
|
|
1845
1169
|
/**
|
|
1846
|
-
*
|
|
1847
|
-
*
|
|
1170
|
+
* MCP tool generation:
|
|
1171
|
+
* - omitted/true: auto-generate (non-raw routes only)
|
|
1172
|
+
* - false: skip MCP
|
|
1173
|
+
* - object: explicit config
|
|
1848
1174
|
*/
|
|
1849
|
-
|
|
1850
|
-
}
|
|
1851
|
-
interface FastifyRequestExtras {
|
|
1852
|
-
user?: Record<string, unknown>;
|
|
1853
|
-
}
|
|
1854
|
-
interface RequestWithExtras extends FastifyRequest {
|
|
1175
|
+
readonly mcp?: boolean | RouteMcpConfig;
|
|
1855
1176
|
/**
|
|
1856
|
-
*
|
|
1857
|
-
*
|
|
1177
|
+
* MCP handler for raw routes — parallel entry point for MCP without
|
|
1178
|
+
* changing the HTTP handler.
|
|
1858
1179
|
*/
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
fieldMask?: {
|
|
1867
|
-
include?: string[];
|
|
1868
|
-
exclude?: string[];
|
|
1869
|
-
};
|
|
1870
|
-
_ownershipCheck?: Record<string, unknown>;
|
|
1871
|
-
}
|
|
1872
|
-
type FastifyWithAuth = FastifyInstance & {
|
|
1873
|
-
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
1874
|
-
};
|
|
1875
|
-
/**
|
|
1876
|
-
* Arc core decorator interface
|
|
1877
|
-
* Added by arcCorePlugin to provide instance-scoped hooks and registry
|
|
1878
|
-
*/
|
|
1879
|
-
interface ArcDecorator {
|
|
1880
|
-
/** Instance-scoped hook system */
|
|
1881
|
-
hooks: HookSystem;
|
|
1882
|
-
/** Instance-scoped resource registry */
|
|
1883
|
-
registry: ResourceRegistry;
|
|
1884
|
-
/** Whether event emission is enabled */
|
|
1885
|
-
emitEvents: boolean;
|
|
1180
|
+
readonly mcpHandler?: (input: Record<string, unknown>) => Promise<{
|
|
1181
|
+
content: Array<{
|
|
1182
|
+
type: string;
|
|
1183
|
+
text: string;
|
|
1184
|
+
}>;
|
|
1185
|
+
isError?: boolean;
|
|
1186
|
+
}>;
|
|
1886
1187
|
}
|
|
1887
1188
|
/**
|
|
1888
|
-
*
|
|
1889
|
-
*
|
|
1189
|
+
* Action handler function for state transitions. Receives the resource
|
|
1190
|
+
* ID, action-specific data, and the request.
|
|
1890
1191
|
*/
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
/**
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1192
|
+
type ActionHandlerFn = (id: string, data: Record<string, unknown>, req: RequestWithExtras) => Promise<unknown>;
|
|
1193
|
+
/** Full action configuration with handler, permissions, and schema. */
|
|
1194
|
+
interface ActionDefinition {
|
|
1195
|
+
readonly handler: ActionHandlerFn;
|
|
1196
|
+
/** Per-action permission (overrides resource-level `actionPermissions`) */
|
|
1197
|
+
readonly permissions?: PermissionCheck;
|
|
1198
|
+
/**
|
|
1199
|
+
* JSON Schema or Zod v4 schema for action-specific body fields.
|
|
1200
|
+
* Per-field values are typed `unknown` so Zod class instances assign
|
|
1201
|
+
* without casts.
|
|
1202
|
+
*/
|
|
1203
|
+
readonly schema?: Record<string, unknown>;
|
|
1204
|
+
/** Description for OpenAPI docs and MCP tool */
|
|
1205
|
+
readonly description?: string;
|
|
1206
|
+
/**
|
|
1207
|
+
* MCP tool generation:
|
|
1208
|
+
* - omitted/true: auto-generate
|
|
1209
|
+
* - false: skip
|
|
1210
|
+
* - object: explicit config
|
|
1211
|
+
*/
|
|
1212
|
+
readonly mcp?: boolean | RouteMcpConfig;
|
|
1901
1213
|
}
|
|
1214
|
+
/** Action config: bare handler function OR full ActionDefinition. */
|
|
1215
|
+
type ActionEntry = ActionHandlerFn | ActionDefinition;
|
|
1216
|
+
/** Actions configuration map. */
|
|
1217
|
+
type ActionsMap = Record<string, ActionEntry>;
|
|
1902
1218
|
/**
|
|
1903
|
-
*
|
|
1904
|
-
*
|
|
1219
|
+
* Hook context passed to resource-level hook handlers. Mirrors
|
|
1220
|
+
* HookSystem's HookContext but with a simpler API for inline use.
|
|
1905
1221
|
*/
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
}) => RouteHandlerMethod;
|
|
1914
|
-
[key: string]: unknown;
|
|
1915
|
-
};
|
|
1916
|
-
interface OwnershipCheck {
|
|
1917
|
-
field: string;
|
|
1918
|
-
userField?: string;
|
|
1222
|
+
interface ResourceHookContext {
|
|
1223
|
+
/** The document data (create/update body, or existing doc for delete) */
|
|
1224
|
+
data: AnyRecord;
|
|
1225
|
+
/** Authenticated user or null */
|
|
1226
|
+
user?: UserBase;
|
|
1227
|
+
/** Additional metadata (e.g. `{ id, existing }` for update/delete) */
|
|
1228
|
+
meta?: AnyRecord;
|
|
1919
1229
|
}
|
|
1920
1230
|
/**
|
|
1921
|
-
*
|
|
1922
|
-
*
|
|
1923
|
-
* Applied to all routes of the resource when `@fastify/rate-limit` is registered
|
|
1924
|
-
* on the Fastify instance. Set to `false` to explicitly disable rate limiting
|
|
1925
|
-
* for a resource even when a global rate limit is configured.
|
|
1231
|
+
* Inline lifecycle hooks on a resource definition. Wired into the
|
|
1232
|
+
* HookSystem automatically — same pipeline as presets and app-level hooks.
|
|
1926
1233
|
*
|
|
1927
1234
|
* @example
|
|
1928
1235
|
* ```typescript
|
|
1929
1236
|
* defineResource({
|
|
1930
|
-
* name: '
|
|
1931
|
-
*
|
|
1237
|
+
* name: 'chat',
|
|
1238
|
+
* hooks: {
|
|
1239
|
+
* afterCreate: async (ctx) => { analytics.track('chat.created', { id: ctx.data._id }); },
|
|
1240
|
+
* beforeDelete: async (ctx) => {
|
|
1241
|
+
* if (ctx.data.isProtected) throw new Error('Cannot delete protected chat');
|
|
1242
|
+
* },
|
|
1243
|
+
* },
|
|
1932
1244
|
* });
|
|
1933
1245
|
* ```
|
|
1934
1246
|
*/
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
staleTime?: number;
|
|
1943
|
-
/** Seconds stale data stays cached (SWR window). Default: 60 */
|
|
1944
|
-
gcTime?: number;
|
|
1945
|
-
/** Per-operation overrides */
|
|
1946
|
-
list?: {
|
|
1947
|
-
staleTime?: number;
|
|
1948
|
-
gcTime?: number;
|
|
1949
|
-
};
|
|
1950
|
-
byId?: {
|
|
1951
|
-
staleTime?: number;
|
|
1952
|
-
gcTime?: number;
|
|
1953
|
-
};
|
|
1954
|
-
/** Tags for cross-resource invalidation grouping */
|
|
1955
|
-
tags?: string[];
|
|
1956
|
-
/**
|
|
1957
|
-
* Cross-resource invalidation: event pattern → tag targets.
|
|
1958
|
-
* When matched event fires, all caches with those tags are invalidated.
|
|
1959
|
-
* @example { 'category.*': ['catalog'] }
|
|
1960
|
-
*/
|
|
1961
|
-
invalidateOn?: Record<string, string[]>;
|
|
1962
|
-
/** Disable caching for this resource */
|
|
1963
|
-
disabled?: boolean;
|
|
1247
|
+
interface ResourceHooks {
|
|
1248
|
+
beforeCreate?: (ctx: ResourceHookContext) => Promise<AnyRecord | void> | AnyRecord | void;
|
|
1249
|
+
afterCreate?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
1250
|
+
beforeUpdate?: (ctx: ResourceHookContext) => Promise<AnyRecord | void> | AnyRecord | void;
|
|
1251
|
+
afterUpdate?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
1252
|
+
beforeDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
1253
|
+
afterDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
1964
1254
|
}
|
|
1965
|
-
interface
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1255
|
+
interface PresetHook {
|
|
1256
|
+
operation: "create" | "update" | "delete" | "read" | "list";
|
|
1257
|
+
phase: "before" | "after";
|
|
1258
|
+
handler: (ctx: AnyRecord) => void | Promise<void> | AnyRecord | Promise<AnyRecord>;
|
|
1259
|
+
priority?: number;
|
|
1260
|
+
}
|
|
1261
|
+
interface PresetResult {
|
|
1262
|
+
name: string;
|
|
1263
|
+
/** Preset routes — merged into the resource's `routes` array. */
|
|
1264
|
+
routes?: RouteDefinition[] | ((permissions: ResourcePermissions) => RouteDefinition[]);
|
|
1265
|
+
middlewares?: MiddlewareConfig;
|
|
1266
|
+
schemaOptions?: RouteSchemaOptions;
|
|
1267
|
+
controllerOptions?: Record<string, unknown>;
|
|
1268
|
+
hooks?: PresetHook[];
|
|
1269
|
+
}
|
|
1270
|
+
type PresetFunction = (config: ResourceConfig) => PresetResult;
|
|
1271
|
+
interface EventDefinition {
|
|
1272
|
+
name: string;
|
|
1273
|
+
/** Optional handler — events are published via `fastify.events.publish()`. */
|
|
1274
|
+
handler?: (data: unknown) => Promise<void> | void;
|
|
1275
|
+
/** JSON schema for event payload */
|
|
1276
|
+
schema?: Record<string, unknown>;
|
|
1277
|
+
description?: string;
|
|
1278
|
+
}
|
|
1279
|
+
/** Resource-level permissions — only `PermissionCheck` functions allowed. */
|
|
1280
|
+
interface ResourcePermissions {
|
|
1281
|
+
list?: PermissionCheck;
|
|
1282
|
+
get?: PermissionCheck;
|
|
1283
|
+
create?: PermissionCheck;
|
|
1284
|
+
update?: PermissionCheck;
|
|
1285
|
+
delete?: PermissionCheck;
|
|
1970
1286
|
}
|
|
1971
1287
|
interface ResourceConfig<TDoc = AnyRecord> {
|
|
1972
1288
|
name: string;
|
|
1973
1289
|
displayName?: string;
|
|
1974
1290
|
tag?: string;
|
|
1291
|
+
/** Defaults to `/${name}s` if not provided. */
|
|
1975
1292
|
prefix?: string;
|
|
1976
1293
|
/**
|
|
1977
|
-
* Skip the global `resourcePrefix` from `createApp()`.
|
|
1978
|
-
*
|
|
1979
|
-
* Useful for webhooks, health, admin routes that shouldn't be under
|
|
1294
|
+
* Skip the global `resourcePrefix` from `createApp()`. The resource
|
|
1295
|
+
* registers at its own `prefix` (or `/${name}s`) directly on root.
|
|
1296
|
+
* Useful for webhooks, health, admin routes that shouldn't be under
|
|
1297
|
+
* `/api/v1`.
|
|
1980
1298
|
*
|
|
1981
1299
|
* @example
|
|
1982
1300
|
* ```typescript
|
|
1983
1301
|
* defineResource({ name: 'webhook', prefix: '/webhooks', skipGlobalPrefix: true })
|
|
1984
|
-
* // Registers at /webhooks even when createApp({ resourcePrefix: '/api/v1' })
|
|
1985
1302
|
* ```
|
|
1986
1303
|
*/
|
|
1987
1304
|
skipGlobalPrefix?: boolean;
|
|
1305
|
+
/** Optional for service-pattern resources */
|
|
1988
1306
|
adapter?: DataAdapter<TDoc>;
|
|
1989
|
-
/** Controller instance
|
|
1307
|
+
/** Controller instance — accepts any object with CRUD methods. */
|
|
1990
1308
|
controller?: IController<TDoc> | ControllerLike;
|
|
1991
1309
|
queryParser?: unknown;
|
|
1992
1310
|
permissions?: ResourcePermissions;
|
|
1993
1311
|
schemaOptions?: RouteSchemaOptions;
|
|
1994
1312
|
openApiSchemas?: OpenApiSchemas;
|
|
1313
|
+
/** Custom JSON schemas (override Arc-generated). */
|
|
1995
1314
|
customSchemas?: Partial<CrudSchemas>;
|
|
1315
|
+
/** Preset names, objects, or PresetResult values. */
|
|
1996
1316
|
presets?: Array<string | PresetResult | {
|
|
1997
1317
|
name: string;
|
|
1998
1318
|
[key: string]: unknown;
|
|
1999
1319
|
}>;
|
|
2000
1320
|
hooks?: ResourceHooks;
|
|
2001
1321
|
/**
|
|
2002
|
-
* Functional pipeline — guards, transforms,
|
|
2003
|
-
*
|
|
1322
|
+
* Functional pipeline — guards, transforms, interceptors. Flat array
|
|
1323
|
+
* (all operations) or per-operation map.
|
|
2004
1324
|
*
|
|
2005
1325
|
* @example
|
|
2006
1326
|
* ```typescript
|
|
2007
|
-
*
|
|
2008
|
-
*
|
|
2009
|
-
* resource('product', {
|
|
2010
|
-
* pipe: pipe(isActive, slugify, timing),
|
|
2011
|
-
* // OR per-operation:
|
|
2012
|
-
* pipe: { create: pipe(isActive, slugify), list: pipe(timing) },
|
|
2013
|
-
* });
|
|
1327
|
+
* pipe: pipe(isActive, slugify, timing),
|
|
1328
|
+
* pipe: { create: pipe(isActive, slugify), list: pipe(timing) },
|
|
2014
1329
|
* ```
|
|
2015
1330
|
*/
|
|
2016
1331
|
pipe?: PipelineConfig;
|
|
@@ -2019,7 +1334,6 @@ interface ResourceConfig<TDoc = AnyRecord> {
|
|
|
2019
1334
|
*
|
|
2020
1335
|
* @example
|
|
2021
1336
|
* ```typescript
|
|
2022
|
-
* import { fields } from '@classytic/arc';
|
|
2023
1337
|
* fields: {
|
|
2024
1338
|
* salary: fields.visibleTo(['admin', 'hr']),
|
|
2025
1339
|
* password: fields.hidden(),
|
|
@@ -2027,23 +1341,22 @@ interface ResourceConfig<TDoc = AnyRecord> {
|
|
|
2027
1341
|
* ```
|
|
2028
1342
|
*/
|
|
2029
1343
|
fields?: FieldPermissionMap;
|
|
1344
|
+
/**
|
|
1345
|
+
* Policy for requests that include fields the caller can't write.
|
|
1346
|
+
*
|
|
1347
|
+
* - `'reject'` (default, secure): 403 with the denied field names.
|
|
1348
|
+
* Surfaces misconfigurations and write-side permission violations
|
|
1349
|
+
* instead of silently dropping them.
|
|
1350
|
+
* - `'strip'`: legacy silent-drop behaviour — only opt in when migrating
|
|
1351
|
+
* pre-2.9 code that relied on the permissive default.
|
|
1352
|
+
*/
|
|
1353
|
+
onFieldWriteDenied?: "reject" | "strip";
|
|
2030
1354
|
middlewares?: MiddlewareConfig;
|
|
2031
1355
|
/**
|
|
2032
1356
|
* PreHandler guards auto-applied to **every** route on this resource
|
|
2033
|
-
* (CRUD + custom
|
|
2034
|
-
*
|
|
2035
|
-
*
|
|
2036
|
-
*
|
|
2037
|
-
* @example
|
|
2038
|
-
* ```typescript
|
|
2039
|
-
* defineResource({
|
|
2040
|
-
* routeGuards: [requireFlowMode('standard')],
|
|
2041
|
-
* routes: [
|
|
2042
|
-
* { method: 'GET', path: '/', raw: true, handler: listHandler },
|
|
2043
|
-
* // guard runs automatically — no per-route boilerplate
|
|
2044
|
-
* ],
|
|
2045
|
-
* });
|
|
2046
|
-
* ```
|
|
1357
|
+
* (CRUD + custom + preset). Runs after auth/permissions, before
|
|
1358
|
+
* per-route `preHandler`. Use for mode gates, tenant checks, feature
|
|
1359
|
+
* flags — anything that applies to every endpoint.
|
|
2047
1360
|
*/
|
|
2048
1361
|
routeGuards?: RouteHandlerMethod[];
|
|
2049
1362
|
/**
|
|
@@ -2059,8 +1372,9 @@ interface ResourceConfig<TDoc = AnyRecord> {
|
|
|
2059
1372
|
*/
|
|
2060
1373
|
routes?: RouteDefinition[];
|
|
2061
1374
|
/**
|
|
2062
|
-
* State-transition actions → unified POST /:id/action endpoint.
|
|
2063
|
-
* Each action can be a bare handler or full config with permissions
|
|
1375
|
+
* State-transition actions → unified `POST /:id/action` endpoint.
|
|
1376
|
+
* Each action can be a bare handler or full config with permissions
|
|
1377
|
+
* + schema.
|
|
2064
1378
|
*
|
|
2065
1379
|
* @example
|
|
2066
1380
|
* ```typescript
|
|
@@ -2083,72 +1397,59 @@ interface ResourceConfig<TDoc = AnyRecord> {
|
|
|
2083
1397
|
actionPermissions?: PermissionCheck;
|
|
2084
1398
|
disableCrud?: boolean;
|
|
2085
1399
|
disableDefaultRoutes?: boolean;
|
|
1400
|
+
/** Specific routes to disable */
|
|
2086
1401
|
disabledRoutes?: CrudRouteKey[];
|
|
2087
1402
|
/**
|
|
2088
1403
|
* Field name used for multi-tenant scoping (default: 'organizationId').
|
|
2089
|
-
* Override to match your schema: 'workspaceId', 'tenantId',
|
|
2090
|
-
* Takes effect when org context is present (via multiTenant preset).
|
|
1404
|
+
* Override to match your schema: 'workspaceId', 'tenantId', etc.
|
|
2091
1405
|
*/
|
|
2092
1406
|
tenantField?: string | false;
|
|
2093
1407
|
/**
|
|
2094
1408
|
* Primary key field name (default: '_id').
|
|
2095
1409
|
*
|
|
2096
|
-
* Type-narrowed to `keyof TDoc` when `defineResource<TDoc>` is called
|
|
2097
|
-
* a typed document interface —
|
|
2098
|
-
* while still accepting any string when TDoc is `unknown` /
|
|
2099
|
-
* adapters with dynamic shapes still work.
|
|
1410
|
+
* Type-narrowed to `keyof TDoc` when `defineResource<TDoc>` is called
|
|
1411
|
+
* with a typed document interface — autocomplete for valid field names
|
|
1412
|
+
* — while still accepting any string when TDoc is `unknown` /
|
|
1413
|
+
* `AnyRecord` so adapters with dynamic shapes still work.
|
|
2100
1414
|
*
|
|
2101
1415
|
* @example
|
|
2102
1416
|
* ```ts
|
|
2103
1417
|
* defineResource<IJob>({ idField: 'jobId' }) // ← autocompletes from IJob fields
|
|
2104
1418
|
* defineResource({ idField: 'sku' }) // ← any string allowed
|
|
2105
1419
|
* ```
|
|
2106
|
-
*
|
|
2107
|
-
* Override for non-MongoDB adapters (e.g., 'id' for SQL databases) or
|
|
2108
|
-
* resources keyed by a business identifier (slug, sku, orderNumber).
|
|
2109
1420
|
*/
|
|
2110
1421
|
idField?: (keyof TDoc & string) | (string & {});
|
|
1422
|
+
/** For grouping in registry */
|
|
2111
1423
|
module?: string;
|
|
1424
|
+
/** Domain events */
|
|
2112
1425
|
events?: Record<string, EventDefinition>;
|
|
1426
|
+
/** Skip schema validation */
|
|
2113
1427
|
skipValidation?: boolean;
|
|
1428
|
+
/** Don't register in introspection */
|
|
2114
1429
|
skipRegistry?: boolean;
|
|
1430
|
+
/** Internal: track applied presets */
|
|
2115
1431
|
_appliedPresets?: string[];
|
|
2116
|
-
/**
|
|
2117
|
-
* Called during plugin registration with the scoped Fastify instance.
|
|
2118
|
-
* Use for wiring singletons, reading decorators, or setting up resource-specific
|
|
2119
|
-
* services that need access to the Fastify instance.
|
|
2120
|
-
*
|
|
2121
|
-
* @example
|
|
2122
|
-
* ```typescript
|
|
2123
|
-
* defineResource({
|
|
2124
|
-
* name: 'notification',
|
|
2125
|
-
* onRegister: (fastify) => {
|
|
2126
|
-
* setSseManager(fastify.sseManager);
|
|
2127
|
-
* },
|
|
2128
|
-
* })
|
|
2129
|
-
* ```
|
|
2130
|
-
*/
|
|
2131
|
-
onRegister?: (fastify: FastifyInstance) => void | Promise<void>;
|
|
2132
1432
|
/** HTTP method for update routes. Default: 'PATCH' */
|
|
2133
|
-
updateMethod?:
|
|
1433
|
+
updateMethod?: "PUT" | "PATCH" | "both";
|
|
2134
1434
|
/**
|
|
2135
|
-
* Per-resource rate limiting.
|
|
2136
|
-
*
|
|
2137
|
-
* Set to `false` to disable rate limiting for this resource.
|
|
1435
|
+
* Per-resource rate limiting. Requires `@fastify/rate-limit` to be
|
|
1436
|
+
* registered. Set to `false` to disable for this resource.
|
|
2138
1437
|
*/
|
|
2139
1438
|
rateLimit?: RateLimitConfig | false;
|
|
2140
1439
|
/**
|
|
2141
|
-
* QueryCache configuration for this resource.
|
|
2142
|
-
*
|
|
2143
|
-
*
|
|
1440
|
+
* QueryCache configuration for this resource. Enables
|
|
1441
|
+
* stale-while-revalidate and auto-invalidation. Requires
|
|
1442
|
+
* `queryCachePlugin` to be registered.
|
|
2144
1443
|
*/
|
|
2145
1444
|
cache?: ResourceCacheConfig;
|
|
2146
1445
|
/**
|
|
2147
1446
|
* Per-resource audit opt-in. When `auditPlugin` is registered with
|
|
2148
|
-
* `autoAudit: { perResource: true }`, only resources with this flag
|
|
1447
|
+
* `autoAudit: { perResource: true }`, only resources with this flag
|
|
1448
|
+
* are audited.
|
|
2149
1449
|
*
|
|
2150
|
-
* The cleanest pattern for apps where most resources don't need
|
|
2151
|
-
* no growing exclude lists, no centralized allowlist to
|
|
1450
|
+
* The cleanest pattern for apps where most resources don't need
|
|
1451
|
+
* auditing — no growing exclude lists, no centralized allowlist to
|
|
1452
|
+
* maintain.
|
|
2152
1453
|
*
|
|
2153
1454
|
* - `true`: Audit create/update/delete on this resource
|
|
2154
1455
|
* - `{ operations: ['delete'] }`: Audit only specific operations
|
|
@@ -2156,15 +1457,8 @@ interface ResourceConfig<TDoc = AnyRecord> {
|
|
|
2156
1457
|
*
|
|
2157
1458
|
* @example
|
|
2158
1459
|
* ```ts
|
|
2159
|
-
*
|
|
2160
|
-
* await fastify.register(auditPlugin, {
|
|
2161
|
-
* autoAudit: { perResource: true },
|
|
2162
|
-
* });
|
|
2163
|
-
*
|
|
2164
|
-
* // order.resource.ts
|
|
1460
|
+
* await fastify.register(auditPlugin, { autoAudit: { perResource: true } });
|
|
2165
1461
|
* defineResource({ name: 'order', audit: true });
|
|
2166
|
-
*
|
|
2167
|
-
* // payment.resource.ts
|
|
2168
1462
|
* defineResource({ name: 'payment', audit: { operations: ['delete'] } });
|
|
2169
1463
|
* ```
|
|
2170
1464
|
*/
|
|
@@ -2172,450 +1466,226 @@ interface ResourceConfig<TDoc = AnyRecord> {
|
|
|
2172
1466
|
operations?: ("create" | "update" | "delete")[];
|
|
2173
1467
|
};
|
|
2174
1468
|
}
|
|
1469
|
+
//#endregion
|
|
1470
|
+
//#region src/core/defineResource.d.ts
|
|
2175
1471
|
/**
|
|
2176
|
-
*
|
|
2177
|
-
* ONLY PermissionCheck functions allowed - no string arrays
|
|
2178
|
-
*/
|
|
2179
|
-
interface ResourcePermissions {
|
|
2180
|
-
list?: PermissionCheck;
|
|
2181
|
-
get?: PermissionCheck;
|
|
2182
|
-
create?: PermissionCheck;
|
|
2183
|
-
update?: PermissionCheck;
|
|
2184
|
-
delete?: PermissionCheck;
|
|
2185
|
-
}
|
|
2186
|
-
/**
|
|
2187
|
-
* Hook context passed to resource-level hook handlers.
|
|
2188
|
-
* Mirrors HookSystem's HookContext but with a simpler API for inline use.
|
|
2189
|
-
*/
|
|
2190
|
-
interface ResourceHookContext {
|
|
2191
|
-
/** The document data (create/update body, or existing doc for delete) */
|
|
2192
|
-
data: AnyRecord;
|
|
2193
|
-
/** Authenticated user or null */
|
|
2194
|
-
user?: UserBase;
|
|
2195
|
-
/** Additional metadata (e.g. `{ id, existing }` for update/delete) */
|
|
2196
|
-
meta?: AnyRecord;
|
|
2197
|
-
}
|
|
2198
|
-
/**
|
|
2199
|
-
* Inline lifecycle hooks on a resource definition.
|
|
2200
|
-
* These are wired into the HookSystem automatically — same pipeline as presets and app-level hooks.
|
|
1472
|
+
* Define a resource with database adapter
|
|
2201
1473
|
*
|
|
2202
|
-
*
|
|
2203
|
-
*
|
|
2204
|
-
* defineResource({
|
|
2205
|
-
* name: 'chat',
|
|
2206
|
-
* hooks: {
|
|
2207
|
-
* afterCreate: async (ctx) => {
|
|
2208
|
-
* analytics.track('chat.created', { chatId: ctx.data._id, userId: ctx.user?.id });
|
|
2209
|
-
* },
|
|
2210
|
-
* beforeDelete: async (ctx) => {
|
|
2211
|
-
* if (ctx.data.isProtected) throw new Error('Cannot delete protected chat');
|
|
2212
|
-
* },
|
|
2213
|
-
* afterDelete: async (ctx) => {
|
|
2214
|
-
* await notificationService.send('chat.deleted', { id: ctx.meta?.id });
|
|
2215
|
-
* },
|
|
2216
|
-
* },
|
|
2217
|
-
* });
|
|
2218
|
-
* ```
|
|
1474
|
+
* This is the MAIN entry point for creating Arc resources.
|
|
1475
|
+
* The adapter provides both repository and schema metadata.
|
|
2219
1476
|
*/
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
1477
|
+
declare function defineResource<TDoc = AnyRecord>(config: ResourceConfig<TDoc>): ResourceDefinition<TDoc>;
|
|
1478
|
+
interface ResolvedResourceConfig<TDoc = AnyRecord> extends ResourceConfig<TDoc> {
|
|
1479
|
+
_appliedPresets?: string[];
|
|
1480
|
+
_controllerOptions?: {
|
|
1481
|
+
slugField?: string;
|
|
1482
|
+
parentField?: string;
|
|
1483
|
+
[key: string]: unknown;
|
|
1484
|
+
};
|
|
1485
|
+
_pendingHooks?: Array<{
|
|
1486
|
+
operation: "create" | "update" | "delete" | "read" | "list";
|
|
1487
|
+
phase: "before" | "after";
|
|
1488
|
+
handler: (ctx: AnyRecord) => unknown;
|
|
1489
|
+
priority: number;
|
|
1490
|
+
}>;
|
|
2233
1491
|
}
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
1492
|
+
declare class ResourceDefinition<TDoc = AnyRecord> {
|
|
1493
|
+
readonly name: string;
|
|
1494
|
+
readonly displayName: string;
|
|
1495
|
+
readonly tag: string;
|
|
1496
|
+
readonly prefix: string;
|
|
1497
|
+
readonly adapter?: DataAdapter<TDoc>;
|
|
1498
|
+
readonly controller?: IController<TDoc>;
|
|
1499
|
+
readonly schemaOptions: RouteSchemaOptions;
|
|
1500
|
+
readonly customSchemas: CrudSchemas;
|
|
1501
|
+
readonly permissions: ResourcePermissions;
|
|
1502
|
+
readonly routes: readonly RouteDefinition[];
|
|
1503
|
+
readonly middlewares: MiddlewareConfig;
|
|
1504
|
+
readonly routeGuards?: RouteHandlerMethod$1[];
|
|
1505
|
+
readonly disableDefaultRoutes: boolean;
|
|
1506
|
+
readonly disabledRoutes: CrudRouteKey[];
|
|
1507
|
+
readonly actions?: ActionsMap;
|
|
1508
|
+
readonly actionPermissions?: PermissionCheck;
|
|
1509
|
+
readonly events: Record<string, EventDefinition>;
|
|
1510
|
+
readonly rateLimit?: RateLimitConfig | false;
|
|
1511
|
+
readonly audit?: boolean | {
|
|
1512
|
+
operations?: ("create" | "update" | "delete")[];
|
|
1513
|
+
};
|
|
1514
|
+
readonly updateMethod?: "PUT" | "PATCH" | "both";
|
|
1515
|
+
readonly pipe?: PipelineConfig;
|
|
1516
|
+
readonly fields?: FieldPermissionMap;
|
|
1517
|
+
readonly cache?: ResourceCacheConfig;
|
|
1518
|
+
readonly skipGlobalPrefix: boolean;
|
|
1519
|
+
readonly tenantField?: string | false;
|
|
1520
|
+
readonly idField?: string;
|
|
1521
|
+
readonly queryParser?: QueryParserInterface;
|
|
1522
|
+
readonly _appliedPresets: string[];
|
|
1523
|
+
_pendingHooks: Array<{
|
|
1524
|
+
operation: "create" | "update" | "delete" | "read" | "list";
|
|
1525
|
+
phase: "before" | "after";
|
|
1526
|
+
handler: (ctx: AnyRecord) => unknown;
|
|
1527
|
+
priority: number;
|
|
1528
|
+
}>;
|
|
1529
|
+
_registryMeta?: RegisterOptions;
|
|
1530
|
+
constructor(config: ResolvedResourceConfig<TDoc>);
|
|
1531
|
+
/** Get repository from adapter (if available) */
|
|
1532
|
+
get repository(): _$_classytic_repo_core_repository0.StandardRepo<TDoc> | RepositoryLike<TDoc> | undefined;
|
|
1533
|
+
_validateControllerMethods(): void;
|
|
1534
|
+
toPlugin(): FastifyPluginAsync;
|
|
2242
1535
|
/**
|
|
2243
|
-
*
|
|
2244
|
-
*
|
|
2245
|
-
* When `wrapHandler: true`:
|
|
2246
|
-
* - `string` — calls controller method by name (e.g., `'approve'`)
|
|
2247
|
-
* - `ControllerHandler` — receives `IRequestContext`, returns `IControllerResponse`
|
|
2248
|
-
*
|
|
2249
|
-
* When `wrapHandler: false`:
|
|
2250
|
-
* - Fastify handler `(request, reply) => unknown`
|
|
1536
|
+
* Get event definitions for registry
|
|
2251
1537
|
*/
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
1538
|
+
getEvents(): Array<{
|
|
1539
|
+
name: string;
|
|
1540
|
+
module: string;
|
|
1541
|
+
schema?: AnyRecord;
|
|
1542
|
+
description?: string;
|
|
1543
|
+
}>;
|
|
2255
1544
|
/**
|
|
2256
|
-
*
|
|
2257
|
-
* true = ControllerHandler (receives context object)
|
|
2258
|
-
* false = FastifyHandler (receives request, reply)
|
|
1545
|
+
* Get resource metadata
|
|
2259
1546
|
*/
|
|
2260
|
-
|
|
1547
|
+
getMetadata(): ResourceMetadata;
|
|
1548
|
+
}
|
|
1549
|
+
//#endregion
|
|
1550
|
+
//#region src/registry/ResourceRegistry.d.ts
|
|
1551
|
+
interface RegisterOptions {
|
|
1552
|
+
module?: string;
|
|
1553
|
+
/** Pre-generated OpenAPI schemas */
|
|
1554
|
+
openApiSchemas?: OpenApiSchemas;
|
|
1555
|
+
}
|
|
1556
|
+
declare class ResourceRegistry {
|
|
1557
|
+
private _resources;
|
|
1558
|
+
private _frozen;
|
|
1559
|
+
constructor();
|
|
2261
1560
|
/**
|
|
2262
|
-
*
|
|
2263
|
-
* Defaults to handler name (string handlers) or method+path slug.
|
|
2264
|
-
* Prevents collisions when multiple routes share the same HTTP method.
|
|
2265
|
-
*
|
|
2266
|
-
* @example
|
|
2267
|
-
* operation: 'listDeleted' // Used as pipeline key and permission action
|
|
2268
|
-
* operation: 'restore'
|
|
1561
|
+
* Register a resource
|
|
2269
1562
|
*/
|
|
2270
|
-
|
|
2271
|
-
/** OpenAPI summary */
|
|
2272
|
-
summary?: string;
|
|
2273
|
-
/** OpenAPI description */
|
|
2274
|
-
description?: string;
|
|
2275
|
-
/** OpenAPI tags */
|
|
2276
|
-
tags?: string[];
|
|
1563
|
+
register(resource: ResourceDefinition<unknown>, options?: RegisterOptions): this;
|
|
2277
1564
|
/**
|
|
2278
|
-
*
|
|
2279
|
-
* Can be an array of handlers or a function that receives fastify and returns handlers
|
|
2280
|
-
* @example
|
|
2281
|
-
* // Direct array
|
|
2282
|
-
* preHandler: [myMiddleware]
|
|
2283
|
-
* // Function that receives fastify (for accessing decorators)
|
|
2284
|
-
* preHandler: (fastify) => [fastify.customerContext({ required: true })]
|
|
1565
|
+
* Get resource by name
|
|
2285
1566
|
*/
|
|
2286
|
-
|
|
1567
|
+
get(name: string): RegistryEntry | undefined;
|
|
2287
1568
|
/**
|
|
2288
|
-
*
|
|
2289
|
-
* Use for promoting query params to headers (e.g., EventSource ?token= → Authorization).
|
|
2290
|
-
*
|
|
2291
|
-
* @example
|
|
2292
|
-
* ```typescript
|
|
2293
|
-
* preAuth: [(req) => {
|
|
2294
|
-
* const token = (req.query as Record<string, string>)?.token;
|
|
2295
|
-
* if (token) req.headers.authorization = `Bearer ${token}`;
|
|
2296
|
-
* }]
|
|
2297
|
-
* ```
|
|
1569
|
+
* Get all resources
|
|
2298
1570
|
*/
|
|
2299
|
-
|
|
1571
|
+
getAll(): RegistryEntry[];
|
|
2300
1572
|
/**
|
|
2301
|
-
*
|
|
2302
|
-
* When `true`:
|
|
2303
|
-
* - Forces `wrapHandler: false` (no `{ success, data }` wrapper)
|
|
2304
|
-
* - Sets SSE headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`
|
|
2305
|
-
* - `request.signal` (Fastify 5 built-in) is available for abort-on-disconnect
|
|
2306
|
-
*
|
|
2307
|
-
* @example
|
|
2308
|
-
* ```typescript
|
|
2309
|
-
* {
|
|
2310
|
-
* method: 'POST',
|
|
2311
|
-
* path: '/stream',
|
|
2312
|
-
* streamResponse: true,
|
|
2313
|
-
* permissions: requireAuth(),
|
|
2314
|
-
* handler: async (request, reply) => {
|
|
2315
|
-
* const { stream } = await generateStream({ abortSignal: request.signal });
|
|
2316
|
-
* return reply.send(stream);
|
|
2317
|
-
* },
|
|
2318
|
-
* }
|
|
2319
|
-
* ```
|
|
1573
|
+
* Get resources by module
|
|
2320
1574
|
*/
|
|
2321
|
-
|
|
2322
|
-
/** Fastify route schema */
|
|
2323
|
-
schema?: Record<string, unknown>;
|
|
1575
|
+
getByModule(moduleName: string): RegistryEntry[];
|
|
2324
1576
|
/**
|
|
2325
|
-
*
|
|
2326
|
-
* When set, this route becomes an MCP tool without needing `wrapHandler: true`.
|
|
2327
|
-
* The HTTP handler stays a plain Fastify handler; MCP gets a parallel entry point.
|
|
2328
|
-
*
|
|
2329
|
-
* @example
|
|
2330
|
-
* ```typescript
|
|
2331
|
-
* additionalRoutes: [{
|
|
2332
|
-
* method: 'GET',
|
|
2333
|
-
* path: '/stats',
|
|
2334
|
-
* handler: (req, reply) => reply.send(getStats()),
|
|
2335
|
-
* wrapHandler: false,
|
|
2336
|
-
* permissions: isAuthenticated,
|
|
2337
|
-
* mcpHandler: async (input) => ({
|
|
2338
|
-
* content: [{ type: 'text', text: JSON.stringify(await getStats()) }],
|
|
2339
|
-
* }),
|
|
2340
|
-
* }]
|
|
2341
|
-
* ```
|
|
1577
|
+
* Get resources by preset
|
|
2342
1578
|
*/
|
|
2343
|
-
|
|
2344
|
-
content: Array<{
|
|
2345
|
-
type: string;
|
|
2346
|
-
text: string;
|
|
2347
|
-
}>;
|
|
2348
|
-
isError?: boolean;
|
|
2349
|
-
}>;
|
|
1579
|
+
getByPreset(presetName: string): RegistryEntry[];
|
|
2350
1580
|
/**
|
|
2351
|
-
*
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
* - object: explicit description/annotations overrides
|
|
2355
|
-
*
|
|
2356
|
-
* Added in 2.8.1 — previously dropped during `routes → additionalRoutes`
|
|
2357
|
-
* normalization, breaking MCP opt-out and per-route annotations.
|
|
2358
|
-
*/
|
|
2359
|
-
mcp?: boolean | {
|
|
2360
|
-
readonly description?: string;
|
|
2361
|
-
readonly annotations?: {
|
|
2362
|
-
readonly readOnlyHint?: boolean;
|
|
2363
|
-
readonly destructiveHint?: boolean;
|
|
2364
|
-
readonly idempotentHint?: boolean;
|
|
2365
|
-
readonly openWorldHint?: boolean;
|
|
2366
|
-
};
|
|
2367
|
-
};
|
|
2368
|
-
}
|
|
2369
|
-
/** HTTP methods for custom routes */
|
|
2370
|
-
type RouteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
2371
|
-
/** MCP tool configuration for a route or action */
|
|
2372
|
-
interface RouteMcpConfig {
|
|
2373
|
-
/** Override auto-generated tool description */
|
|
2374
|
-
readonly description?: string;
|
|
2375
|
-
/** MCP tool annotations */
|
|
2376
|
-
readonly annotations?: {
|
|
2377
|
-
readonly readOnlyHint?: boolean;
|
|
2378
|
-
readonly destructiveHint?: boolean;
|
|
2379
|
-
readonly idempotentHint?: boolean;
|
|
2380
|
-
readonly openWorldHint?: boolean;
|
|
2381
|
-
};
|
|
2382
|
-
}
|
|
2383
|
-
/**
|
|
2384
|
-
* Route definition — replaces additionalRoutes.
|
|
2385
|
-
*
|
|
2386
|
-
* - `handler: 'string'` → controller method → full Arc pipeline + MCP tool
|
|
2387
|
-
* - `handler: function` → inline handler → full Arc pipeline + MCP tool
|
|
2388
|
-
* - `raw: true` → raw Fastify handler → no pipeline, no MCP by default
|
|
2389
|
-
*/
|
|
2390
|
-
interface RouteDefinition {
|
|
2391
|
-
readonly method: RouteMethod;
|
|
2392
|
-
/** Path relative to resource prefix */
|
|
2393
|
-
readonly path: string;
|
|
1581
|
+
* Check if resource exists
|
|
1582
|
+
*/
|
|
1583
|
+
has(name: string): boolean;
|
|
2394
1584
|
/**
|
|
2395
|
-
*
|
|
2396
|
-
* - String: controller method name (goes through Arc pipeline)
|
|
2397
|
-
* - Function without `raw: true`: receives IRequestContext, returns IControllerResponse (goes through Arc pipeline)
|
|
2398
|
-
* - Function with `raw: true`: raw Fastify handler (request, reply)
|
|
1585
|
+
* Get registry statistics
|
|
2399
1586
|
*/
|
|
2400
|
-
|
|
2401
|
-
/** Permission check — REQUIRED */
|
|
2402
|
-
readonly permissions: PermissionCheck;
|
|
1587
|
+
getStats(): RegistryStats;
|
|
2403
1588
|
/**
|
|
2404
|
-
*
|
|
2405
|
-
* Default: false (handler goes through Arc pipeline).
|
|
1589
|
+
* Get full introspection data
|
|
2406
1590
|
*/
|
|
2407
|
-
|
|
2408
|
-
/** Logical operation name (for pipeline keys, MCP tool naming). Defaults to handler name or method+path slug. */
|
|
2409
|
-
readonly operation?: string;
|
|
2410
|
-
/** OpenAPI summary */
|
|
2411
|
-
readonly summary?: string;
|
|
2412
|
-
/** OpenAPI description */
|
|
2413
|
-
readonly description?: string;
|
|
2414
|
-
/** OpenAPI tags */
|
|
2415
|
-
readonly tags?: string[];
|
|
2416
|
-
/** Route-level middleware */
|
|
2417
|
-
readonly preHandler?: RouteHandlerMethod[] | ((fastify: FastifyInstance) => RouteHandlerMethod[]);
|
|
2418
|
-
/** Pre-auth handlers (run before authentication) */
|
|
2419
|
-
readonly preAuth?: RouteHandlerMethod[];
|
|
2420
|
-
/** SSE streaming mode */
|
|
2421
|
-
readonly streamResponse?: boolean;
|
|
1591
|
+
getIntrospection(): IntrospectionData;
|
|
2422
1592
|
/**
|
|
2423
|
-
*
|
|
2424
|
-
* `response[status]`) accepts a plain JSON Schema object **or** a Zod v4 schema —
|
|
2425
|
-
* arc auto-converts via `convertRouteSchema` at registration time. Slot values
|
|
2426
|
-
* are typed `unknown` so class-based Zod schemas assign without casts.
|
|
1593
|
+
* Freeze registry (prevent further registrations)
|
|
2427
1594
|
*/
|
|
2428
|
-
|
|
2429
|
-
body?: unknown;
|
|
2430
|
-
querystring?: unknown;
|
|
2431
|
-
params?: unknown;
|
|
2432
|
-
headers?: unknown;
|
|
2433
|
-
response?: Record<number | string, unknown>;
|
|
2434
|
-
[key: string]: unknown;
|
|
2435
|
-
};
|
|
1595
|
+
freeze(): void;
|
|
2436
1596
|
/**
|
|
2437
|
-
*
|
|
2438
|
-
* - omitted/true: auto-generate (non-raw routes only)
|
|
2439
|
-
* - false: skip MCP
|
|
2440
|
-
* - object: explicit config
|
|
1597
|
+
* Check if frozen
|
|
2441
1598
|
*/
|
|
2442
|
-
|
|
1599
|
+
isFrozen(): boolean;
|
|
2443
1600
|
/**
|
|
2444
|
-
*
|
|
1601
|
+
* Unfreeze registry (allow new registrations)
|
|
2445
1602
|
*/
|
|
2446
|
-
|
|
2447
|
-
content: Array<{
|
|
2448
|
-
type: string;
|
|
2449
|
-
text: string;
|
|
2450
|
-
}>;
|
|
2451
|
-
isError?: boolean;
|
|
2452
|
-
}>;
|
|
2453
|
-
}
|
|
2454
|
-
/**
|
|
2455
|
-
* Action handler function for state transitions.
|
|
2456
|
-
* Receives the resource ID, action-specific data, and the request context.
|
|
2457
|
-
*/
|
|
2458
|
-
type ActionHandlerFn = (id: string, data: Record<string, unknown>, req: RequestWithExtras) => Promise<unknown>;
|
|
2459
|
-
/**
|
|
2460
|
-
* Full action configuration with handler, permissions, and schema.
|
|
2461
|
-
*/
|
|
2462
|
-
interface ActionDefinition {
|
|
2463
|
-
/** Action handler */
|
|
2464
|
-
readonly handler: ActionHandlerFn;
|
|
2465
|
-
/** Per-action permission check (overrides resource-level actionPermissions) */
|
|
2466
|
-
readonly permissions?: PermissionCheck;
|
|
1603
|
+
unfreeze(): void;
|
|
2467
1604
|
/**
|
|
2468
|
-
*
|
|
2469
|
-
* Per-field values are typed `unknown` so Zod class instances assign without casts.
|
|
1605
|
+
* Reset registry — clear all resources and unfreeze
|
|
2470
1606
|
*/
|
|
2471
|
-
|
|
2472
|
-
/**
|
|
2473
|
-
|
|
1607
|
+
reset(): void;
|
|
1608
|
+
/** @internal Alias for unfreeze() */
|
|
1609
|
+
_unfreeze(): void;
|
|
1610
|
+
/** @internal Alias for reset() */
|
|
1611
|
+
_clear(): void;
|
|
2474
1612
|
/**
|
|
2475
|
-
*
|
|
2476
|
-
* - omitted/true: auto-generate
|
|
2477
|
-
* - false: skip
|
|
2478
|
-
* - object: explicit config
|
|
1613
|
+
* Group by key
|
|
2479
1614
|
*/
|
|
2480
|
-
|
|
1615
|
+
private _groupBy;
|
|
2481
1616
|
}
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
readonlyFields?: string[];
|
|
2489
|
-
requiredFields?: string[];
|
|
2490
|
-
optionalFields?: string[];
|
|
2491
|
-
excludeFields?: string[];
|
|
1617
|
+
//#endregion
|
|
1618
|
+
//#region src/types/fastify.d.ts
|
|
1619
|
+
interface FastifyRequestExtras {
|
|
1620
|
+
user?: Record<string, unknown>;
|
|
1621
|
+
}
|
|
1622
|
+
interface RequestWithExtras extends FastifyRequest {
|
|
2492
1623
|
/**
|
|
2493
|
-
*
|
|
2494
|
-
*
|
|
2495
|
-
* If not set and using a QueryParser with `allowedFilterFields`, MCP auto-derives from it.
|
|
1624
|
+
* Arc metadata — set by createCrudRouter. Contains resource configuration
|
|
1625
|
+
* and schema options.
|
|
2496
1626
|
*/
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
enum?: ReadonlyArray<string | number>; /** Human-readable description — auto-maps to OpenAPI `description` */
|
|
2510
|
-
description?: string;
|
|
2511
|
-
[key: string]: unknown;
|
|
2512
|
-
}>;
|
|
2513
|
-
query?: Record<string, unknown>;
|
|
2514
|
-
}
|
|
2515
|
-
interface FieldRule {
|
|
2516
|
-
field: string;
|
|
2517
|
-
required?: boolean;
|
|
2518
|
-
readonly?: boolean;
|
|
2519
|
-
hidden?: boolean;
|
|
1627
|
+
arc?: {
|
|
1628
|
+
resourceName?: string;
|
|
1629
|
+
schemaOptions?: RouteSchemaOptions;
|
|
1630
|
+
permissions?: ResourcePermissions;
|
|
1631
|
+
};
|
|
1632
|
+
context?: Record<string, unknown>;
|
|
1633
|
+
_policyFilters?: Record<string, unknown>;
|
|
1634
|
+
fieldMask?: {
|
|
1635
|
+
include?: string[];
|
|
1636
|
+
exclude?: string[];
|
|
1637
|
+
};
|
|
1638
|
+
_ownershipCheck?: Record<string, unknown>;
|
|
2520
1639
|
}
|
|
1640
|
+
type FastifyWithAuth = FastifyInstance & {
|
|
1641
|
+
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
1642
|
+
};
|
|
2521
1643
|
/**
|
|
2522
|
-
*
|
|
2523
|
-
*
|
|
2524
|
-
* Each slot accepts either a plain JSON Schema object **or** a Zod v4 schema —
|
|
2525
|
-
* arc's `convertRouteSchema` feature-detects at runtime. The slot values are
|
|
2526
|
-
* typed `unknown` (not `Record<string, unknown>`) so class-based Zod schemas
|
|
2527
|
-
* assign cleanly without `as unknown as Record<string, unknown>` casts.
|
|
2528
|
-
*
|
|
2529
|
-
* @example
|
|
2530
|
-
* ```ts
|
|
2531
|
-
* {
|
|
2532
|
-
* list: {
|
|
2533
|
-
* querystring: { type: 'object', properties: { page: { type: 'number' } } },
|
|
2534
|
-
* response: { 200: z.object({ docs: z.array(EntitySchema) }) }
|
|
2535
|
-
* },
|
|
2536
|
-
* create: {
|
|
2537
|
-
* body: z.object({ name: z.string(), size: z.number().int().positive() }),
|
|
2538
|
-
* response: { 201: EntitySchema }
|
|
2539
|
-
* }
|
|
2540
|
-
* }
|
|
2541
|
-
* ```
|
|
1644
|
+
* Arc core decorator — added by `arcCorePlugin`. Provides instance-scoped
|
|
1645
|
+
* hooks and resource registry.
|
|
2542
1646
|
*/
|
|
2543
|
-
interface
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
[key: string]: unknown;
|
|
2549
|
-
};
|
|
2550
|
-
/** GET /:id - Get single resource */
|
|
2551
|
-
get?: {
|
|
2552
|
-
params?: unknown;
|
|
2553
|
-
response?: Record<number, unknown>;
|
|
2554
|
-
[key: string]: unknown;
|
|
2555
|
-
};
|
|
2556
|
-
/** POST / - Create resource */
|
|
2557
|
-
create?: {
|
|
2558
|
-
body?: unknown;
|
|
2559
|
-
response?: Record<number, unknown>;
|
|
2560
|
-
[key: string]: unknown;
|
|
2561
|
-
};
|
|
2562
|
-
/** PATCH /:id - Update resource */
|
|
2563
|
-
update?: {
|
|
2564
|
-
params?: unknown;
|
|
2565
|
-
body?: unknown;
|
|
2566
|
-
response?: Record<number, unknown>;
|
|
2567
|
-
[key: string]: unknown;
|
|
2568
|
-
};
|
|
2569
|
-
/** DELETE /:id - Delete resource */
|
|
2570
|
-
delete?: {
|
|
2571
|
-
params?: unknown;
|
|
2572
|
-
response?: Record<number, unknown>;
|
|
2573
|
-
[key: string]: unknown;
|
|
2574
|
-
};
|
|
2575
|
-
[key: string]: unknown;
|
|
1647
|
+
interface ArcDecorator {
|
|
1648
|
+
hooks: HookSystem;
|
|
1649
|
+
registry: ResourceRegistry;
|
|
1650
|
+
/** Whether event emission is enabled */
|
|
1651
|
+
emitEvents: boolean;
|
|
2576
1652
|
}
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
1653
|
+
/** Events decorator — added by `eventPlugin`. Provides event pub/sub. */
|
|
1654
|
+
interface EventsDecorator {
|
|
1655
|
+
publish: <T>(type: string, payload: T, meta?: Partial<{
|
|
1656
|
+
id: string;
|
|
1657
|
+
timestamp: Date;
|
|
1658
|
+
}>) => Promise<void>;
|
|
2583
1659
|
/**
|
|
2584
|
-
*
|
|
2585
|
-
*
|
|
2586
|
-
*
|
|
2587
|
-
*
|
|
2588
|
-
*
|
|
2589
|
-
*
|
|
2590
|
-
* @example
|
|
2591
|
-
* response: {
|
|
2592
|
-
* type: 'object',
|
|
2593
|
-
* properties: {
|
|
2594
|
-
* _id: { type: 'string' },
|
|
2595
|
-
* name: { type: 'string' },
|
|
2596
|
-
* email: { type: 'string' },
|
|
2597
|
-
* // Exclude password, include virtuals
|
|
2598
|
-
* fullName: { type: 'string' },
|
|
2599
|
-
* }
|
|
2600
|
-
* }
|
|
1660
|
+
* Subscribe to an event pattern. Handler receives the full
|
|
1661
|
+
* `DomainEvent<T> = { type, payload, meta }` envelope — destructure
|
|
1662
|
+
* `payload` if you only need the body, or read `meta.correlationId` /
|
|
1663
|
+
* `meta.timestamp` for tracing. See CHANGELOG 2.10 for the migration
|
|
1664
|
+
* note from 2.9.x's loose `unknown` type.
|
|
2601
1665
|
*/
|
|
2602
|
-
|
|
2603
|
-
|
|
1666
|
+
subscribe: <T = unknown>(pattern: string, handler: (event: DomainEvent<T>) => void | Promise<void>) => Promise<() => void>;
|
|
1667
|
+
transportName: string;
|
|
2604
1668
|
}
|
|
2605
|
-
/**
|
|
1669
|
+
/**
|
|
1670
|
+
* Fastify instance with Arc decorators. Arc adds these via plugins/presets.
|
|
1671
|
+
*/
|
|
1672
|
+
type FastifyWithDecorators = FastifyInstance & {
|
|
1673
|
+
arc?: ArcDecorator;
|
|
1674
|
+
events?: EventsDecorator;
|
|
1675
|
+
authenticate?: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
1676
|
+
optionalAuthenticate?: (request: FastifyRequest, reply: FastifyReply) => Promise<void>; /** Organization-scoped filtering — from `multiTenant` preset */
|
|
1677
|
+
organizationScoped?: (options?: {
|
|
1678
|
+
required?: boolean;
|
|
1679
|
+
}) => RouteHandlerMethod;
|
|
1680
|
+
[key: string]: unknown;
|
|
1681
|
+
};
|
|
1682
|
+
/** Handler signature for middleware functions. */
|
|
2606
1683
|
type MiddlewareHandler = (request: RequestWithExtras, reply: FastifyReply) => Promise<unknown>;
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
list?: MiddlewareHandler[];
|
|
2610
|
-
get?: MiddlewareHandler[];
|
|
2611
|
-
create?: MiddlewareHandler[];
|
|
2612
|
-
update?: MiddlewareHandler[];
|
|
2613
|
-
delete?: MiddlewareHandler[];
|
|
2614
|
-
[key: string]: MiddlewareHandler[] | undefined;
|
|
2615
|
-
}
|
|
1684
|
+
//#endregion
|
|
1685
|
+
//#region src/types/auth.d.ts
|
|
2616
1686
|
/**
|
|
2617
|
-
* JWT utilities provided to authenticator
|
|
2618
|
-
*
|
|
1687
|
+
* JWT utilities provided to authenticator. Arc provides the helpers;
|
|
1688
|
+
* apps use them as needed.
|
|
2619
1689
|
*/
|
|
2620
1690
|
interface JwtContext {
|
|
2621
1691
|
/** Verify a JWT token and return decoded payload */
|
|
@@ -2627,51 +1697,31 @@ interface JwtContext {
|
|
|
2627
1697
|
/** Decode without verification (for inspection) */
|
|
2628
1698
|
decode: <T = Record<string, unknown>>(token: string) => T | null;
|
|
2629
1699
|
}
|
|
2630
|
-
/**
|
|
2631
|
-
* Context passed to app's authenticator function
|
|
2632
|
-
*/
|
|
1700
|
+
/** Context passed to app's authenticator function. */
|
|
2633
1701
|
interface AuthenticatorContext {
|
|
2634
|
-
/** JWT utilities (available if jwt.secret provided) */
|
|
1702
|
+
/** JWT utilities (available if `jwt.secret` provided) */
|
|
2635
1703
|
jwt: JwtContext | null;
|
|
2636
1704
|
/** Fastify instance for advanced use cases */
|
|
2637
1705
|
fastify: FastifyInstance;
|
|
2638
1706
|
}
|
|
2639
1707
|
/**
|
|
2640
|
-
* App-provided authenticator function
|
|
1708
|
+
* App-provided authenticator function. Arc calls this for every
|
|
1709
|
+
* non-public route. The app has full control over authentication logic.
|
|
2641
1710
|
*
|
|
2642
|
-
*
|
|
2643
|
-
* App has FULL control over authentication logic.
|
|
1711
|
+
* Return a user object to authenticate, `null`/`undefined` to reject.
|
|
2644
1712
|
*
|
|
2645
1713
|
* @example
|
|
2646
1714
|
* ```typescript
|
|
2647
|
-
* // Simple JWT auth
|
|
2648
1715
|
* authenticate: async (request, { jwt }) => {
|
|
2649
1716
|
* const token = request.headers.authorization?.split(' ')[1];
|
|
2650
1717
|
* if (!token || !jwt) return null;
|
|
2651
1718
|
* const decoded = jwt.verify(token);
|
|
2652
1719
|
* return userRepo.findById(decoded.id);
|
|
2653
1720
|
* }
|
|
2654
|
-
*
|
|
2655
|
-
* // Multi-strategy (JWT + API Key)
|
|
2656
|
-
* authenticate: async (request, { jwt }) => {
|
|
2657
|
-
* const apiKey = request.headers['x-api-key'];
|
|
2658
|
-
* if (apiKey) {
|
|
2659
|
-
* const result = await apiKeyService.verify(apiKey);
|
|
2660
|
-
* if (result) return { _id: result.userId, isApiKey: true };
|
|
2661
|
-
* }
|
|
2662
|
-
* const token = request.headers.authorization?.split(' ')[1];
|
|
2663
|
-
* if (token && jwt) {
|
|
2664
|
-
* const decoded = jwt.verify(token);
|
|
2665
|
-
* return userRepo.findById(decoded.id);
|
|
2666
|
-
* }
|
|
2667
|
-
* return null;
|
|
2668
|
-
* }
|
|
2669
1721
|
* ```
|
|
2670
1722
|
*/
|
|
2671
1723
|
type Authenticator = (request: FastifyRequest, context: AuthenticatorContext) => Promise<unknown | null> | unknown | null;
|
|
2672
|
-
/**
|
|
2673
|
-
* Token pair returned by issueTokens helper
|
|
2674
|
-
*/
|
|
1724
|
+
/** Token pair returned by `issueTokens` helper. */
|
|
2675
1725
|
interface TokenPair {
|
|
2676
1726
|
/** Access token (JWT) */
|
|
2677
1727
|
accessToken: string;
|
|
@@ -2682,25 +1732,18 @@ interface TokenPair {
|
|
|
2682
1732
|
/** Refresh token expiry in seconds */
|
|
2683
1733
|
refreshExpiresIn?: number;
|
|
2684
1734
|
/** Token type (always 'Bearer') */
|
|
2685
|
-
tokenType:
|
|
1735
|
+
tokenType: "Bearer";
|
|
2686
1736
|
}
|
|
2687
1737
|
/**
|
|
2688
|
-
* Auth helpers
|
|
1738
|
+
* Auth helpers exposed on `fastify.auth`.
|
|
2689
1739
|
*
|
|
2690
1740
|
* @example
|
|
2691
1741
|
* ```typescript
|
|
2692
|
-
* // In login handler
|
|
2693
|
-
* const user = await userRepo.findByEmail(email);
|
|
2694
|
-
* if (!user || !await bcrypt.compare(password, user.password)) {
|
|
2695
|
-
* return reply.code(401).send({ error: 'Invalid credentials' });
|
|
2696
|
-
* }
|
|
2697
|
-
*
|
|
2698
1742
|
* const tokens = fastify.auth.issueTokens({
|
|
2699
1743
|
* id: user._id,
|
|
2700
1744
|
* email: user.email,
|
|
2701
1745
|
* role: user.role,
|
|
2702
1746
|
* });
|
|
2703
|
-
*
|
|
2704
1747
|
* return { success: true, ...tokens, user };
|
|
2705
1748
|
* ```
|
|
2706
1749
|
*/
|
|
@@ -2708,41 +1751,110 @@ interface AuthHelpers {
|
|
|
2708
1751
|
/** JWT utilities (if configured) */
|
|
2709
1752
|
jwt: JwtContext | null;
|
|
2710
1753
|
/**
|
|
2711
|
-
* Issue access + refresh tokens for a user
|
|
2712
|
-
*
|
|
1754
|
+
* Issue access + refresh tokens for a user. App calls this after
|
|
1755
|
+
* validating credentials.
|
|
2713
1756
|
*/
|
|
2714
1757
|
issueTokens: (payload: Record<string, unknown>, options?: {
|
|
2715
1758
|
expiresIn?: string;
|
|
2716
1759
|
refreshExpiresIn?: string;
|
|
2717
1760
|
}) => TokenPair;
|
|
2718
|
-
/**
|
|
2719
|
-
* Verify a refresh token and return decoded payload
|
|
2720
|
-
*/
|
|
1761
|
+
/** Verify a refresh token and return decoded payload. */
|
|
2721
1762
|
verifyRefreshToken: <T = Record<string, unknown>>(token: string) => T;
|
|
2722
1763
|
}
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
1764
|
+
/**
|
|
1765
|
+
* Auth plugin options — clean, minimal configuration.
|
|
1766
|
+
*
|
|
1767
|
+
* Arc provides JWT infrastructure and calls your authenticator. You
|
|
1768
|
+
* control all authentication logic.
|
|
1769
|
+
*
|
|
1770
|
+
* @example
|
|
1771
|
+
* ```typescript
|
|
1772
|
+
* auth: {
|
|
1773
|
+
* jwt: { secret: process.env.JWT_SECRET },
|
|
1774
|
+
* authenticate: async (request, { jwt }) => {
|
|
1775
|
+
* const token = request.headers.authorization?.split(' ')[1];
|
|
1776
|
+
* if (!token) return null;
|
|
1777
|
+
* const decoded = jwt.verify(token);
|
|
1778
|
+
* return userRepo.findById(decoded.id);
|
|
1779
|
+
* },
|
|
1780
|
+
* }
|
|
1781
|
+
* ```
|
|
1782
|
+
*/
|
|
1783
|
+
interface AuthPluginOptions {
|
|
1784
|
+
/**
|
|
1785
|
+
* JWT configuration (optional but recommended). If provided, JWT
|
|
1786
|
+
* utilities are available in the authenticator context.
|
|
1787
|
+
*/
|
|
1788
|
+
jwt?: {
|
|
1789
|
+
/** JWT secret (required for JWT features) */secret: string; /** Access token expiry (default: '15m') */
|
|
1790
|
+
expiresIn?: string; /** Refresh token secret (defaults to main secret) */
|
|
1791
|
+
refreshSecret?: string; /** Refresh token expiry (default: '7d') */
|
|
1792
|
+
refreshExpiresIn?: string; /** Additional `@fastify/jwt` sign options */
|
|
1793
|
+
sign?: Record<string, unknown>; /** Additional `@fastify/jwt` verify options */
|
|
1794
|
+
verify?: Record<string, unknown>;
|
|
1795
|
+
};
|
|
1796
|
+
/**
|
|
1797
|
+
* Custom authenticator function. Arc calls this for non-public routes.
|
|
1798
|
+
* If not provided and `jwt.secret` is set, uses default `jwtVerify`.
|
|
1799
|
+
*/
|
|
1800
|
+
authenticate?: Authenticator;
|
|
1801
|
+
/**
|
|
1802
|
+
* Custom auth failure handler. Customize the 401 response when
|
|
1803
|
+
* authentication fails.
|
|
1804
|
+
*/
|
|
1805
|
+
onFailure?: (request: FastifyRequest, reply: FastifyReply, error?: Error) => void | Promise<void>;
|
|
1806
|
+
/**
|
|
1807
|
+
* Expose detailed auth error messages in 401 responses. When `false`
|
|
1808
|
+
* (default), returns generic "Authentication required". Decoupled from
|
|
1809
|
+
* log level — set explicitly per environment.
|
|
1810
|
+
*/
|
|
1811
|
+
exposeAuthErrors?: boolean;
|
|
1812
|
+
/** Property name to store user on request (default: 'user') */
|
|
1813
|
+
userProperty?: string;
|
|
1814
|
+
/**
|
|
1815
|
+
* Custom token extractor for the built-in JWT auth path. Defaults to
|
|
1816
|
+
* extracting Bearer token from Authorization header. Use when tokens
|
|
1817
|
+
* are in HttpOnly cookies, custom headers, or query params.
|
|
1818
|
+
*
|
|
1819
|
+
* @example
|
|
1820
|
+
* ```typescript
|
|
1821
|
+
* tokenExtractor: (request) => request.cookies?.['auth-token'] ?? null,
|
|
1822
|
+
* ```
|
|
1823
|
+
*/
|
|
1824
|
+
tokenExtractor?: (request: FastifyRequest) => string | null;
|
|
1825
|
+
/**
|
|
1826
|
+
* Token revocation check — called after JWT verification succeeds.
|
|
1827
|
+
* Return `true` to reject the token (revoked), `false` to allow.
|
|
1828
|
+
*
|
|
1829
|
+
* **Fail-closed**: if the check throws, the token is treated as revoked.
|
|
1830
|
+
*
|
|
1831
|
+
* @example
|
|
1832
|
+
* ```typescript
|
|
1833
|
+
* isRevoked: async (decoded) => {
|
|
1834
|
+
* return await redis.sismember('revoked-tokens', decoded.jti ?? decoded.id);
|
|
1835
|
+
* },
|
|
1836
|
+
* ```
|
|
1837
|
+
*/
|
|
1838
|
+
isRevoked?: (decoded: Record<string, unknown>) => boolean | Promise<boolean>;
|
|
1839
|
+
/**
|
|
1840
|
+
* Enforce strict JWT `type` claim validation (default: `true`).
|
|
1841
|
+
*
|
|
1842
|
+
* When enabled, `authenticate` requires `decoded.type === "access"`.
|
|
1843
|
+
* Tokens with a missing or unexpected `type` claim are rejected —
|
|
1844
|
+
* defence in depth for apps that reuse the JWT secret to sign other
|
|
1845
|
+
* token kinds (invite links, one-time verification codes).
|
|
1846
|
+
*
|
|
1847
|
+
* Arc's own `issueTokens` always sets `type: "access"` or
|
|
1848
|
+
* `type: "refresh"`, so this default is safe for Arc-generated tokens.
|
|
1849
|
+
*
|
|
1850
|
+
* Set to `false` ONLY when you must accept tokens signed without a
|
|
1851
|
+
* `type` claim (e.g. a legacy issuer you don't control). In that mode
|
|
1852
|
+
* Arc still rejects tokens explicitly marked `type: "refresh"`.
|
|
1853
|
+
*/
|
|
1854
|
+
strictTokenType?: boolean;
|
|
2744
1855
|
}
|
|
2745
|
-
|
|
1856
|
+
//#endregion
|
|
1857
|
+
//#region src/types/plugins.d.ts
|
|
2746
1858
|
interface GracefulShutdownOptions {
|
|
2747
1859
|
timeout?: number;
|
|
2748
1860
|
onShutdown?: () => Promise<void> | void;
|
|
@@ -2762,488 +1874,628 @@ interface HealthCheck {
|
|
|
2762
1874
|
timestamp: string;
|
|
2763
1875
|
[key: string]: unknown;
|
|
2764
1876
|
}
|
|
1877
|
+
interface IntrospectionPluginOptions {
|
|
1878
|
+
path?: string;
|
|
1879
|
+
prefix?: string;
|
|
1880
|
+
enabled?: boolean;
|
|
1881
|
+
authRoles?: string[];
|
|
1882
|
+
}
|
|
1883
|
+
interface CrudRouterOptions {
|
|
1884
|
+
/** Route prefix */
|
|
1885
|
+
prefix?: string;
|
|
1886
|
+
/** Permission checks for CRUD operations */
|
|
1887
|
+
permissions?: ResourcePermissions;
|
|
1888
|
+
/** OpenAPI tag for grouping routes */
|
|
1889
|
+
tag?: string;
|
|
1890
|
+
/** JSON schemas for CRUD operations */
|
|
1891
|
+
schemas?: Partial<CrudSchemas>;
|
|
1892
|
+
/** Middlewares for each CRUD operation */
|
|
1893
|
+
middlewares?: MiddlewareConfig;
|
|
1894
|
+
/** Custom routes (from presets or user-defined) */
|
|
1895
|
+
routes?: readonly RouteDefinition[];
|
|
1896
|
+
/** Disable all default CRUD routes */
|
|
1897
|
+
disableDefaultRoutes?: boolean;
|
|
1898
|
+
/** Disable specific CRUD routes */
|
|
1899
|
+
disabledRoutes?: CrudRouteKey[];
|
|
1900
|
+
/** Functional pipeline (guard/transform/intercept) */
|
|
1901
|
+
pipe?: PipelineConfig;
|
|
1902
|
+
/** Resource name for lifecycle hooks */
|
|
1903
|
+
resourceName?: string;
|
|
1904
|
+
/** Schema generation options */
|
|
1905
|
+
schemaOptions?: RouteSchemaOptions;
|
|
1906
|
+
/** Field-level permissions (visibility, writability per role) */
|
|
1907
|
+
fields?: FieldPermissionMap;
|
|
1908
|
+
/** HTTP method for update routes. Default: 'PATCH' */
|
|
1909
|
+
updateMethod?: "PUT" | "PATCH" | "both";
|
|
1910
|
+
/**
|
|
1911
|
+
* Per-resource rate limiting. Requires `@fastify/rate-limit` to be
|
|
1912
|
+
* registered. Set to `false` to disable for this resource.
|
|
1913
|
+
*/
|
|
1914
|
+
rateLimit?: RateLimitConfig | false;
|
|
1915
|
+
/** PreHandler guards applied to every route (CRUD + custom + preset). */
|
|
1916
|
+
routeGuards?: RouteHandlerMethod[];
|
|
1917
|
+
}
|
|
1918
|
+
//#endregion
|
|
1919
|
+
//#region src/types/registry.d.ts
|
|
1920
|
+
interface ResourceMetadata {
|
|
1921
|
+
name: string;
|
|
1922
|
+
displayName?: string;
|
|
1923
|
+
tag?: string;
|
|
1924
|
+
prefix: string;
|
|
1925
|
+
module?: string;
|
|
1926
|
+
permissions?: ResourcePermissions;
|
|
1927
|
+
presets: string[];
|
|
1928
|
+
customRoutes?: Array<{
|
|
1929
|
+
method: string;
|
|
1930
|
+
path: string;
|
|
1931
|
+
handler: string;
|
|
1932
|
+
operation?: string;
|
|
1933
|
+
summary?: string;
|
|
1934
|
+
description?: string;
|
|
1935
|
+
permissions?: PermissionCheck;
|
|
1936
|
+
raw?: boolean;
|
|
1937
|
+
schema?: Record<string, unknown>;
|
|
1938
|
+
}>;
|
|
1939
|
+
routes: Array<{
|
|
1940
|
+
method: string;
|
|
1941
|
+
path: string;
|
|
1942
|
+
handler?: string;
|
|
1943
|
+
operation?: string;
|
|
1944
|
+
summary?: string;
|
|
1945
|
+
}>;
|
|
1946
|
+
events?: string[];
|
|
1947
|
+
}
|
|
1948
|
+
interface RegistryEntry extends ResourceMetadata {
|
|
1949
|
+
plugin: unknown;
|
|
1950
|
+
adapter?: {
|
|
1951
|
+
type: string;
|
|
1952
|
+
name: string;
|
|
1953
|
+
} | null;
|
|
1954
|
+
events?: string[];
|
|
1955
|
+
disableDefaultRoutes?: boolean;
|
|
1956
|
+
openApiSchemas?: OpenApiSchemas;
|
|
1957
|
+
registeredAt?: string;
|
|
1958
|
+
/** Field-level permissions metadata (for OpenAPI docs) */
|
|
1959
|
+
fieldPermissions?: Record<string, {
|
|
1960
|
+
type: string;
|
|
1961
|
+
roles?: readonly string[];
|
|
1962
|
+
redactValue?: unknown;
|
|
1963
|
+
}>;
|
|
1964
|
+
/** Pipeline step names (for OpenAPI docs) */
|
|
1965
|
+
pipelineSteps?: Array<{
|
|
1966
|
+
type: string;
|
|
1967
|
+
name: string;
|
|
1968
|
+
operations?: string[];
|
|
1969
|
+
}>;
|
|
1970
|
+
/** Update HTTP method(s) used for this resource */
|
|
1971
|
+
updateMethod?: "PUT" | "PATCH" | "both";
|
|
1972
|
+
/** Routes disabled for this resource */
|
|
1973
|
+
disabledRoutes?: string[];
|
|
1974
|
+
/** Rate limit config */
|
|
1975
|
+
rateLimit?: RateLimitConfig | false;
|
|
1976
|
+
/** Per-resource audit opt-in flag (read by `auditPlugin` perResource mode) */
|
|
1977
|
+
audit?: boolean | {
|
|
1978
|
+
operations?: ("create" | "update" | "delete")[];
|
|
1979
|
+
};
|
|
1980
|
+
/**
|
|
1981
|
+
* v2.8 declarative actions metadata — populated from
|
|
1982
|
+
* `ResourceConfig.actions`. Consumed by OpenAPI generation (renders
|
|
1983
|
+
* `POST /:id/action` with a discriminated body schema) and MCP tool
|
|
1984
|
+
* generation. Added in 2.8.1.
|
|
1985
|
+
*/
|
|
1986
|
+
actions?: Array<{
|
|
1987
|
+
readonly name: string;
|
|
1988
|
+
readonly description?: string; /** Raw per-action schema (JSON Schema, Zod v4, or legacy field map) */
|
|
1989
|
+
readonly schema?: Record<string, unknown>; /** Per-action permission check (if different from resource-level `actionPermissions`) */
|
|
1990
|
+
readonly permissions?: PermissionCheck; /** MCP tool generation flag — `false` to skip, object for overrides */
|
|
1991
|
+
readonly mcp?: boolean | {
|
|
1992
|
+
readonly description?: string;
|
|
1993
|
+
readonly annotations?: Record<string, unknown>;
|
|
1994
|
+
};
|
|
1995
|
+
}>;
|
|
1996
|
+
/**
|
|
1997
|
+
* Resource-level fallback permission for actions without per-action
|
|
1998
|
+
* permissions. Used by OpenAPI to determine auth requirements and by
|
|
1999
|
+
* MCP as the fallback in `createActionToolHandler`. Added in 2.8.1.
|
|
2000
|
+
*/
|
|
2001
|
+
actionPermissions?: PermissionCheck;
|
|
2002
|
+
}
|
|
2003
|
+
interface RegistryStats {
|
|
2004
|
+
total?: number;
|
|
2005
|
+
totalResources: number;
|
|
2006
|
+
byTag?: Record<string, number>;
|
|
2007
|
+
byModule?: Record<string, number>;
|
|
2008
|
+
presetUsage?: Record<string, number>;
|
|
2009
|
+
totalRoutes?: number;
|
|
2010
|
+
totalEvents?: number;
|
|
2011
|
+
}
|
|
2012
|
+
interface IntrospectionData {
|
|
2013
|
+
resources: ResourceMetadata[];
|
|
2014
|
+
stats: RegistryStats;
|
|
2015
|
+
generatedAt?: string;
|
|
2016
|
+
}
|
|
2017
|
+
//#endregion
|
|
2018
|
+
//#region src/types/validation.d.ts
|
|
2019
|
+
/**
|
|
2020
|
+
* Validation result types — produced by `validateResourceConfig` and
|
|
2021
|
+
* consumed by `assertValidConfig` / `formatValidationErrors`.
|
|
2022
|
+
*/
|
|
2023
|
+
interface ConfigError {
|
|
2024
|
+
field: string;
|
|
2025
|
+
message: string;
|
|
2026
|
+
code?: string;
|
|
2027
|
+
}
|
|
2028
|
+
interface ValidationResult {
|
|
2029
|
+
valid: boolean;
|
|
2030
|
+
errors: ConfigError[];
|
|
2031
|
+
}
|
|
2032
|
+
interface ValidateOptions {
|
|
2033
|
+
strict?: boolean;
|
|
2034
|
+
}
|
|
2035
|
+
//#endregion
|
|
2036
|
+
//#region src/types/utility.d.ts
|
|
2765
2037
|
/**
|
|
2766
|
-
*
|
|
2038
|
+
* Infer document type from a `DataAdapter` or `ResourceConfig`. Smart
|
|
2039
|
+
* inference that works with multiple sources.
|
|
2767
2040
|
*
|
|
2768
|
-
*
|
|
2769
|
-
*
|
|
2041
|
+
* @example
|
|
2042
|
+
* ```typescript
|
|
2043
|
+
* type Doc1 = InferDocType<typeof adapter>; // From DataAdapter
|
|
2044
|
+
* type Doc2 = InferDocType<typeof resource>; // From ResourceConfig
|
|
2045
|
+
* ```
|
|
2046
|
+
*/
|
|
2047
|
+
type InferDocType<T> = T extends DataAdapter<infer D> ? D : T extends ResourceConfig<infer D> ? D : never;
|
|
2048
|
+
/**
|
|
2049
|
+
* Infer document type from a `DataAdapter`. Falls back to `unknown`
|
|
2050
|
+
* (not `never`) — safe for generic constraints.
|
|
2770
2051
|
*
|
|
2771
2052
|
* @example
|
|
2772
2053
|
* ```typescript
|
|
2773
|
-
*
|
|
2774
|
-
*
|
|
2775
|
-
*
|
|
2776
|
-
|
|
2054
|
+
* const adapter = createMongooseAdapter({ model: ProductModel, repository: productRepo });
|
|
2055
|
+
* type ProductDoc = InferAdapterDoc<typeof adapter>;
|
|
2056
|
+
* ```
|
|
2057
|
+
*/
|
|
2058
|
+
type InferAdapterDoc<A> = A extends DataAdapter<infer D> ? D : unknown;
|
|
2059
|
+
type InferResourceDoc<T> = T extends ResourceConfig<infer D> ? D : never;
|
|
2060
|
+
type TypedResourceConfig<TDoc> = ResourceConfig<TDoc>;
|
|
2061
|
+
type TypedController<TDoc> = IController<TDoc>;
|
|
2062
|
+
type TypedRepository<TDoc> = StandardRepo<TDoc>;
|
|
2063
|
+
//#endregion
|
|
2064
|
+
//#region src/types/repository.d.ts
|
|
2065
|
+
/**
|
|
2066
|
+
* Discriminated union of pagination result shapes. Narrow on `method`.
|
|
2777
2067
|
*
|
|
2778
|
-
*
|
|
2779
|
-
*
|
|
2780
|
-
*
|
|
2781
|
-
*
|
|
2782
|
-
* const token = request.headers.authorization?.split(' ')[1];
|
|
2783
|
-
* if (!token) return null;
|
|
2784
|
-
* const decoded = jwt.verify(token);
|
|
2785
|
-
* return userRepo.findById(decoded.id);
|
|
2786
|
-
* },
|
|
2787
|
-
* }
|
|
2068
|
+
* repo-core ships the individual shapes (`OffsetPaginationResult` /
|
|
2069
|
+
* `KeysetPaginationResult`) but no combined union — arc needs one for the
|
|
2070
|
+
* BaseController's `list` / `getDeleted` return signatures, where either
|
|
2071
|
+
* shape is valid depending on the caller's pagination params.
|
|
2788
2072
|
*
|
|
2789
|
-
*
|
|
2790
|
-
*
|
|
2791
|
-
*
|
|
2792
|
-
*
|
|
2793
|
-
* //
|
|
2794
|
-
*
|
|
2795
|
-
*
|
|
2796
|
-
* const result = await apiKeyService.verify(apiKey);
|
|
2797
|
-
* if (result) return { _id: result.userId, isApiKey: true };
|
|
2798
|
-
* }
|
|
2799
|
-
* // Try JWT
|
|
2800
|
-
* const token = request.headers.authorization?.split(' ')[1];
|
|
2801
|
-
* if (token) {
|
|
2802
|
-
* const decoded = jwt.verify(token);
|
|
2803
|
-
* return userRepo.findById(decoded.id);
|
|
2804
|
-
* }
|
|
2805
|
-
* return null;
|
|
2806
|
-
* },
|
|
2807
|
-
* onFailure: (request, reply) => {
|
|
2808
|
-
* reply.code(401).send({
|
|
2809
|
-
* success: false,
|
|
2810
|
-
* error: 'Authentication required',
|
|
2811
|
-
* message: 'Use Bearer token or X-API-Key header',
|
|
2812
|
-
* });
|
|
2813
|
-
* },
|
|
2073
|
+
* @example
|
|
2074
|
+
* ```ts
|
|
2075
|
+
* const result = await repo.getAll(params);
|
|
2076
|
+
* if (result.method === 'keyset') {
|
|
2077
|
+
* result.next; // keyset cursor
|
|
2078
|
+
* } else {
|
|
2079
|
+
* result.page; // offset number
|
|
2814
2080
|
* }
|
|
2815
2081
|
* ```
|
|
2816
2082
|
*/
|
|
2817
|
-
|
|
2083
|
+
type PaginationResult<TDoc, TExtra extends Record<string, unknown> = Record<string, never>> = OffsetPaginationResult<TDoc, TExtra> | KeysetPaginationResult<TDoc, TExtra>;
|
|
2084
|
+
//#endregion
|
|
2085
|
+
//#region src/core/AccessControl.d.ts
|
|
2086
|
+
/** Denial reason codes returned by `fetchDetailed()`. */
|
|
2087
|
+
type FetchDenialReason = "NOT_FOUND" | "POLICY_FILTERED" | "ORG_SCOPE_DENIED";
|
|
2088
|
+
/** Result of a detailed fetch with access control. */
|
|
2089
|
+
interface FetchResult<TDoc> {
|
|
2090
|
+
/** The document, or null if denied. */
|
|
2091
|
+
doc: TDoc | null;
|
|
2092
|
+
/** Null when the doc was found. A string code when denied. */
|
|
2093
|
+
reason: FetchDenialReason | null;
|
|
2094
|
+
}
|
|
2095
|
+
interface AccessControlConfig {
|
|
2096
|
+
/** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable org filtering. */
|
|
2097
|
+
tenantField: string | false;
|
|
2098
|
+
/** Primary key field name (default: '_id') */
|
|
2099
|
+
idField: string;
|
|
2100
|
+
/**
|
|
2101
|
+
* Custom filter matching for policy enforcement.
|
|
2102
|
+
* Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
|
|
2103
|
+
* Falls back to built-in MongoDB-style matching if not provided.
|
|
2104
|
+
*/
|
|
2105
|
+
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
2106
|
+
}
|
|
2107
|
+
/** Minimal repository interface for access-controlled fetch operations */
|
|
2108
|
+
interface AccessControlRepository {
|
|
2109
|
+
getById(id: string, options?: QueryOptions): Promise<unknown>;
|
|
2110
|
+
getOne?: (filter: AnyRecord, options?: QueryOptions) => Promise<unknown>;
|
|
2111
|
+
}
|
|
2112
|
+
declare class AccessControl {
|
|
2113
|
+
private readonly tenantField;
|
|
2114
|
+
private readonly idField;
|
|
2115
|
+
private readonly _adapterMatchesFilter?;
|
|
2116
|
+
/** Patterns that indicate dangerous regex (nested quantifiers, excessive backtracking).
|
|
2117
|
+
* Uses [^...] character classes instead of .+ to avoid backtracking in the detector itself. */
|
|
2118
|
+
private static readonly DANGEROUS_REGEX;
|
|
2119
|
+
/** Forbidden paths that could lead to prototype pollution */
|
|
2120
|
+
private static readonly FORBIDDEN_PATHS;
|
|
2121
|
+
constructor(config: AccessControlConfig);
|
|
2122
|
+
/**
|
|
2123
|
+
* Build filter for single-item operations (get/update/delete)
|
|
2124
|
+
* Combines ID filter with policy/org filters for proper security enforcement
|
|
2125
|
+
*/
|
|
2126
|
+
buildIdFilter(id: string, req: IRequestContext): AnyRecord;
|
|
2127
|
+
/**
|
|
2128
|
+
* Check if item matches policy filters (for get/update/delete operations)
|
|
2129
|
+
* Validates that fetched item satisfies all policy constraints
|
|
2130
|
+
*
|
|
2131
|
+
* Delegates to adapter-provided matchesFilter if available (for SQL, etc.),
|
|
2132
|
+
* otherwise falls back to built-in MongoDB-style matching.
|
|
2133
|
+
*/
|
|
2134
|
+
checkPolicyFilters(item: AnyRecord, req: IRequestContext): boolean;
|
|
2135
|
+
/**
|
|
2136
|
+
* Check org/tenant scope for a document — uses configurable tenantField.
|
|
2137
|
+
*
|
|
2138
|
+
* SECURITY: When org scope is active (orgId present), documents that are
|
|
2139
|
+
* missing the tenant field are DENIED by default. This prevents legacy or
|
|
2140
|
+
* unscoped records from leaking across tenants.
|
|
2141
|
+
*/
|
|
2142
|
+
checkOrgScope(item: AnyRecord | null, arcContext: ArcInternalMetadata | RequestContext | undefined): boolean;
|
|
2143
|
+
/** Check ownership for update/delete (ownedByUser preset) */
|
|
2144
|
+
checkOwnership(item: AnyRecord | null, req: IRequestContext): boolean;
|
|
2145
|
+
/**
|
|
2146
|
+
* Fetch a single document with full access control enforcement.
|
|
2147
|
+
* Combines compound DB filter (ID + org + policy) with post-hoc fallback.
|
|
2148
|
+
*
|
|
2149
|
+
* Takes repository as a parameter to avoid coupling.
|
|
2150
|
+
*
|
|
2151
|
+
* Replaces the duplicated pattern in get/update/delete:
|
|
2152
|
+
* buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
|
|
2153
|
+
*/
|
|
2154
|
+
fetchWithAccessControl<TDoc>(id: string, req: IRequestContext, repository: AccessControlRepository, queryOptions?: QueryOptions): Promise<TDoc | null>;
|
|
2155
|
+
/**
|
|
2156
|
+
* Same as `fetchWithAccessControl` but returns a structured result with
|
|
2157
|
+
* a denial reason so callers can distinguish "doc doesn't exist" from
|
|
2158
|
+
* "doc exists but was filtered by policy/org scope" from "repo threw".
|
|
2159
|
+
*
|
|
2160
|
+
* Codes:
|
|
2161
|
+
* - `null` — doc was found, no denial
|
|
2162
|
+
* - `'NOT_FOUND'` — doc genuinely doesn't exist in the DB
|
|
2163
|
+
* - `'POLICY_FILTERED'` — doc exists but the request's policy filters exclude it
|
|
2164
|
+
* - `'ORG_SCOPE_DENIED'` — doc exists but the caller's org context doesn't match
|
|
2165
|
+
* - `'REPO_ERROR'` — the repository threw a "not found" error (mongokit style)
|
|
2166
|
+
*/
|
|
2167
|
+
fetchDetailed<TDoc>(id: string, req: IRequestContext, repository: AccessControlRepository, queryOptions?: QueryOptions): Promise<FetchResult<TDoc>>;
|
|
2168
|
+
/**
|
|
2169
|
+
* Post-fetch access control validation for items fetched by non-ID queries
|
|
2170
|
+
* (e.g., getBySlug, restore). Applies org scope, policy filters, and
|
|
2171
|
+
* ownership checks — the same guarantees as fetchWithAccessControl.
|
|
2172
|
+
*/
|
|
2173
|
+
validateItemAccess(item: AnyRecord | null, req: IRequestContext): boolean;
|
|
2174
|
+
/** Extract typed Arc internal metadata from request */
|
|
2175
|
+
private _meta;
|
|
2818
2176
|
/**
|
|
2819
|
-
*
|
|
2820
|
-
* If provided, jwt utilities are available in authenticator context
|
|
2177
|
+
* Check if a value matches a MongoDB query operator
|
|
2821
2178
|
*/
|
|
2822
|
-
|
|
2823
|
-
/** JWT secret (required for JWT features) */secret: string; /** Access token expiry (default: '15m') */
|
|
2824
|
-
expiresIn?: string; /** Refresh token secret (defaults to main secret) */
|
|
2825
|
-
refreshSecret?: string; /** Refresh token expiry (default: '7d') */
|
|
2826
|
-
refreshExpiresIn?: string; /** Additional @fastify/jwt sign options */
|
|
2827
|
-
sign?: Record<string, unknown>; /** Additional @fastify/jwt verify options */
|
|
2828
|
-
verify?: Record<string, unknown>;
|
|
2829
|
-
};
|
|
2179
|
+
private matchesOperator;
|
|
2830
2180
|
/**
|
|
2831
|
-
*
|
|
2832
|
-
*
|
|
2833
|
-
* Arc calls this for non-public routes.
|
|
2834
|
-
* Return user object to authenticate, null/undefined to reject.
|
|
2835
|
-
*
|
|
2836
|
-
* If not provided and jwt.secret is set, uses default jwtVerify.
|
|
2181
|
+
* Check if item matches a single filter condition
|
|
2182
|
+
* Supports nested paths (e.g., "owner.id", "metadata.status")
|
|
2837
2183
|
*/
|
|
2838
|
-
|
|
2184
|
+
private matchesFilter;
|
|
2839
2185
|
/**
|
|
2840
|
-
*
|
|
2841
|
-
*
|
|
2186
|
+
* Built-in MongoDB-style policy filter matching.
|
|
2187
|
+
* Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
|
|
2842
2188
|
*/
|
|
2843
|
-
|
|
2189
|
+
private defaultMatchesPolicyFilters;
|
|
2844
2190
|
/**
|
|
2845
|
-
*
|
|
2846
|
-
*
|
|
2847
|
-
* When true, includes the actual error message for debugging.
|
|
2848
|
-
* Decoupled from log level — set explicitly per environment.
|
|
2191
|
+
* Get nested value from object using dot notation (e.g., "owner.id")
|
|
2192
|
+
* Security: Validates path against forbidden patterns to prevent prototype pollution
|
|
2849
2193
|
*/
|
|
2850
|
-
|
|
2194
|
+
private getNestedValue;
|
|
2851
2195
|
/**
|
|
2852
|
-
*
|
|
2196
|
+
* Create a safe RegExp from a string, guarding against ReDoS.
|
|
2197
|
+
* Returns null if the pattern is invalid or dangerous.
|
|
2853
2198
|
*/
|
|
2854
|
-
|
|
2199
|
+
private static safeRegex;
|
|
2200
|
+
}
|
|
2201
|
+
//#endregion
|
|
2202
|
+
//#region src/core/BodySanitizer.d.ts
|
|
2203
|
+
/**
|
|
2204
|
+
* Policy for handling fields the caller lacks write permission for.
|
|
2205
|
+
*
|
|
2206
|
+
* - `'reject'` (default, secure): throw 403 listing the denied fields so
|
|
2207
|
+
* misconfigurations and attacks surface instead of silently disappearing.
|
|
2208
|
+
* - `'strip'` (legacy): silently drop the field and continue. Preserved for
|
|
2209
|
+
* apps that relied on the pre-2.9 behaviour — new code should not use it.
|
|
2210
|
+
*/
|
|
2211
|
+
type FieldWriteDenialPolicy = "reject" | "strip";
|
|
2212
|
+
interface BodySanitizerConfig {
|
|
2213
|
+
/** Schema options for field sanitization */
|
|
2214
|
+
schemaOptions: RouteSchemaOptions;
|
|
2855
2215
|
/**
|
|
2856
|
-
*
|
|
2857
|
-
*
|
|
2858
|
-
* Use this when tokens are in HttpOnly cookies, custom headers, or query params.
|
|
2859
|
-
*
|
|
2860
|
-
* @example
|
|
2861
|
-
* ```typescript
|
|
2862
|
-
* // Extract from HttpOnly cookie
|
|
2863
|
-
* tokenExtractor: (request) => request.cookies?.['auth-token'] ?? null,
|
|
2864
|
-
*
|
|
2865
|
-
* // Extract from custom header
|
|
2866
|
-
* tokenExtractor: (request) => request.headers['x-api-token'] as string ?? null,
|
|
2867
|
-
* ```
|
|
2216
|
+
* What to do when a request contains fields the caller can't write.
|
|
2217
|
+
* Default: `'reject'` — surface the misconfiguration as a 403.
|
|
2868
2218
|
*/
|
|
2869
|
-
|
|
2219
|
+
onFieldWriteDenied?: FieldWriteDenialPolicy;
|
|
2220
|
+
}
|
|
2221
|
+
declare class BodySanitizer {
|
|
2222
|
+
private schemaOptions;
|
|
2223
|
+
private onFieldWriteDenied;
|
|
2224
|
+
constructor(config: BodySanitizerConfig);
|
|
2870
2225
|
/**
|
|
2871
|
-
*
|
|
2872
|
-
*
|
|
2873
|
-
*
|
|
2874
|
-
* Arc provides this primitive — implement your own store (Redis set,
|
|
2875
|
-
* DB lookup, Better Auth session check, etc.)
|
|
2876
|
-
*
|
|
2877
|
-
* **Fail-closed**: if the check throws, the token is treated as revoked.
|
|
2878
|
-
*
|
|
2879
|
-
* @example
|
|
2880
|
-
* ```typescript
|
|
2881
|
-
* // Redis-backed revocation
|
|
2882
|
-
* isRevoked: async (decoded) => {
|
|
2883
|
-
* return await redis.sismember('revoked-tokens', decoded.jti ?? decoded.id);
|
|
2884
|
-
* },
|
|
2226
|
+
* Strip readonly and system-managed fields from request body.
|
|
2227
|
+
* Prevents clients from overwriting _id, timestamps, __v, etc.
|
|
2885
2228
|
*
|
|
2886
|
-
*
|
|
2887
|
-
*
|
|
2888
|
-
* const user = await db.user.findById(decoded.id);
|
|
2889
|
-
* return !user || user.bannedAt != null;
|
|
2890
|
-
* },
|
|
2891
|
-
* ```
|
|
2229
|
+
* Also applies field-level write permissions when the request has
|
|
2230
|
+
* field permission metadata.
|
|
2892
2231
|
*/
|
|
2893
|
-
|
|
2894
|
-
}
|
|
2895
|
-
interface IntrospectionPluginOptions {
|
|
2896
|
-
path?: string;
|
|
2897
|
-
prefix?: string;
|
|
2898
|
-
enabled?: boolean;
|
|
2899
|
-
authRoles?: string[];
|
|
2232
|
+
sanitize(body: AnyRecord, _operation: "create" | "update", req?: IRequestContext, meta?: ArcInternalMetadata): AnyRecord;
|
|
2900
2233
|
}
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
/**
|
|
2905
|
-
|
|
2906
|
-
/**
|
|
2907
|
-
|
|
2908
|
-
/**
|
|
2909
|
-
|
|
2910
|
-
/**
|
|
2911
|
-
|
|
2912
|
-
/**
|
|
2913
|
-
additionalRoutes?: AdditionalRoute[];
|
|
2914
|
-
/** Disable all default CRUD routes */
|
|
2915
|
-
disableDefaultRoutes?: boolean;
|
|
2916
|
-
/** Disable specific CRUD routes */
|
|
2917
|
-
disabledRoutes?: CrudRouteKey[];
|
|
2918
|
-
/** Functional pipeline (guard/transform/intercept) */
|
|
2919
|
-
pipe?: PipelineConfig;
|
|
2920
|
-
/** Resource name for lifecycle hooks */
|
|
2921
|
-
resourceName?: string;
|
|
2922
|
-
/** Schema generation options */
|
|
2234
|
+
//#endregion
|
|
2235
|
+
//#region src/core/QueryResolver.d.ts
|
|
2236
|
+
interface QueryResolverConfig {
|
|
2237
|
+
/** Query parser instance (default: Arc built-in parser) */
|
|
2238
|
+
queryParser?: QueryParserInterface;
|
|
2239
|
+
/** Maximum limit for pagination (default: 100) */
|
|
2240
|
+
maxLimit?: number;
|
|
2241
|
+
/** Default limit for pagination (default: 20) */
|
|
2242
|
+
defaultLimit?: number;
|
|
2243
|
+
/** Default sort field (default: '-createdAt') */
|
|
2244
|
+
defaultSort?: string;
|
|
2245
|
+
/** Schema options for field sanitization */
|
|
2923
2246
|
schemaOptions?: RouteSchemaOptions;
|
|
2924
|
-
/** Field-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2247
|
+
/** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable. */
|
|
2248
|
+
tenantField?: string | false;
|
|
2249
|
+
}
|
|
2250
|
+
declare class QueryResolver {
|
|
2251
|
+
private queryParser;
|
|
2252
|
+
private maxLimit;
|
|
2253
|
+
private defaultLimit;
|
|
2254
|
+
private defaultSort;
|
|
2255
|
+
private schemaOptions;
|
|
2256
|
+
private tenantField;
|
|
2257
|
+
constructor(config?: QueryResolverConfig);
|
|
2928
2258
|
/**
|
|
2929
|
-
*
|
|
2930
|
-
*
|
|
2931
|
-
* Set to `false` to disable rate limiting for this resource.
|
|
2259
|
+
* Resolve a request into parsed query options -- ONE parse per request.
|
|
2260
|
+
* Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
|
|
2932
2261
|
*/
|
|
2933
|
-
|
|
2934
|
-
/**
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
permissions?: PermissionCheck;
|
|
2953
|
-
raw?: boolean;
|
|
2954
|
-
schema?: Record<string, unknown>;
|
|
2955
|
-
}>;
|
|
2956
|
-
routes: Array<{
|
|
2957
|
-
method: string;
|
|
2958
|
-
path: string;
|
|
2959
|
-
handler?: string;
|
|
2960
|
-
operation?: string;
|
|
2961
|
-
summary?: string;
|
|
2962
|
-
}>;
|
|
2963
|
-
events?: string[];
|
|
2262
|
+
resolve(req: IRequestContext, meta?: ArcInternalMetadata): ControllerQueryOptions;
|
|
2263
|
+
/**
|
|
2264
|
+
* Sanitize select — preserves the input format (string, array, or object).
|
|
2265
|
+
* This is critical for db-agnostic support: MongoKit returns object projections,
|
|
2266
|
+
* Mongoose uses space-separated strings, SQL adapters may use arrays.
|
|
2267
|
+
*/
|
|
2268
|
+
private sanitizeSelectAny;
|
|
2269
|
+
/** Sanitize populate fields */
|
|
2270
|
+
private sanitizePopulate;
|
|
2271
|
+
/** Sanitize advanced populate options against allowedPopulate */
|
|
2272
|
+
private sanitizePopulateOptions;
|
|
2273
|
+
/**
|
|
2274
|
+
* Sanitize lookup/join options.
|
|
2275
|
+
* If schemaOptions.query.allowedLookups is set, only those collections are allowed.
|
|
2276
|
+
* Validates lookup structure to prevent injection.
|
|
2277
|
+
*/
|
|
2278
|
+
private sanitizeLookups;
|
|
2279
|
+
/** Get blocked fields from schema options */
|
|
2280
|
+
private getBlockedFields;
|
|
2964
2281
|
}
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
name: string;
|
|
2985
|
-
operations?: string[];
|
|
2986
|
-
}>;
|
|
2987
|
-
/** Update HTTP method(s) used for this resource */
|
|
2988
|
-
updateMethod?: 'PUT' | 'PATCH' | 'both';
|
|
2989
|
-
/** Routes disabled for this resource */
|
|
2990
|
-
disabledRoutes?: string[];
|
|
2991
|
-
/** Rate limit config */
|
|
2992
|
-
rateLimit?: RateLimitConfig | false;
|
|
2993
|
-
/** Per-resource audit opt-in flag (read by auditPlugin perResource mode) */
|
|
2994
|
-
audit?: boolean | {
|
|
2995
|
-
operations?: ("create" | "update" | "delete")[];
|
|
2996
|
-
};
|
|
2282
|
+
//#endregion
|
|
2283
|
+
//#region src/core/BaseController.d.ts
|
|
2284
|
+
interface BaseControllerOptions {
|
|
2285
|
+
/** Schema options for field sanitization */
|
|
2286
|
+
schemaOptions?: RouteSchemaOptions;
|
|
2287
|
+
/**
|
|
2288
|
+
* Query parser instance.
|
|
2289
|
+
* Default: Arc built-in query parser (adapter-agnostic).
|
|
2290
|
+
* Swap in MongoKit QueryParser, pgkit parser, etc.
|
|
2291
|
+
*/
|
|
2292
|
+
queryParser?: QueryParserInterface;
|
|
2293
|
+
/** Maximum limit for pagination (default: 100) */
|
|
2294
|
+
maxLimit?: number;
|
|
2295
|
+
/** Default limit for pagination (default: 20) */
|
|
2296
|
+
defaultLimit?: number;
|
|
2297
|
+
/** Default sort field (default: '-createdAt') */
|
|
2298
|
+
defaultSort?: string;
|
|
2299
|
+
/** Resource name for hook execution (e.g., 'product' -> 'product.created') */
|
|
2300
|
+
resourceName?: string;
|
|
2997
2301
|
/**
|
|
2998
|
-
*
|
|
2302
|
+
* Field name used for multi-tenant scoping (default: 'organizationId').
|
|
2303
|
+
* Override to match your schema: 'workspaceId', 'tenantId', 'teamId', etc.
|
|
2304
|
+
* Set to `false` to disable org filtering for platform-universal resources.
|
|
2305
|
+
*/
|
|
2306
|
+
tenantField?: string | false;
|
|
2307
|
+
/**
|
|
2308
|
+
* Primary key field name (default: '_id').
|
|
2309
|
+
*
|
|
2310
|
+
* If not set, the controller auto-derives it from the repository's own
|
|
2311
|
+
* `idField` property (e.g. MongoKit's `Repository({ idField: 'id' })`),
|
|
2312
|
+
* so you only need to configure it in one place.
|
|
2999
2313
|
*
|
|
3000
|
-
*
|
|
3001
|
-
*
|
|
2314
|
+
* Set explicitly to override the repo's setting (e.g. `'_id'` to opt out
|
|
2315
|
+
* of native pass-through and force the slug-translation path).
|
|
3002
2316
|
*
|
|
3003
|
-
*
|
|
2317
|
+
* Override for non-MongoDB adapters (e.g., 'id' for SQL databases).
|
|
3004
2318
|
*/
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
2319
|
+
idField?: string;
|
|
2320
|
+
/**
|
|
2321
|
+
* Custom filter matching for policy enforcement.
|
|
2322
|
+
* Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
|
|
2323
|
+
* Falls back to built-in MongoDB-style matching if not provided.
|
|
2324
|
+
*/
|
|
2325
|
+
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
2326
|
+
/** Cache configuration for the resource */
|
|
2327
|
+
cache?: ResourceCacheConfig;
|
|
2328
|
+
/** Internal preset fields map (slug, tree, etc.) */
|
|
2329
|
+
presetFields?: {
|
|
2330
|
+
slugField?: string;
|
|
2331
|
+
parentField?: string;
|
|
2332
|
+
};
|
|
3015
2333
|
/**
|
|
3016
|
-
*
|
|
3017
|
-
* permissions. Used by OpenAPI to determine auth requirements and by MCP
|
|
3018
|
-
* as the fallback in `createActionToolHandler`.
|
|
2334
|
+
* Policy for requests that include fields the caller can't write.
|
|
3019
2335
|
*
|
|
3020
|
-
*
|
|
3021
|
-
*
|
|
2336
|
+
* - `'reject'` (default): 403 with the denied field names. Surfaces
|
|
2337
|
+
* misconfigurations and attempts to set protected fields instead of
|
|
2338
|
+
* silently dropping them.
|
|
2339
|
+
* - `'strip'`: legacy silent-drop behaviour. Only opt in when migrating
|
|
2340
|
+
* code that relied on the pre-2.9 permissive default.
|
|
3022
2341
|
*/
|
|
3023
|
-
|
|
3024
|
-
}
|
|
3025
|
-
interface RegistryStats {
|
|
3026
|
-
total?: number;
|
|
3027
|
-
totalResources: number;
|
|
3028
|
-
byTag?: Record<string, number>;
|
|
3029
|
-
byModule?: Record<string, number>;
|
|
3030
|
-
presetUsage?: Record<string, number>;
|
|
3031
|
-
totalRoutes?: number;
|
|
3032
|
-
totalEvents?: number;
|
|
3033
|
-
}
|
|
3034
|
-
interface IntrospectionData {
|
|
3035
|
-
resources: ResourceMetadata[];
|
|
3036
|
-
stats: RegistryStats;
|
|
3037
|
-
generatedAt?: string;
|
|
3038
|
-
}
|
|
3039
|
-
interface EventDefinition {
|
|
3040
|
-
name: string;
|
|
3041
|
-
/** Optional handler — events are published via fastify.events.publish(), not invoked through resource definitions */
|
|
3042
|
-
handler?: (data: unknown) => Promise<void> | void;
|
|
3043
|
-
schema?: Record<string, unknown>;
|
|
3044
|
-
description?: string;
|
|
3045
|
-
}
|
|
3046
|
-
interface ConfigError {
|
|
3047
|
-
field: string;
|
|
3048
|
-
message: string;
|
|
3049
|
-
code?: string;
|
|
3050
|
-
}
|
|
3051
|
-
interface ValidationResult$1 {
|
|
3052
|
-
valid: boolean;
|
|
3053
|
-
errors: ConfigError[];
|
|
3054
|
-
}
|
|
3055
|
-
interface ValidateOptions {
|
|
3056
|
-
strict?: boolean;
|
|
2342
|
+
onFieldWriteDenied?: FieldWriteDenialPolicy;
|
|
3057
2343
|
}
|
|
3058
2344
|
/**
|
|
3059
|
-
*
|
|
3060
|
-
*/
|
|
3061
|
-
type InferDocType<T> = T extends DataAdapter<infer D> ? D : T extends ResourceConfig<infer D> ? D : never;
|
|
3062
|
-
/**
|
|
3063
|
-
* Infer document type from a DataAdapter.
|
|
3064
|
-
* Falls back to `unknown` (not `never`) — safe for generic constraints.
|
|
3065
|
-
*
|
|
3066
|
-
* @example
|
|
3067
|
-
* ```typescript
|
|
3068
|
-
* const adapter = createMongooseAdapter({ model: ProductModel, repository: productRepo });
|
|
3069
|
-
* type ProductDoc = InferAdapterDoc<typeof adapter>;
|
|
3070
|
-
* // ProductDoc = the document type inferred from the adapter
|
|
3071
|
-
* ```
|
|
3072
|
-
*/
|
|
3073
|
-
type InferAdapterDoc<A> = A extends DataAdapter<infer D> ? D : unknown;
|
|
3074
|
-
type InferResourceDoc<T> = T extends ResourceConfig<infer D> ? D : never;
|
|
3075
|
-
type TypedResourceConfig<TDoc> = ResourceConfig<TDoc>;
|
|
3076
|
-
type TypedController<TDoc> = IController<TDoc>;
|
|
3077
|
-
type TypedRepository<TDoc> = CrudRepository<TDoc>;
|
|
3078
|
-
//#endregion
|
|
3079
|
-
//#region src/adapters/interface.d.ts
|
|
3080
|
-
/**
|
|
3081
|
-
* Minimal structural repository shape for flexible adapter compatibility.
|
|
3082
|
-
*
|
|
3083
|
-
* `RepositoryLike` is the **loose** variant of `CrudRepository<TDoc>` — it
|
|
3084
|
-
* uses `unknown` for document payloads so any object with the right method
|
|
3085
|
-
* names satisfies it without type assertions. Prefer `CrudRepository<TDoc>`
|
|
3086
|
-
* for kits you own; use `RepositoryLike` when wrapping third-party repos.
|
|
3087
|
-
*
|
|
3088
|
-
* Both interfaces declare the same tiered capabilities:
|
|
2345
|
+
* Framework-agnostic base controller implementing IController.
|
|
3089
2346
|
*
|
|
3090
|
-
*
|
|
3091
|
-
*
|
|
3092
|
-
*
|
|
3093
|
-
* - **Optional** — feature-detected at runtime by presets and the
|
|
3094
|
-
* BaseController. Declare only what your DB supports.
|
|
2347
|
+
* Composes AccessControl, BodySanitizer, and QueryResolver for clean
|
|
2348
|
+
* separation of concerns. CRUD methods delegate directly to these
|
|
2349
|
+
* composed classes — no intermediate wrapper methods.
|
|
3095
2350
|
*
|
|
3096
|
-
*
|
|
3097
|
-
*
|
|
2351
|
+
* @template TDoc - The document type
|
|
2352
|
+
* @template TRepository - The repository type (defaults to RepositoryLike)
|
|
3098
2353
|
*/
|
|
3099
|
-
|
|
2354
|
+
declare class BaseController<TDoc = AnyRecord, TRepository extends RepositoryLike = RepositoryLike> implements IController<TDoc> {
|
|
2355
|
+
protected repository: TRepository;
|
|
2356
|
+
protected schemaOptions: RouteSchemaOptions;
|
|
2357
|
+
protected queryParser: QueryParserInterface;
|
|
2358
|
+
protected maxLimit: number;
|
|
2359
|
+
protected defaultLimit: number;
|
|
2360
|
+
protected defaultSort: string;
|
|
2361
|
+
protected resourceName?: string;
|
|
2362
|
+
protected tenantField: string | false;
|
|
2363
|
+
protected idField: string;
|
|
2364
|
+
/** Composable access control (ID filtering, policy checks, org scope, ownership) */
|
|
2365
|
+
readonly accessControl: AccessControl;
|
|
2366
|
+
/** Composable body sanitization (field permissions, system fields) */
|
|
2367
|
+
readonly bodySanitizer: BodySanitizer;
|
|
2368
|
+
/** Composable query resolution (parsing, pagination, sort, select/populate) */
|
|
2369
|
+
readonly queryResolver: QueryResolver;
|
|
2370
|
+
private _matchesFilter?;
|
|
2371
|
+
private _presetFields;
|
|
2372
|
+
private _cacheConfig?;
|
|
2373
|
+
constructor(repository: TRepository, options?: BaseControllerOptions);
|
|
2374
|
+
/**
|
|
2375
|
+
* Get the tenant field name if multi-tenant scoping is enabled.
|
|
2376
|
+
* Returns `undefined` when `tenantField` is `false` (platform-universal mode).
|
|
2377
|
+
*
|
|
2378
|
+
* Use this in subclass overrides instead of accessing `this.tenantField` directly
|
|
2379
|
+
* to avoid TypeScript indexing errors with `string | false`.
|
|
2380
|
+
*/
|
|
2381
|
+
protected getTenantField(): string | undefined;
|
|
2382
|
+
/** Extract typed Arc internal metadata from request */
|
|
2383
|
+
private meta;
|
|
2384
|
+
/** Get hook system from request context (instance-scoped) */
|
|
2385
|
+
private getHooks;
|
|
3100
2386
|
/**
|
|
3101
|
-
*
|
|
3102
|
-
*
|
|
3103
|
-
*
|
|
2387
|
+
* Resolve the repository primary key for mutation calls (update/delete/restore).
|
|
2388
|
+
*
|
|
2389
|
+
* When the resource declares a custom `idField` (e.g. `slug`, `jobId`, UUID),
|
|
2390
|
+
* the default behavior is to translate the route id → the fetched doc's `_id`
|
|
2391
|
+
* because most Mongo repositories key their mutation methods off `_id`.
|
|
3104
2392
|
*
|
|
3105
|
-
*
|
|
3106
|
-
*
|
|
3107
|
-
*
|
|
3108
|
-
*
|
|
3109
|
-
* repos that don't key on `_id`.
|
|
2393
|
+
* Exception: if the repository itself exposes a matching `idField` property
|
|
2394
|
+
* (e.g. MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
|
|
2395
|
+
* repository already knows how to look up by that field — so we pass the
|
|
2396
|
+
* route id through unchanged and skip the translation.
|
|
3110
2397
|
*
|
|
3111
|
-
*
|
|
2398
|
+
* This makes `defineResource({ idField: 'id' })` work end-to-end with repos
|
|
2399
|
+
* that natively support custom primary keys, without breaking the slug-style
|
|
2400
|
+
* aliasing that Arc 2.6.3 introduced for repos keyed on `_id`.
|
|
3112
2401
|
*/
|
|
3113
|
-
|
|
3114
|
-
getAll(params?: PaginationParams, options?: QueryOptions): Promise<unknown>;
|
|
3115
|
-
getById(id: string, options?: QueryOptions): Promise<unknown>;
|
|
3116
|
-
create(data: unknown, options?: WriteOptions): Promise<unknown>;
|
|
3117
|
-
update(id: string, data: unknown, options?: WriteOptions): Promise<unknown>;
|
|
3118
|
-
/**
|
|
3119
|
-
* Delete by primary key. Pass `{ mode: 'hard' }` to bypass soft-delete
|
|
3120
|
-
* interception (required by arc's hard-delete flow — `?hard=true` on
|
|
3121
|
-
* the DELETE route forwards this option).
|
|
3122
|
-
*/
|
|
3123
|
-
delete(id: string, options?: DeleteOptions): Promise<unknown>;
|
|
3124
|
-
/**
|
|
3125
|
-
* Find a single doc by compound filter. Used by AccessControl for
|
|
3126
|
-
* `idField + org + policy` scoping. Without this, arc falls back to
|
|
3127
|
-
* `getById` + post-fetch security checks (slower, and 404s on custom
|
|
3128
|
-
* idFields that live outside the user's scope).
|
|
3129
|
-
*/
|
|
3130
|
-
getOne?(filter: Record<string, unknown>, options?: QueryOptions): Promise<unknown>;
|
|
3131
|
-
/** Alias many kits expose alongside `getOne`. Arc checks both. */
|
|
3132
|
-
getByQuery?(filter: Record<string, unknown>, options?: QueryOptions): Promise<unknown>;
|
|
3133
|
-
count?(filter?: Record<string, unknown>, options?: QueryOptions): Promise<number>;
|
|
3134
|
-
exists?(filter: Record<string, unknown>, options?: QueryOptions): Promise<boolean | {
|
|
3135
|
-
_id: unknown;
|
|
3136
|
-
} | null>;
|
|
3137
|
-
distinct?<T = unknown>(field: string, filter?: Record<string, unknown>, options?: QueryOptions): Promise<T[]>;
|
|
3138
|
-
findAll?(filter?: Record<string, unknown>, options?: QueryOptions): Promise<unknown[]>;
|
|
3139
|
-
getOrCreate?(filter: Record<string, unknown>, data: unknown, options?: WriteOptions): Promise<unknown>;
|
|
3140
|
-
createMany?(items: unknown[], options?: WriteOptions): Promise<unknown[]>;
|
|
3141
|
-
updateMany?(filter: Record<string, unknown>, data: Record<string, unknown>, options?: WriteOptions): Promise<UpdateManyResult>;
|
|
3142
|
-
deleteMany?(filter: Record<string, unknown>, options?: DeleteOptions): Promise<DeleteManyResult>;
|
|
3143
|
-
bulkWrite?: unknown;
|
|
3144
|
-
restore?(id: string, options?: QueryOptions): Promise<unknown>;
|
|
3145
|
-
getDeleted?(params?: PaginationParams, options?: QueryOptions): Promise<PaginationResult<unknown> | unknown[]>;
|
|
3146
|
-
aggregate?: unknown;
|
|
3147
|
-
aggregatePaginate?: unknown;
|
|
3148
|
-
withTransaction?<T>(callback: (session: RepositorySession) => Promise<T>, options?: Record<string, unknown>): Promise<T>;
|
|
3149
|
-
getBySlug?(slug: string, options?: QueryOptions): Promise<unknown>;
|
|
3150
|
-
getTree?(options?: QueryOptions): Promise<unknown>;
|
|
3151
|
-
getChildren?(parentId: string, options?: QueryOptions): Promise<unknown>;
|
|
3152
|
-
[key: string]: unknown;
|
|
3153
|
-
}
|
|
3154
|
-
interface DataAdapter<TDoc = unknown> {
|
|
2402
|
+
private resolveRepoId;
|
|
3155
2403
|
/**
|
|
3156
|
-
*
|
|
3157
|
-
* `
|
|
3158
|
-
*
|
|
2404
|
+
* Centralized 404 response builder. Maps the denial reason from
|
|
2405
|
+
* `fetchDetailed()` into a structured `details.code` so consumers can
|
|
2406
|
+
* programmatically distinguish "doc doesn't exist" from "doc filtered
|
|
2407
|
+
* by policy/org scope" without parsing error strings.
|
|
2408
|
+
*
|
|
2409
|
+
* Error messages are intentionally vague in the `error` field (don't
|
|
2410
|
+
* leak whether the doc exists) — the detail is in `details.code` only.
|
|
3159
2411
|
*/
|
|
3160
|
-
|
|
3161
|
-
/**
|
|
3162
|
-
|
|
3163
|
-
/**
|
|
3164
|
-
|
|
2412
|
+
private notFoundResponse;
|
|
2413
|
+
/** Resolve cache config for a specific operation, merging per-op overrides */
|
|
2414
|
+
private resolveCacheConfig;
|
|
2415
|
+
/**
|
|
2416
|
+
* Extract user/org IDs from request for cache key scoping.
|
|
2417
|
+
* Only includes orgId when this resource uses tenant-scoped data (tenantField is set).
|
|
2418
|
+
* Universal resources (tenantField: false) get shared cache keys to avoid fragmentation.
|
|
2419
|
+
*/
|
|
2420
|
+
private cacheScope;
|
|
2421
|
+
list(req: IRequestContext): Promise<IControllerResponse<OffsetPaginationResult<TDoc>>>;
|
|
2422
|
+
/** Execute list query through hooks (extracted for cache revalidation) */
|
|
2423
|
+
private executeListQuery;
|
|
2424
|
+
get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
2425
|
+
/** Execute get query through hooks (extracted for cache revalidation) */
|
|
2426
|
+
private executeGetQuery;
|
|
2427
|
+
create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
2428
|
+
update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
2429
|
+
delete(req: IRequestContext): Promise<IControllerResponse<{
|
|
2430
|
+
message: string;
|
|
2431
|
+
id?: string;
|
|
2432
|
+
soft?: boolean;
|
|
2433
|
+
}>>;
|
|
2434
|
+
getBySlug(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
2435
|
+
getDeleted(req: IRequestContext): Promise<IControllerResponse<PaginationResult<TDoc>>>;
|
|
2436
|
+
restore(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
2437
|
+
getTree(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
2438
|
+
getChildren(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
2439
|
+
bulkCreate(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
3165
2440
|
/**
|
|
3166
|
-
*
|
|
2441
|
+
* Build a tenant-scoped filter for bulk update/delete.
|
|
3167
2442
|
*
|
|
3168
|
-
*
|
|
3169
|
-
*
|
|
2443
|
+
* Mirrors `AccessControl.buildIdFilter` semantics for single-doc ops:
|
|
2444
|
+
* - Always merge `_policyFilters` (from permission middleware)
|
|
2445
|
+
* - When `tenantField` is set AND a `member` scope is present, add the
|
|
2446
|
+
* org filter so cross-tenant data can't be touched.
|
|
2447
|
+
* - When the scope is `elevated` (platform admin), no org filter is
|
|
2448
|
+
* applied — admins can bulk-update across orgs intentionally.
|
|
2449
|
+
* - When the scope is `public` on a tenant-scoped resource, deny.
|
|
2450
|
+
* - When NO scope is present at all (e.g., direct controller calls in
|
|
2451
|
+
* unit tests, or app routes without auth middleware), the controller
|
|
2452
|
+
* stays lenient — it's the middleware layer's job to fail-close.
|
|
2453
|
+
* Apps that want fail-close on bulk routes should run the multi-tenant
|
|
2454
|
+
* preset middleware (or equivalent) ahead of these handlers.
|
|
3170
2455
|
*
|
|
3171
|
-
*
|
|
3172
|
-
* @param context - Resource-level context: idField (for params schema), resourceName.
|
|
3173
|
-
* Adapters should honor `context.idField` when producing the params
|
|
3174
|
-
* schema — e.g. skip the ObjectId pattern when idField is a custom
|
|
3175
|
-
* string field. Backwards compatible: legacy adapters ignoring the
|
|
3176
|
-
* context still work because Arc strips the mismatched pattern as
|
|
3177
|
-
* a safety net.
|
|
3178
|
-
* @returns OpenAPI schemas for CRUD operations or null if not supported
|
|
2456
|
+
* Returns the merged filter, or `null` when access must be denied.
|
|
3179
2457
|
*/
|
|
3180
|
-
|
|
3181
|
-
/** Extract schema metadata for OpenAPI/introspection */
|
|
3182
|
-
getSchemaMetadata?(): SchemaMetadata | null;
|
|
3183
|
-
/** Validate data against schema before persistence */
|
|
3184
|
-
validate?(data: unknown): Promise<ValidationResult> | ValidationResult;
|
|
3185
|
-
/** Health check for database connection */
|
|
3186
|
-
healthCheck?(): Promise<boolean>;
|
|
2458
|
+
private buildBulkFilter;
|
|
3187
2459
|
/**
|
|
3188
|
-
*
|
|
3189
|
-
*
|
|
3190
|
-
*
|
|
2460
|
+
* Sanitize a bulk update data payload through the same write-permission
|
|
2461
|
+
* pipeline as single-doc update(). Handles both shapes:
|
|
2462
|
+
*
|
|
2463
|
+
* - Flat: `{ name: 'x', status: 'y' }`
|
|
2464
|
+
* - Mongo operator: `{ $set: { name: 'x' }, $inc: { views: 1 }, $unset: { tag: '' } }`
|
|
2465
|
+
*
|
|
2466
|
+
* For each operand, runs `bodySanitizer.sanitize('update', ...)` so that
|
|
2467
|
+
* system fields, systemManaged/readonly/immutable rules, AND field-level
|
|
2468
|
+
* write permissions are enforced. Without this, a tenant-scoped user could
|
|
2469
|
+
* pass `{ $set: { organizationId: 'org-b' } }` to move records across orgs.
|
|
2470
|
+
*
|
|
2471
|
+
* Returns the sanitized payload along with the list of stripped fields for
|
|
2472
|
+
* audit/error reporting.
|
|
3191
2473
|
*/
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
}
|
|
3218
|
-
interface FieldMetadata {
|
|
3219
|
-
type: "string" | "number" | "boolean" | "date" | "object" | "array" | "objectId" | "enum";
|
|
3220
|
-
required?: boolean;
|
|
3221
|
-
unique?: boolean;
|
|
3222
|
-
default?: unknown;
|
|
3223
|
-
enum?: Array<string | number>;
|
|
3224
|
-
min?: number;
|
|
3225
|
-
max?: number;
|
|
3226
|
-
minLength?: number;
|
|
3227
|
-
maxLength?: number;
|
|
3228
|
-
pattern?: string;
|
|
3229
|
-
description?: string;
|
|
3230
|
-
ref?: string;
|
|
3231
|
-
array?: boolean;
|
|
3232
|
-
}
|
|
3233
|
-
interface RelationMetadata {
|
|
3234
|
-
type: "one-to-one" | "one-to-many" | "many-to-many";
|
|
3235
|
-
target: string;
|
|
3236
|
-
foreignKey?: string;
|
|
3237
|
-
through?: string;
|
|
3238
|
-
}
|
|
3239
|
-
interface ValidationResult {
|
|
3240
|
-
valid: boolean;
|
|
3241
|
-
errors?: Array<{
|
|
3242
|
-
field: string;
|
|
3243
|
-
message: string;
|
|
3244
|
-
code?: string;
|
|
3245
|
-
}>;
|
|
2474
|
+
private sanitizeBulkUpdateData;
|
|
2475
|
+
bulkUpdate(req: IRequestContext): Promise<IControllerResponse<{
|
|
2476
|
+
matchedCount: number;
|
|
2477
|
+
modifiedCount: number;
|
|
2478
|
+
}>>;
|
|
2479
|
+
/**
|
|
2480
|
+
* Bulk delete by `filter` or `ids`.
|
|
2481
|
+
*
|
|
2482
|
+
* Body shape (one of):
|
|
2483
|
+
* - `{ filter: { status: 'archived' } }` — delete by query filter
|
|
2484
|
+
* - `{ ids: ['id1', 'id2', 'id3'] }` — delete specific docs by id
|
|
2485
|
+
*
|
|
2486
|
+
* The `ids` form translates to `{ [idField]: { $in: ids } }` using the
|
|
2487
|
+
* resource's `idField` (so it works with custom PKs like `slug`, `jobId`,
|
|
2488
|
+
* UUID, etc.). Tenant scope and policy filters are merged in either way,
|
|
2489
|
+
* so cross-tenant deletes are blocked at the controller layer.
|
|
2490
|
+
*
|
|
2491
|
+
* Both forms perform a single `repo.deleteMany()` DB call — no per-doc
|
|
2492
|
+
* fetch loop. Per-doc lifecycle hooks (`before:delete`/`after:delete`) do
|
|
2493
|
+
* NOT fire for bulk operations; use the single-doc `delete()` if you need
|
|
2494
|
+
* them, or subscribe to the bulk lifecycle event from the events plugin.
|
|
2495
|
+
*/
|
|
2496
|
+
bulkDelete(req: IRequestContext): Promise<IControllerResponse<{
|
|
2497
|
+
deletedCount: number;
|
|
2498
|
+
}>>;
|
|
3246
2499
|
}
|
|
3247
|
-
type AdapterFactory<TDoc> = (config: unknown) => DataAdapter<TDoc>;
|
|
3248
2500
|
//#endregion
|
|
3249
|
-
export {
|
|
2501
|
+
export { CrudSchemas as $, HookPhase as $t, AuthHelpers as A, SchemaMetadata as At, FastifyWithDecorators as B, ArcInternalMetadata as Bt, ResourceMetadata as C, RouteHandler as Ct, HealthOptions as D, FieldMetadata as Dt, HealthCheck as E, DataAdapter as Et, TokenPair as F, OperationFilter as Ft, ResourceDefinition as G, PopulateOption as Gt, RequestWithExtras as H, LookupOption as Ht, ArcDecorator as I, PipelineConfig as It, ActionEntry as J, ServiceContext as Jt, defineResource as K, QueryParserInterface as Kt, EventsDecorator as L, PipelineContext as Lt, Authenticator as M, Guard as Mt, AuthenticatorContext as N, Interceptor as Nt, IntrospectionPluginOptions as O, RelationMetadata as Ot, JwtContext as P, NextFunction as Pt, CrudRouteKey as Q, HookOperation as Qt, FastifyRequestExtras as R, PipelineStep as Rt, RegistryStats as S, IRequestContext as St, GracefulShutdownOptions as T, AdapterSchemaContext as Tt, RegisterOptions as U, OwnershipCheck as Ut, MiddlewareHandler as V, ControllerQueryOptions as Vt, ResourceRegistry as W, ParsedQuery as Wt, ActionsMap as X, HookContext as Xt, ActionHandlerFn as Y, DefineHookOptions as Yt, CrudController as Z, HookHandler as Zt, ConfigError as _, UserOrganization as _n, ControllerHandler as _t, QueryResolverConfig as a, afterUpdate as an, PresetHook as at, IntrospectionData as b, IController as bt, AccessControl as c, beforeUpdate as cn, ResourceCacheConfig as ct, InferAdapterDoc as d, AnyRecord as dn, ResourceHooks as dt, HookRegistration as en, EventDefinition as et, InferDocType as f, ApiResponse as fn, ResourcePermissions as ft, TypedResourceConfig as g, UserLike as gn, RouteSchemaOptions as gt, TypedRepository as h, ObjectId as hn, RouteMethod as ht, QueryResolver as i, afterDelete as in, PresetFunction as it, AuthPluginOptions as j, ValidationResult$1 as jt, RequestIdOptions as k, RepositoryLike as kt, AccessControlConfig as l, createHookSystem as ln, ResourceConfig as lt, TypedController as m, JWTPayload as mn, RouteMcpConfig as mt, BaseController as n, HookSystemOptions as nn, MiddlewareConfig as nt, BodySanitizer as o, beforeCreate as on, PresetResult as ot, InferResourceDoc as p, ArcRequest as pn, RouteDefinition as pt, ActionDefinition as q, RequestContext as qt, BaseControllerOptions as r, afterCreate as rn, OpenApiSchemas as rt, BodySanitizerConfig as s, beforeDelete as sn, RateLimitConfig as st, RouteHandlerMethod$1 as t, HookSystem as tn, FieldRule as tt, PaginationResult as u, defineHook as un, ResourceHookContext as ut, ValidateOptions as v, envelope as vn, ControllerLike as vt, CrudRouterOptions as w, AdapterFactory as wt, RegistryEntry as x, IControllerResponse as xt, ValidationResult as y, getUserId as yn, FastifyHandler as yt, FastifyWithAuth as z, Transform as zt };
|