@classytic/arc 2.10.3 → 2.10.8
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 +1 -1
- package/dist/{BaseController-CbKKIflT.mjs → BaseController-DVNKvoX4.mjs} +151 -131
- package/dist/actionPermissions-TUVR3uiZ.mjs +22 -0
- package/dist/adapters/index.d.mts +2 -2
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +15 -17
- package/dist/auth/index.d.mts +4 -4
- package/dist/auth/index.mjs +5 -5
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/cache/index.d.mts +2 -2
- package/dist/cache/index.mjs +3 -3
- 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/context/index.d.mts +58 -0
- package/dist/context/index.mjs +2 -0
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +2 -2
- package/dist/{core-CcR01lup.mjs → core-3MWJosCH.mjs} +139 -91
- package/dist/{createApp-BuvPma24.mjs → createApp-BwnEAO2h.mjs} +54 -20
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-C7hgL_aI.mjs → elevation-Dci0AYLT.mjs} +2 -2
- package/dist/errorHandler-2ii4RIYr.d.mts +114 -0
- package/dist/{errorHandler-Bb49BvPD.mjs → errorHandler-CSxe7KIM.mjs} +1 -1
- package/dist/{eventPlugin-DCUjuiQT.mjs → eventPlugin-ByU4Cv0e.mjs} +1 -1
- package/dist/{eventPlugin-CxWgpd6K.d.mts → eventPlugin-D1ThQ1Pp.d.mts} +1 -1
- package/dist/events/index.d.mts +4 -4
- package/dist/events/index.mjs +69 -51
- 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/{fields-Lo1VUDpt.d.mts → fields-C8Y0XLAu.d.mts} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +3 -3
- package/dist/idempotency/index.mjs +38 -27
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-Cl0uoKd5.d.mts → index-BGbpGVyM.d.mts} +2362 -2155
- package/dist/{index-DStwgFUK.d.mts → index-BziRPS4H.d.mts} +1 -1
- package/dist/{index-ChIw3776.d.mts → index-C_Noptz-.d.mts} +3 -3
- package/dist/{index-8qw4y6ff.d.mts → index-EqQN6p0W.d.mts} +3 -3
- package/dist/index.d.mts +7 -219
- package/dist/index.mjs +8 -128
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/logger/index.d.mts +81 -0
- package/dist/{logger-DLg8-Ueg.mjs → logger/index.mjs} +1 -6
- package/dist/middleware/index.d.mts +109 -0
- package/dist/middleware/index.mjs +70 -0
- package/dist/multipartBody-CUQGVlM_.mjs +123 -0
- package/dist/{openapi-B5F8AddX.mjs → openapi-DpNpqBmo.mjs} +9 -7
- package/dist/org/index.d.mts +2 -2
- package/dist/permissions/index.d.mts +2 -2
- package/dist/permissions/index.mjs +3 -3
- package/dist/{permissions-Dk6mshja.mjs → permissions-wkqRwicB.mjs} +2 -2
- package/dist/pipe-CGJxqDGx.mjs +62 -0
- package/dist/pipeline/index.d.mts +62 -0
- package/dist/pipeline/index.mjs +53 -0
- package/dist/plugins/index.d.mts +25 -5
- package/dist/plugins/index.mjs +9 -9
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +4 -4
- package/dist/presets/filesUpload.mjs +255 -1
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +2 -2
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +42 -8
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-fLJVXdVn.mjs → presets-CrwOvuXI.mjs} +1 -1
- package/dist/{queryCachePlugin-DQCEfJis.mjs → queryCachePlugin-ChLNZvFT.mjs} +2 -2
- package/dist/{queryCachePlugin-BKbWjgDG.d.mts → queryCachePlugin-Dumka73q.d.mts} +1 -1
- package/dist/{queryParser-DBqBB6AC.mjs → queryParser-NR__Qiju.mjs} +68 -1
- package/dist/{redis-DqyeggCa.d.mts → redis-MXLp1oOf.d.mts} +1 -1
- package/dist/{redis-stream-CakIQmwR.d.mts → redis-stream-bkO88VHx.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{requestContext-xHIKedG6.mjs → requestContext-C38GskNt.mjs} +1 -1
- package/dist/{resourceToTools-BElv3xPT.mjs → resourceToTools-BhF3JV5p.mjs} +8 -3
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +2 -2
- package/dist/{sse-yBCgOLGu.mjs → sse-D8UeDwis.mjs} +1 -1
- package/dist/{store-helpers-ZCSMJJAX.mjs → store-helpers-DYYUQbQN.mjs} +4 -0
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/index.mjs +11 -2
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/index.mjs +1 -1
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-Btdda02s.d.mts → types-CVKBssX5.d.mts} +1 -1
- package/dist/{types-Co8k3NyS.d.mts → types-CVdgPXBW.d.mts} +22 -9
- package/dist/utils/index.d.mts +73 -3
- package/dist/utils/index.mjs +4 -4
- package/dist/{utils-B2fNOD_i.mjs → utils-LMwVidKy.mjs} +20 -2
- package/dist/versioning-CeUXHfjw.d.mts +117 -0
- package/package.json +22 -6
- package/skills/arc/SKILL.md +1 -1
- package/dist/errorHandler-DRQ3EqfL.d.mts +0 -218
- package/dist/filesUpload-t21LS-py.mjs +0 -377
- /package/dist/{EventTransport-CUw5NNWe.d.mts → EventTransport-CfVEGaEl.d.mts} +0 -0
- /package/dist/{HookSystem-BNYKnrXF.mjs → HookSystem-BjFu7zf1.mjs} +0 -0
- /package/dist/{ResourceRegistry-BPd6NQDm.mjs → ResourceRegistry-CcN2LVrc.mjs} +0 -0
- /package/dist/{betterAuthOpenApi-BBRVhjQN.mjs → betterAuthOpenApi--rdY15Ld.mjs} +0 -0
- /package/dist/{caching-CBpK_SCM.mjs → caching-3h93rkJM.mjs} +0 -0
- /package/dist/{createActionRouter-Bp_5c_2b.mjs → createActionRouter-C8UUB3Px.mjs} +0 -0
- /package/dist/{elevation-C5SwtkAn.d.mts → elevation-s5ykdNHr.d.mts} +0 -0
- /package/dist/{errors-CCSsMpXE.d.mts → errors-BI8kEKsO.d.mts} +0 -0
- /package/dist/{errors-D5c-5BJL.mjs → errors-BqdUDja_.mjs} +0 -0
- /package/dist/{externalPaths-BQ8QijNH.d.mts → externalPaths-Bapitwvd.d.mts} +0 -0
- /package/dist/{fields-bxkeltzz.mjs → fields-CTMWOUDt.mjs} +0 -0
- /package/dist/{interface-CSbZdv_3.d.mts → interface-B-pe8fhj.d.mts} +0 -0
- /package/dist/{interface-D218ikEo.d.mts → interface-yhyb_pLY.d.mts} +0 -0
- /package/dist/{keys-qcD-TVJl.mjs → keys-nWQGUTu1.mjs} +0 -0
- /package/dist/{loadResources-BAzJItAJ.mjs → loadResources-Bksk8ydA.mjs} +0 -0
- /package/dist/{memory-B5Amv9A1.mjs → memory-DqI-449b.mjs} +0 -0
- /package/dist/{metrics-DuhiSEZI.mjs → metrics-TuOmguhi.mjs} +0 -0
- /package/dist/{pluralize-A0tWEl1K.mjs → pluralize-CWP6MB39.mjs} +0 -0
- /package/dist/{registry-B3lRFBWo.mjs → registry-B0Wl7uVV.mjs} +0 -0
- /package/dist/{replyHelpers-CXtJDAZ0.mjs → replyHelpers-BLojtuvR.mjs} +0 -0
- /package/dist/{sessionManager-BkzVU8h2.d.mts → sessionManager-D-oNWHz3.d.mts} +0 -0
- /package/dist/{storage-CVk_SEn2.d.mts → storage-BwGQXUpd.d.mts} +0 -0
- /package/dist/{tracing-65B51Dw3.d.mts → tracing-xqXzWeaf.d.mts} +0 -0
- /package/dist/{types-Csi3FLfq.mjs → types-CDnTEpga.mjs} +0 -0
- /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
- /package/dist/{types-BD85MlEK.d.mts → types-tgR4Pt8F.d.mts} +0 -0
- /package/dist/{versioning-C2U_bLY0.mjs → versioning-B6mimogM.mjs} +0 -0
|
@@ -1,1383 +1,1732 @@
|
|
|
1
|
-
import { r as RequestScope } from "./types-
|
|
2
|
-
import { c as PermissionCheck, d as UserBase, n as FieldPermissionMap } from "./fields-
|
|
3
|
-
import { n as DomainEvent } from "./EventTransport-
|
|
1
|
+
import { r as RequestScope } from "./types-tgR4Pt8F.mjs";
|
|
2
|
+
import { c as PermissionCheck, d as UserBase, n as FieldPermissionMap } from "./fields-C8Y0XLAu.mjs";
|
|
3
|
+
import { n as DomainEvent } from "./EventTransport-CfVEGaEl.mjs";
|
|
4
4
|
import { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest, RouteHandlerMethod, RouteHandlerMethod as RouteHandlerMethod$1 } from "fastify";
|
|
5
5
|
import * as _$_classytic_repo_core_repository0 from "@classytic/repo-core/repository";
|
|
6
6
|
import { MinimalRepo, QueryOptions, StandardRepo } from "@classytic/repo-core/repository";
|
|
7
7
|
import { KeysetPaginationResult, OffsetPaginationResult } from "@classytic/repo-core/pagination";
|
|
8
8
|
|
|
9
|
-
//#region src/
|
|
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
|
-
};
|
|
9
|
+
//#region src/adapters/interface.d.ts
|
|
93
10
|
/**
|
|
94
|
-
*
|
|
11
|
+
* Arc's structural repository contract: the repo-core minimum plus any
|
|
12
|
+
* standard-repo methods a given kit implements. All optional methods are
|
|
13
|
+
* feature-detected at call sites — arc never assumes capabilities it
|
|
14
|
+
* hasn't probed.
|
|
95
15
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* }
|
|
16
|
+
* ```ts
|
|
17
|
+
* const adapter: DataAdapter<Product> = {
|
|
18
|
+
* repository: myRepo, // any MinimalRepo<Product> — kit-agnostic
|
|
19
|
+
* type: 'drizzle', // or 'mongoose' | 'prisma' | 'custom'
|
|
20
|
+
* name: 'products',
|
|
21
|
+
* };
|
|
22
|
+
* defineResource({ adapter, ... });
|
|
102
23
|
* ```
|
|
103
24
|
*/
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
25
|
+
type RepositoryLike<TDoc = unknown> = MinimalRepo<TDoc> & Partial<StandardRepo<TDoc>>;
|
|
26
|
+
interface DataAdapter<TDoc = unknown> {
|
|
27
|
+
/**
|
|
28
|
+
* Repository implementing CRUD operations. Accepts the typed
|
|
29
|
+
* `StandardRepo<TDoc>` (repo-core's standard contract) or the structural
|
|
30
|
+
* `RepositoryLike` (minimum + optionals). Arc feature-detects optional
|
|
31
|
+
* methods at runtime — kits only declare what they support.
|
|
32
|
+
*/
|
|
33
|
+
repository: StandardRepo<TDoc> | RepositoryLike<TDoc>;
|
|
34
|
+
/** Adapter identifier for introspection */
|
|
35
|
+
readonly type: "mongoose" | "prisma" | "drizzle" | "typeorm" | "custom";
|
|
36
|
+
/** Human-readable name */
|
|
37
|
+
readonly name: string;
|
|
38
|
+
/**
|
|
39
|
+
* Generate OpenAPI schemas for CRUD operations. Each adapter produces
|
|
40
|
+
* schemas appropriate to its ORM/database (mongoose kits use mongokit's
|
|
41
|
+
* `buildCrudSchemasFromModel`; SQL kits introspect columns).
|
|
42
|
+
*
|
|
43
|
+
* @param options - Schema generation options (field rules, populate hints)
|
|
44
|
+
* @param context - Resource-level context (idField for params schema, name for logs).
|
|
45
|
+
* Adapters should honor `context.idField` when producing the params
|
|
46
|
+
* schema (e.g., skip the ObjectId pattern when idField is a custom
|
|
47
|
+
* string field).
|
|
48
|
+
*/
|
|
49
|
+
generateSchemas?(options?: RouteSchemaOptions, context?: AdapterSchemaContext): OpenApiSchemas | Record<string, unknown> | null;
|
|
50
|
+
/** Extract schema metadata for OpenAPI/introspection. */
|
|
51
|
+
getSchemaMetadata?(): SchemaMetadata | null;
|
|
52
|
+
/** Validate data against schema before persistence. */
|
|
53
|
+
validate?(data: unknown): Promise<ValidationResult$1> | ValidationResult$1;
|
|
54
|
+
/** Health check for database connection. */
|
|
55
|
+
healthCheck?(): Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* Custom filter matching for in-memory policy enforcement. Falls back
|
|
58
|
+
* to arc's built-in shallow matcher when omitted. Override for SQL
|
|
59
|
+
* adapters, non-Mongo operators, or kits that compile Filter IR.
|
|
60
|
+
*/
|
|
61
|
+
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
62
|
+
/** Close / cleanup resources. */
|
|
63
|
+
close?(): Promise<void>;
|
|
122
64
|
}
|
|
123
|
-
type HookHandler<T = AnyRecord> = (ctx: HookContext<T>) => void | Promise<void> | T | Promise<T>;
|
|
124
65
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
66
|
+
* Context passed to `adapter.generateSchemas()` so adapters shape output
|
|
67
|
+
* to match resource-level configuration. All fields optional — adapters
|
|
68
|
+
* that ignore this still work; arc applies safety-net normalization.
|
|
127
69
|
*/
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
name
|
|
132
|
-
|
|
133
|
-
operation: HookOperation;
|
|
134
|
-
phase: HookPhase;
|
|
135
|
-
handler: HookHandler;
|
|
136
|
-
priority: number;
|
|
137
|
-
/** Names of hooks that must execute before this one */
|
|
138
|
-
dependsOn?: string[];
|
|
70
|
+
interface AdapterSchemaContext {
|
|
71
|
+
/** The idField configured on the resource. Defaults to "_id". */
|
|
72
|
+
idField?: string;
|
|
73
|
+
/** Resource name (for error messages / logging). */
|
|
74
|
+
resourceName?: string;
|
|
139
75
|
}
|
|
140
|
-
interface
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
76
|
+
interface SchemaMetadata {
|
|
77
|
+
name: string;
|
|
78
|
+
fields: Record<string, FieldMetadata>;
|
|
79
|
+
indexes?: Array<{
|
|
80
|
+
fields: string[];
|
|
81
|
+
unique?: boolean;
|
|
82
|
+
sparse?: boolean;
|
|
83
|
+
}>;
|
|
84
|
+
relations?: Record<string, RelationMetadata>;
|
|
146
85
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
86
|
+
interface FieldMetadata {
|
|
87
|
+
type: "string" | "number" | "boolean" | "date" | "object" | "array" | "objectId" | "enum";
|
|
88
|
+
required?: boolean;
|
|
89
|
+
unique?: boolean;
|
|
90
|
+
default?: unknown;
|
|
91
|
+
enum?: Array<string | number>;
|
|
92
|
+
min?: number;
|
|
93
|
+
max?: number;
|
|
94
|
+
minLength?: number;
|
|
95
|
+
maxLength?: number;
|
|
96
|
+
pattern?: string;
|
|
97
|
+
description?: string;
|
|
98
|
+
ref?: string;
|
|
99
|
+
array?: boolean;
|
|
100
|
+
}
|
|
101
|
+
interface RelationMetadata {
|
|
102
|
+
type: "one-to-one" | "one-to-many" | "many-to-many";
|
|
103
|
+
target: string;
|
|
104
|
+
foreignKey?: string;
|
|
105
|
+
through?: string;
|
|
106
|
+
}
|
|
107
|
+
interface ValidationResult$1 {
|
|
108
|
+
valid: boolean;
|
|
109
|
+
errors?: Array<{
|
|
110
|
+
field: string;
|
|
111
|
+
message: string;
|
|
112
|
+
code?: string;
|
|
113
|
+
}>;
|
|
114
|
+
}
|
|
115
|
+
type AdapterFactory<TDoc> = (config: unknown) => DataAdapter<TDoc>;
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/core/AccessControl.d.ts
|
|
118
|
+
/** Denial reason codes returned by `fetchDetailed()`. */
|
|
119
|
+
type FetchDenialReason = "NOT_FOUND" | "POLICY_FILTERED" | "ORG_SCOPE_DENIED";
|
|
120
|
+
/** Result of a detailed fetch with access control. */
|
|
121
|
+
interface FetchResult<TDoc> {
|
|
122
|
+
/** The document, or null if denied. */
|
|
123
|
+
doc: TDoc | null;
|
|
124
|
+
/** Null when the doc was found. A string code when denied. */
|
|
125
|
+
reason: FetchDenialReason | null;
|
|
126
|
+
}
|
|
127
|
+
interface AccessControlConfig {
|
|
128
|
+
/** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable org filtering. */
|
|
129
|
+
tenantField: string | false;
|
|
130
|
+
/** Primary key field name (default: '_id') */
|
|
131
|
+
idField: string;
|
|
152
132
|
/**
|
|
153
|
-
*
|
|
133
|
+
* Custom filter matching for policy enforcement.
|
|
134
|
+
* Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
|
|
135
|
+
* Falls back to built-in MongoDB-style matching if not provided.
|
|
154
136
|
*/
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
priority?: number;
|
|
167
|
-
dependsOn?: string[];
|
|
168
|
-
}, operation?: HookOperation, phase?: HookPhase, handler?: HookHandler<T>, priority?: number): () => void;
|
|
169
|
-
/**
|
|
170
|
-
* Register before hook
|
|
171
|
-
*/
|
|
172
|
-
before<T = AnyRecord>(resource: string, operation: HookOperation, handler: HookHandler<T>, priority?: number): () => void;
|
|
137
|
+
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
138
|
+
}
|
|
139
|
+
/** Minimal repository interface for access-controlled fetch operations */
|
|
140
|
+
interface AccessControlRepository {
|
|
141
|
+
getById(id: string, options?: QueryOptions): Promise<unknown>;
|
|
142
|
+
getOne?: (filter: AnyRecord, options?: QueryOptions) => Promise<unknown>;
|
|
143
|
+
}
|
|
144
|
+
declare class AccessControl {
|
|
145
|
+
private readonly tenantField;
|
|
146
|
+
private readonly idField;
|
|
147
|
+
private readonly _adapterMatchesFilter?;
|
|
173
148
|
/**
|
|
174
|
-
*
|
|
149
|
+
* One-shot latch for the "adapter didn't supply matchesFilter, in-memory
|
|
150
|
+
* policy-filter re-check is skipped" warning. The primary fetch path
|
|
151
|
+
* (`getOne(compoundFilter)`) already applied filters at the DB layer;
|
|
152
|
+
* this warn only fires when `validateItemAccess` runs and the adapter
|
|
153
|
+
* hasn't provided a native matcher for the post-hoc re-check.
|
|
175
154
|
*/
|
|
176
|
-
|
|
155
|
+
private _warnedNoMatcher;
|
|
156
|
+
constructor(config: AccessControlConfig);
|
|
177
157
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
158
|
+
* Build filter for single-item operations (get/update/delete)
|
|
159
|
+
* Combines ID filter with policy/org filters for proper security enforcement
|
|
180
160
|
*/
|
|
181
|
-
|
|
161
|
+
buildIdFilter(id: string, req: IRequestContext): AnyRecord;
|
|
182
162
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
163
|
+
* Check if a post-fetch item matches the request's `_policyFilters`.
|
|
164
|
+
*
|
|
165
|
+
* **When this runs:** only on paths where the primary fetch path did NOT
|
|
166
|
+
* apply policy filters at the DB layer — notably `validateItemAccess`
|
|
167
|
+
* (used by `getBySlug` and cache revalidation). The main `fetchDetailed`
|
|
168
|
+
* path builds a compound filter (`buildIdFilter`) and passes it to
|
|
169
|
+
* `repository.getOne(compoundFilter)`, so the DB has already enforced
|
|
170
|
+
* the filter and an in-memory re-check would be redundant.
|
|
171
|
+
*
|
|
172
|
+
* **Evaluation order (fail-closed):**
|
|
173
|
+
* 1. No `_policyFilters` set → `true` (nothing to enforce).
|
|
174
|
+
* 2. Adapter supplied `matchesFilter` → delegate to it verbatim. Adapters
|
|
175
|
+
* are expected to handle every filter shape the host emits
|
|
176
|
+
* (mongokit/sqlitekit evaluate at the DB layer; Prisma/custom engines
|
|
177
|
+
* can wrap their own predicate engine).
|
|
178
|
+
* 3. No adapter matcher → fall back to `simpleEqualityMatcher` — arc's
|
|
179
|
+
* built-in flat-key equality helper. This is defense-in-depth for the
|
|
180
|
+
* common case: arc's own permission helpers emit flat filters
|
|
181
|
+
* (`{userId: …}`, `{organizationId: …}`), which this matcher evaluates
|
|
182
|
+
* correctly. Operator-shaped filters (`$in`, `$ne`, `$regex`, `$and`,
|
|
183
|
+
* `$or`) are **rejected** (the matcher returns `false`) — fail-closed
|
|
184
|
+
* rather than fail-open. A one-shot warn flags the gap so adapter
|
|
185
|
+
* authors can wire a richer matcher.
|
|
186
|
+
*
|
|
187
|
+
* Arc deliberately does NOT ship a full MongoDB-syntax matcher:
|
|
188
|
+
* re-implementing Mongo in JS was dead code for mongokit users (the DB
|
|
189
|
+
* did it) and silently wrong for non-Mongo adapters. The flat-equality
|
|
190
|
+
* fallback is small (~20 LOC), correct in both dialects, and closes the
|
|
191
|
+
* previous `getBySlug`-style policy-bypass path.
|
|
185
192
|
*/
|
|
186
|
-
|
|
187
|
-
user?: UserBase;
|
|
188
|
-
context?: RequestContext;
|
|
189
|
-
meta?: AnyRecord;
|
|
190
|
-
}): Promise<T | undefined>;
|
|
193
|
+
checkPolicyFilters(item: AnyRecord, req: IRequestContext): boolean;
|
|
191
194
|
/**
|
|
192
|
-
*
|
|
195
|
+
* Emit a one-shot warn when policy filters contain operators (`$in`,
|
|
196
|
+
* `$ne`, `$regex`, etc.) and no `DataAdapter.matchesFilter` is wired —
|
|
197
|
+
* arc's flat-equality fallback fail-closes on operators, so the host
|
|
198
|
+
* sees 404s on docs that should match. Latched on `_warnedNoMatcher`
|
|
199
|
+
* so subsequent requests stay quiet.
|
|
193
200
|
*/
|
|
194
|
-
|
|
201
|
+
private _warnNoMatcher;
|
|
195
202
|
/**
|
|
196
|
-
*
|
|
203
|
+
* Check org/tenant scope for a document — uses configurable tenantField.
|
|
204
|
+
*
|
|
205
|
+
* SECURITY: When org scope is active (orgId present), documents that are
|
|
206
|
+
* missing the tenant field are DENIED by default. This prevents legacy or
|
|
207
|
+
* unscoped records from leaking across tenants.
|
|
197
208
|
*/
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
meta?: AnyRecord;
|
|
202
|
-
}): Promise<T>;
|
|
209
|
+
checkOrgScope(item: AnyRecord | null, arcContext: ArcInternalMetadata | RequestContext | undefined): boolean;
|
|
210
|
+
/** Check ownership for update/delete (ownedByUser preset) */
|
|
211
|
+
checkOwnership(item: AnyRecord | null, req: IRequestContext): boolean;
|
|
203
212
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
213
|
+
* Fetch a single document with full access control enforcement.
|
|
214
|
+
* Combines compound DB filter (ID + org + policy) with post-hoc fallback.
|
|
215
|
+
*
|
|
216
|
+
* Takes repository as a parameter to avoid coupling.
|
|
217
|
+
*
|
|
218
|
+
* Replaces the duplicated pattern in get/update/delete:
|
|
219
|
+
* buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
|
|
206
220
|
*/
|
|
207
|
-
|
|
208
|
-
user?: UserBase;
|
|
209
|
-
context?: RequestContext;
|
|
210
|
-
meta?: AnyRecord;
|
|
211
|
-
}): Promise<void>;
|
|
221
|
+
fetchWithAccessControl<TDoc>(id: string, req: IRequestContext, repository: AccessControlRepository, queryOptions?: QueryOptions): Promise<TDoc | null>;
|
|
212
222
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
223
|
+
* Same as `fetchWithAccessControl` but returns a structured result with
|
|
224
|
+
* a denial reason so callers can distinguish "doc doesn't exist" from
|
|
225
|
+
* "doc exists but was filtered by policy/org scope" from "repo threw".
|
|
226
|
+
*
|
|
227
|
+
* Codes:
|
|
228
|
+
* - `null` — doc was found, no denial
|
|
229
|
+
* - `'NOT_FOUND'` — doc genuinely doesn't exist in the DB
|
|
230
|
+
* - `'POLICY_FILTERED'` — doc exists but the request's policy filters exclude it
|
|
231
|
+
* - `'ORG_SCOPE_DENIED'` — doc exists but the caller's org context doesn't match
|
|
232
|
+
* - `'REPO_ERROR'` — the repository threw a "not found" error (mongokit style)
|
|
217
233
|
*/
|
|
218
|
-
|
|
234
|
+
fetchDetailed<TDoc>(id: string, req: IRequestContext, repository: AccessControlRepository, queryOptions?: QueryOptions): Promise<FetchResult<TDoc>>;
|
|
219
235
|
/**
|
|
220
|
-
*
|
|
236
|
+
* Post-fetch access control validation for items fetched by non-ID queries
|
|
237
|
+
* (e.g., getBySlug, restore). Applies org scope, policy filters, and
|
|
238
|
+
* ownership checks — the same guarantees as fetchWithAccessControl.
|
|
221
239
|
*/
|
|
222
|
-
|
|
240
|
+
validateItemAccess(item: AnyRecord | null, req: IRequestContext): boolean;
|
|
241
|
+
/** Extract typed Arc internal metadata from request */
|
|
242
|
+
private _meta;
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/core/BodySanitizer.d.ts
|
|
246
|
+
/**
|
|
247
|
+
* Policy for handling fields the caller lacks write permission for.
|
|
248
|
+
*
|
|
249
|
+
* - `'reject'` (default, secure): throw 403 listing the denied fields so
|
|
250
|
+
* misconfigurations and attacks surface instead of silently disappearing.
|
|
251
|
+
* - `'strip'` (legacy): silently drop the field and continue. Preserved for
|
|
252
|
+
* apps that relied on the pre-2.9 behaviour — new code should not use it.
|
|
253
|
+
*/
|
|
254
|
+
type FieldWriteDenialPolicy = "reject" | "strip";
|
|
255
|
+
interface BodySanitizerConfig {
|
|
256
|
+
/** Schema options for field sanitization */
|
|
257
|
+
schemaOptions: RouteSchemaOptions;
|
|
223
258
|
/**
|
|
224
|
-
*
|
|
259
|
+
* What to do when a request contains fields the caller can't write.
|
|
260
|
+
* Default: `'reject'` — surface the misconfiguration as a 403.
|
|
225
261
|
*/
|
|
226
|
-
|
|
262
|
+
onFieldWriteDenied?: FieldWriteDenialPolicy;
|
|
263
|
+
}
|
|
264
|
+
declare class BodySanitizer {
|
|
265
|
+
private schemaOptions;
|
|
266
|
+
private onFieldWriteDenied;
|
|
267
|
+
constructor(config: BodySanitizerConfig);
|
|
227
268
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
269
|
+
* Strip readonly and system-managed fields from request body.
|
|
270
|
+
* Prevents clients from overwriting _id, timestamps, __v, etc.
|
|
230
271
|
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* // Find all before-create hooks for products (including wildcards)
|
|
234
|
-
* const hooks = hookSystem.getRegistered({
|
|
235
|
-
* resource: 'product',
|
|
236
|
-
* operation: 'create',
|
|
237
|
-
* phase: 'before',
|
|
238
|
-
* });
|
|
239
|
-
* ```
|
|
272
|
+
* Also applies field-level write permissions when the request has
|
|
273
|
+
* field permission metadata.
|
|
240
274
|
*/
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
275
|
+
sanitize(body: AnyRecord, _operation: "create" | "update", req?: IRequestContext, meta?: ArcInternalMetadata): AnyRecord;
|
|
276
|
+
}
|
|
277
|
+
//#endregion
|
|
278
|
+
//#region src/core/QueryResolver.d.ts
|
|
279
|
+
interface QueryResolverConfig {
|
|
280
|
+
/** Query parser instance (default: Arc built-in parser) */
|
|
281
|
+
queryParser?: QueryParserInterface;
|
|
282
|
+
/** Maximum limit for pagination (default: 100) */
|
|
283
|
+
maxLimit?: number;
|
|
284
|
+
/** Default limit for pagination (default: 20) */
|
|
285
|
+
defaultLimit?: number;
|
|
246
286
|
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* // { total: 12, resources: { product: [...], '*': [...] }, summary: [...] }
|
|
253
|
-
* ```
|
|
287
|
+
* Default sort applied when the request doesn't specify one.
|
|
288
|
+
* - `string` — e.g. `'-createdAt'` (Mongo convention: leading `-` = DESC).
|
|
289
|
+
* - `false` — disable the default; resolved query has no `sort` clause.
|
|
290
|
+
* Use for SQL kits without a `createdAt` column.
|
|
291
|
+
* Defaults to `'-createdAt'` for back-compat with mongokit consumers.
|
|
254
292
|
*/
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
293
|
+
defaultSort?: string | false;
|
|
294
|
+
/** Schema options for field sanitization */
|
|
295
|
+
schemaOptions?: RouteSchemaOptions;
|
|
296
|
+
/** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable. */
|
|
297
|
+
tenantField?: string | false;
|
|
298
|
+
}
|
|
299
|
+
declare class QueryResolver {
|
|
300
|
+
private queryParser;
|
|
301
|
+
private maxLimit;
|
|
302
|
+
private defaultLimit;
|
|
303
|
+
/** `undefined` means "no default sort" (caller passed `false`). */
|
|
304
|
+
private defaultSort;
|
|
305
|
+
private schemaOptions;
|
|
306
|
+
private tenantField;
|
|
307
|
+
constructor(config?: QueryResolverConfig);
|
|
265
308
|
/**
|
|
266
|
-
*
|
|
309
|
+
* Resolve a request into parsed query options -- ONE parse per request.
|
|
310
|
+
* Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
|
|
267
311
|
*/
|
|
268
|
-
|
|
312
|
+
resolve(req: IRequestContext, meta?: ArcInternalMetadata): ControllerQueryOptions;
|
|
269
313
|
/**
|
|
270
|
-
*
|
|
314
|
+
* Sanitize select — preserves the input format (string, array, or object).
|
|
315
|
+
* This is critical for db-agnostic support: MongoKit returns object projections,
|
|
316
|
+
* Mongoose uses space-separated strings, SQL adapters may use arrays.
|
|
271
317
|
*/
|
|
272
|
-
|
|
318
|
+
private sanitizeSelectAny;
|
|
319
|
+
/** Sanitize populate fields */
|
|
320
|
+
private sanitizePopulate;
|
|
321
|
+
/** Sanitize advanced populate options against allowedPopulate */
|
|
322
|
+
private sanitizePopulateOptions;
|
|
273
323
|
/**
|
|
274
|
-
*
|
|
324
|
+
* Sanitize lookup/join options.
|
|
325
|
+
* If schemaOptions.query.allowedLookups is set, only those collections are allowed.
|
|
326
|
+
* Validates lookup structure to prevent injection.
|
|
275
327
|
*/
|
|
276
|
-
|
|
328
|
+
private sanitizeLookups;
|
|
329
|
+
/** Get blocked fields from schema options */
|
|
330
|
+
private getBlockedFields;
|
|
277
331
|
}
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region src/core/BaseController.d.ts
|
|
278
334
|
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* -
|
|
335
|
+
* Union of every return shape repo-core's `MinimalRepo.getAll()` is
|
|
336
|
+
* contractually allowed to produce. Keeping arc's list surface in sync
|
|
337
|
+
* with that contract (v2.10.6) — previously arc narrowed to
|
|
338
|
+
* `OffsetPaginationResult<TDoc>` and treated bare arrays as
|
|
339
|
+
* "non-conforming," which drifted from repo-core's published shape.
|
|
284
340
|
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
341
|
+
* - `OffsetPaginationResult<TDoc>` — when the query uses offset pagination
|
|
342
|
+
* (`page` parameter).
|
|
343
|
+
* - `KeysetPaginationResult<TDoc>` — when the query uses keyset/cursor
|
|
344
|
+
* pagination (`sort` + optional `after`).
|
|
345
|
+
* - `TDoc[]` — raw array when neither drives pagination; valid per
|
|
346
|
+
* repo-core's `MinimalRepo.getAll` docstring.
|
|
288
347
|
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
348
|
+
* Arc passes the kit's response verbatim; callers / consumers should
|
|
349
|
+
* narrow on shape (`Array.isArray(result)` → bare array, presence of
|
|
350
|
+
* `page`/`total` → offset, presence of `nextCursor` → keyset).
|
|
291
351
|
*/
|
|
292
|
-
|
|
293
|
-
interface
|
|
294
|
-
/**
|
|
295
|
-
|
|
296
|
-
/** Target resource */
|
|
297
|
-
resource: string;
|
|
298
|
-
/** CRUD operation */
|
|
299
|
-
operation: HookOperation;
|
|
300
|
-
/** before or after */
|
|
301
|
-
phase: HookPhase;
|
|
302
|
-
/** Hook handler */
|
|
303
|
-
handler: HookHandler<T>;
|
|
304
|
-
/** Priority (lower = earlier, default: 10) */
|
|
305
|
-
priority?: number;
|
|
306
|
-
/** Names of hooks that must execute before this one */
|
|
307
|
-
dependsOn?: string[];
|
|
308
|
-
}
|
|
309
|
-
/**
|
|
310
|
-
* Define a named hook with optional dependencies.
|
|
311
|
-
* Returns a registration object — call `register(hookSystem)` to activate.
|
|
312
|
-
*
|
|
313
|
-
* @example
|
|
314
|
-
* ```typescript
|
|
315
|
-
* const generateSlug = defineHook({
|
|
316
|
-
* name: 'generateSlug',
|
|
317
|
-
* resource: 'product', operation: 'create', phase: 'before',
|
|
318
|
-
* handler: (ctx) => ({ ...ctx.data, slug: slugify(ctx.data.name) }),
|
|
319
|
-
* });
|
|
320
|
-
*
|
|
321
|
-
* const validateUniqueSlug = defineHook({
|
|
322
|
-
* name: 'validateUniqueSlug',
|
|
323
|
-
* resource: 'product', operation: 'create', phase: 'before',
|
|
324
|
-
* dependsOn: ['generateSlug'],
|
|
325
|
-
* handler: async (ctx) => { // check uniqueness },
|
|
326
|
-
* });
|
|
327
|
-
*
|
|
328
|
-
* // Register on a hook system
|
|
329
|
-
* generateSlug.register(hooks);
|
|
330
|
-
* validateUniqueSlug.register(hooks);
|
|
331
|
-
* ```
|
|
332
|
-
*/
|
|
333
|
-
declare function defineHook<T = AnyRecord>(options: DefineHookOptions<T>): DefineHookOptions<T> & {
|
|
334
|
-
register: (hooks: HookSystem) => () => void;
|
|
335
|
-
};
|
|
336
|
-
/**
|
|
337
|
-
* Create a before-create hook registration for a given hook system
|
|
338
|
-
*/
|
|
339
|
-
declare function beforeCreate<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
340
|
-
/**
|
|
341
|
-
* Create an after-create hook registration for a given hook system
|
|
342
|
-
*/
|
|
343
|
-
declare function afterCreate<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
344
|
-
/**
|
|
345
|
-
* Create a before-update hook registration for a given hook system
|
|
346
|
-
*/
|
|
347
|
-
declare function beforeUpdate<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
348
|
-
/**
|
|
349
|
-
* Create an after-update hook registration for a given hook system
|
|
350
|
-
*/
|
|
351
|
-
declare function afterUpdate<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
352
|
-
/**
|
|
353
|
-
* Create a before-delete hook registration for a given hook system
|
|
354
|
-
*/
|
|
355
|
-
declare function beforeDelete<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
356
|
-
/**
|
|
357
|
-
* Create an after-delete hook registration for a given hook system
|
|
358
|
-
*/
|
|
359
|
-
declare function afterDelete<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
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>;
|
|
352
|
+
type ListResult<TDoc> = OffsetPaginationResult<TDoc> | KeysetPaginationResult<TDoc> | TDoc[];
|
|
353
|
+
interface BaseControllerOptions {
|
|
354
|
+
/** Schema options for field sanitization */
|
|
355
|
+
schemaOptions?: RouteSchemaOptions;
|
|
404
356
|
/**
|
|
405
|
-
*
|
|
406
|
-
*
|
|
357
|
+
* Query parser instance.
|
|
358
|
+
* Default: Arc built-in query parser (adapter-agnostic).
|
|
359
|
+
* Swap in MongoKit QueryParser, pgkit parser, etc.
|
|
407
360
|
*/
|
|
408
|
-
|
|
361
|
+
queryParser?: QueryParserInterface;
|
|
362
|
+
/** Maximum limit for pagination (default: 100) */
|
|
363
|
+
maxLimit?: number;
|
|
364
|
+
/** Default limit for pagination (default: 20) */
|
|
365
|
+
defaultLimit?: number;
|
|
409
366
|
/**
|
|
410
|
-
*
|
|
411
|
-
* `$lookup`; future SQL adapters would map to JOINs.
|
|
367
|
+
* Default sort applied when the request doesn't specify one.
|
|
412
368
|
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
369
|
+
* - `string` (default: `'-createdAt'`) — sort descending on the field.
|
|
370
|
+
* Uses the Mongo convention `-fieldName` for DESC; matches the
|
|
371
|
+
* mongokit / mongokit-sort parser. Applied only if the resource's
|
|
372
|
+
* schema actually has the column.
|
|
373
|
+
* - `false` — disable the default sort. Requests that pass no `sort`
|
|
374
|
+
* get whatever order the adapter returns (PK order on most kits).
|
|
375
|
+
* **Use this for SQL/Drizzle resources that don't declare a
|
|
376
|
+
* `createdAt` column** — the default would otherwise compile to
|
|
377
|
+
* `ORDER BY "createdAt" DESC` against a missing column.
|
|
378
|
+
*
|
|
379
|
+
* The `-createdAt` default is kept for back-compat with existing
|
|
380
|
+
* mongokit users; going forward, set this explicitly per resource.
|
|
415
381
|
*/
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
};
|
|
382
|
+
defaultSort?: string | false;
|
|
383
|
+
/** Resource name for hook execution (e.g., 'product' -> 'product.created') */
|
|
384
|
+
resourceName?: string;
|
|
503
385
|
/**
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
386
|
+
* Field name used for multi-tenant scoping (default: 'organizationId').
|
|
387
|
+
* Override to match your schema: 'workspaceId', 'tenantId', 'teamId', etc.
|
|
388
|
+
* Set to `false` to disable org filtering for platform-universal resources.
|
|
507
389
|
*/
|
|
508
|
-
|
|
390
|
+
tenantField?: string | false;
|
|
509
391
|
/**
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
392
|
+
* Primary key field name (default: '_id').
|
|
393
|
+
*
|
|
394
|
+
* If not set, the controller auto-derives it from the repository's own
|
|
395
|
+
* `idField` property (e.g. MongoKit's `Repository({ idField: 'id' })`),
|
|
396
|
+
* so you only need to configure it in one place.
|
|
397
|
+
*
|
|
398
|
+
* Set explicitly to override the repo's setting (e.g. `'_id'` to opt out
|
|
399
|
+
* of native pass-through and force the slug-translation path).
|
|
400
|
+
*
|
|
401
|
+
* Override for non-MongoDB adapters (e.g., 'id' for SQL databases).
|
|
513
402
|
*/
|
|
514
|
-
|
|
403
|
+
idField?: string;
|
|
515
404
|
/**
|
|
516
|
-
*
|
|
517
|
-
*
|
|
405
|
+
* Custom filter matching for policy enforcement.
|
|
406
|
+
* Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
|
|
407
|
+
* Falls back to built-in MongoDB-style matching if not provided.
|
|
518
408
|
*/
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
409
|
+
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
410
|
+
/** Cache configuration for the resource */
|
|
411
|
+
cache?: ResourceCacheConfig;
|
|
412
|
+
/** Internal preset fields map (slug, tree, etc.) */
|
|
413
|
+
presetFields?: {
|
|
414
|
+
slugField?: string;
|
|
415
|
+
parentField?: string;
|
|
416
|
+
};
|
|
417
|
+
/**
|
|
418
|
+
* Policy for requests that include fields the caller can't write.
|
|
419
|
+
*
|
|
420
|
+
* - `'reject'` (default): 403 with the denied field names. Surfaces
|
|
421
|
+
* misconfigurations and attempts to set protected fields instead of
|
|
422
|
+
* silently dropping them.
|
|
423
|
+
* - `'strip'`: legacy silent-drop behaviour. Only opt in when migrating
|
|
424
|
+
* code that relied on the pre-2.9 permissive default.
|
|
425
|
+
*/
|
|
426
|
+
onFieldWriteDenied?: FieldWriteDenialPolicy;
|
|
536
427
|
}
|
|
537
|
-
//#endregion
|
|
538
|
-
//#region src/pipeline/types.d.ts
|
|
539
428
|
/**
|
|
540
|
-
*
|
|
541
|
-
* Extends IRequestContext with pipeline-specific metadata.
|
|
542
|
-
*/
|
|
543
|
-
interface PipelineContext extends IRequestContext {
|
|
544
|
-
/** Resource name being accessed */
|
|
545
|
-
resource: string;
|
|
546
|
-
/** CRUD operation being performed */
|
|
547
|
-
operation: "list" | "get" | "create" | "update" | "delete" | string;
|
|
548
|
-
}
|
|
549
|
-
/**
|
|
550
|
-
* Which operations a pipeline step applies to.
|
|
551
|
-
* If omitted, applies to ALL operations.
|
|
552
|
-
*/
|
|
553
|
-
type OperationFilter = Array<"list" | "get" | "create" | "update" | "delete" | string>;
|
|
554
|
-
/**
|
|
555
|
-
* Guard — boolean check that short-circuits on failure.
|
|
556
|
-
* Return true to proceed, throw to deny.
|
|
557
|
-
*/
|
|
558
|
-
interface Guard {
|
|
559
|
-
readonly _type: "guard";
|
|
560
|
-
readonly name: string;
|
|
561
|
-
readonly operations?: OperationFilter;
|
|
562
|
-
handler(ctx: PipelineContext): boolean | Promise<boolean>;
|
|
563
|
-
}
|
|
564
|
-
/**
|
|
565
|
-
* Transform — modifies request data before the handler.
|
|
566
|
-
* Returns modified context (or mutates in place).
|
|
567
|
-
*/
|
|
568
|
-
interface Transform {
|
|
569
|
-
readonly _type: "transform";
|
|
570
|
-
readonly name: string;
|
|
571
|
-
readonly operations?: OperationFilter;
|
|
572
|
-
handler(ctx: PipelineContext): PipelineContext | undefined | Promise<PipelineContext | undefined>;
|
|
573
|
-
}
|
|
574
|
-
/**
|
|
575
|
-
* Next function passed to interceptors — calls the handler (or next interceptor).
|
|
576
|
-
*/
|
|
577
|
-
type NextFunction = () => Promise<IControllerResponse<unknown>>;
|
|
578
|
-
/**
|
|
579
|
-
* Intercept — wraps handler execution (before + after pattern).
|
|
580
|
-
*/
|
|
581
|
-
interface Interceptor {
|
|
582
|
-
readonly _type: "interceptor";
|
|
583
|
-
readonly name: string;
|
|
584
|
-
readonly operations?: OperationFilter;
|
|
585
|
-
handler(ctx: PipelineContext, next: NextFunction): Promise<IControllerResponse<unknown>>;
|
|
586
|
-
}
|
|
587
|
-
type PipelineStep = Guard | Transform | Interceptor;
|
|
588
|
-
/**
|
|
589
|
-
* Pipeline configuration — can be a flat array or per-operation map.
|
|
590
|
-
*/
|
|
591
|
-
type PipelineConfig = PipelineStep[] | {
|
|
592
|
-
list?: PipelineStep[];
|
|
593
|
-
get?: PipelineStep[];
|
|
594
|
-
create?: PipelineStep[];
|
|
595
|
-
update?: PipelineStep[];
|
|
596
|
-
delete?: PipelineStep[];
|
|
597
|
-
[operation: string]: PipelineStep[] | undefined;
|
|
598
|
-
};
|
|
599
|
-
//#endregion
|
|
600
|
-
//#region src/adapters/interface.d.ts
|
|
601
|
-
/**
|
|
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.
|
|
429
|
+
* Framework-agnostic base controller implementing IController.
|
|
606
430
|
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
* defineResource({ adapter, ... });
|
|
614
|
-
* ```
|
|
431
|
+
* Composes AccessControl, BodySanitizer, and QueryResolver for clean
|
|
432
|
+
* separation of concerns. CRUD methods delegate directly to these
|
|
433
|
+
* composed classes — no intermediate wrapper methods.
|
|
434
|
+
*
|
|
435
|
+
* @template TDoc - The document type
|
|
436
|
+
* @template TRepository - The repository type (defaults to RepositoryLike)
|
|
615
437
|
*/
|
|
616
|
-
|
|
617
|
-
|
|
438
|
+
declare class BaseController<TDoc = AnyRecord, TRepository extends RepositoryLike = RepositoryLike> implements IController<TDoc> {
|
|
439
|
+
protected repository: TRepository;
|
|
440
|
+
protected schemaOptions: RouteSchemaOptions;
|
|
441
|
+
protected queryParser: QueryParserInterface;
|
|
442
|
+
protected maxLimit: number;
|
|
443
|
+
protected defaultLimit: number;
|
|
444
|
+
/** `undefined` means "no default sort" (caller passed `false`). */
|
|
445
|
+
protected defaultSort: string | undefined;
|
|
446
|
+
protected resourceName?: string;
|
|
447
|
+
protected tenantField: string | false;
|
|
448
|
+
protected idField: string;
|
|
449
|
+
/** Composable access control (ID filtering, policy checks, org scope, ownership) */
|
|
450
|
+
readonly accessControl: AccessControl;
|
|
451
|
+
/** Composable body sanitization (field permissions, system fields) */
|
|
452
|
+
readonly bodySanitizer: BodySanitizer;
|
|
453
|
+
/** Composable query resolution (parsing, pagination, sort, select/populate) */
|
|
454
|
+
readonly queryResolver: QueryResolver;
|
|
455
|
+
private _matchesFilter?;
|
|
456
|
+
private _presetFields;
|
|
457
|
+
private _cacheConfig?;
|
|
458
|
+
constructor(repository: TRepository, options?: BaseControllerOptions);
|
|
618
459
|
/**
|
|
619
|
-
*
|
|
620
|
-
* `
|
|
621
|
-
*
|
|
622
|
-
*
|
|
460
|
+
* Get the tenant field name if multi-tenant scoping is enabled.
|
|
461
|
+
* Returns `undefined` when `tenantField` is `false` (platform-universal mode).
|
|
462
|
+
*
|
|
463
|
+
* Use this in subclass overrides instead of accessing `this.tenantField` directly
|
|
464
|
+
* to avoid TypeScript indexing errors with `string | false`.
|
|
623
465
|
*/
|
|
624
|
-
|
|
625
|
-
/** Adapter identifier for introspection */
|
|
626
|
-
readonly type: "mongoose" | "prisma" | "drizzle" | "typeorm" | "custom";
|
|
627
|
-
/** Human-readable name */
|
|
628
|
-
readonly name: string;
|
|
466
|
+
protected getTenantField(): string | undefined;
|
|
629
467
|
/**
|
|
630
|
-
*
|
|
631
|
-
* schemas appropriate to its ORM/database (mongoose kits use mongokit's
|
|
632
|
-
* `buildCrudSchemasFromModel`; SQL kits introspect columns).
|
|
468
|
+
* Build top-level tenant options to thread into the repository call.
|
|
633
469
|
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
470
|
+
* **Why this exists:** repo plugins (e.g. `@classytic/mongokit`'s
|
|
471
|
+
* `multiTenantPlugin`) read tenant scope from the TOP of the repository
|
|
472
|
+
* operation context — `context.organizationId`, not `context.data.organizationId`
|
|
473
|
+
* or `context.context.organizationId`. Without this stamping, a tenant-scoped
|
|
474
|
+
* repository throws `Missing 'organizationId' in context for '<op>'` even
|
|
475
|
+
* though arc already injected the tenant into the request body.
|
|
476
|
+
*
|
|
477
|
+
* **What this returns:**
|
|
478
|
+
* - `{ [tenantField]: orgId }` when the resource is tenant-scoped and the
|
|
479
|
+
* caller's scope carries an org ID (member, service key bound to an org,
|
|
480
|
+
* elevated admin impersonating an org).
|
|
481
|
+
* - `{}` otherwise — platform-universal resources (`tenantField: false`),
|
|
482
|
+
* public/anonymous reads, elevated admins without an org target.
|
|
483
|
+
*
|
|
484
|
+
* **Call sites:** every `this.repository.*` CRUD entry — `create`, `update`,
|
|
485
|
+
* `delete`, `getAll` (via list), plus merged into `QueryOptions` for the
|
|
486
|
+
* access-controlled read path (`accessControl.fetchDetailed` → `getById`/`getOne`).
|
|
487
|
+
*
|
|
488
|
+
* **Name of the field:** uses the instance's own `tenantField` configuration
|
|
489
|
+
* (default `organizationId`). Matches mongokit's `multiTenantPlugin` default
|
|
490
|
+
* `contextKey` so host apps don't need to override either side.
|
|
491
|
+
*
|
|
492
|
+
* Multi-field tenancy (via `multiTenantPreset({ tenantFields: [...] })`)
|
|
493
|
+
* resolves additional fields at middleware time and stashes them on
|
|
494
|
+
* `_tenantFields` — {@link tenantRepoOptions} merges those in too.
|
|
639
495
|
*/
|
|
640
|
-
|
|
641
|
-
/** Extract
|
|
642
|
-
|
|
643
|
-
/**
|
|
644
|
-
|
|
645
|
-
/** Health check for database connection. */
|
|
646
|
-
healthCheck?(): Promise<boolean>;
|
|
496
|
+
private tenantRepoOptions;
|
|
497
|
+
/** Extract typed Arc internal metadata from request */
|
|
498
|
+
private meta;
|
|
499
|
+
/** Get hook system from request context (instance-scoped) */
|
|
500
|
+
private getHooks;
|
|
647
501
|
/**
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
502
|
+
* Resolve the repository primary key for mutation calls (update/delete/restore).
|
|
503
|
+
*
|
|
504
|
+
* When the resource declares a custom `idField` (e.g. `slug`, `jobId`, UUID),
|
|
505
|
+
* the default behavior is to translate the route id → the fetched doc's `_id`
|
|
506
|
+
* because most Mongo repositories key their mutation methods off `_id`.
|
|
507
|
+
*
|
|
508
|
+
* Exception: if the repository itself exposes a matching `idField` property
|
|
509
|
+
* (e.g. MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
|
|
510
|
+
* repository already knows how to look up by that field — so we pass the
|
|
511
|
+
* route id through unchanged and skip the translation.
|
|
512
|
+
*
|
|
513
|
+
* This makes `defineResource({ idField: 'id' })` work end-to-end with repos
|
|
514
|
+
* that natively support custom primary keys, without breaking the slug-style
|
|
515
|
+
* aliasing that Arc 2.6.3 introduced for repos keyed on `_id`.
|
|
651
516
|
*/
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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;
|
|
517
|
+
private resolveRepoId;
|
|
518
|
+
/**
|
|
519
|
+
* Centralized 404 response builder. Maps the denial reason from
|
|
520
|
+
* `fetchDetailed()` into a structured `details.code` so consumers can
|
|
521
|
+
* programmatically distinguish "doc doesn't exist" from "doc filtered
|
|
522
|
+
* by policy/org scope" without parsing error strings.
|
|
523
|
+
*
|
|
524
|
+
* Error messages are intentionally vague in the `error` field (don't
|
|
525
|
+
* leak whether the doc exists) — the detail is in `details.code` only.
|
|
526
|
+
*/
|
|
527
|
+
private notFoundResponse;
|
|
528
|
+
/** Resolve cache config for a specific operation, merging per-op overrides */
|
|
529
|
+
private resolveCacheConfig;
|
|
530
|
+
/**
|
|
531
|
+
* Extract user/org IDs from request for cache key scoping.
|
|
532
|
+
* Only includes orgId when this resource uses tenant-scoped data (tenantField is set).
|
|
533
|
+
* Universal resources (tenantField: false) get shared cache keys to avoid fragmentation.
|
|
534
|
+
*/
|
|
535
|
+
private cacheScope;
|
|
536
|
+
list(req: IRequestContext): Promise<IControllerResponse<ListResult<TDoc>>>;
|
|
537
|
+
/** Execute list query through hooks (extracted for cache revalidation) */
|
|
538
|
+
private executeListQuery;
|
|
539
|
+
get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
540
|
+
/** Execute get query through hooks (extracted for cache revalidation) */
|
|
541
|
+
private executeGetQuery;
|
|
542
|
+
create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
543
|
+
update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
544
|
+
delete(req: IRequestContext): Promise<IControllerResponse<{
|
|
702
545
|
message: string;
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
* without switching to `raw: true`.
|
|
713
|
-
*/
|
|
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>;
|
|
718
|
-
};
|
|
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>;
|
|
725
|
-
};
|
|
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;
|
|
732
|
-
};
|
|
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>;
|
|
746
|
-
};
|
|
747
|
-
}
|
|
748
|
-
/**
|
|
749
|
-
* Request context passed to controller handlers.
|
|
750
|
-
*
|
|
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.)
|
|
759
|
-
*
|
|
760
|
-
* @example
|
|
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) };
|
|
766
|
-
* }
|
|
767
|
-
*
|
|
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
|
-
* }
|
|
772
|
-
*
|
|
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
|
-
* ```
|
|
787
|
-
*/
|
|
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;
|
|
546
|
+
id?: string;
|
|
547
|
+
soft?: boolean;
|
|
548
|
+
}>>;
|
|
549
|
+
getBySlug(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
550
|
+
getDeleted(req: IRequestContext): Promise<IControllerResponse<PaginationResult<TDoc>>>;
|
|
551
|
+
restore(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
552
|
+
getTree(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
553
|
+
getChildren(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
554
|
+
bulkCreate(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
803
555
|
/**
|
|
804
|
-
*
|
|
805
|
-
* Contains orgRoles, orgScope, organizationId, and any custom fields
|
|
806
|
-
* set by the auth adapter or org-scope plugin.
|
|
556
|
+
* Build a tenant-scoped filter for bulk update/delete.
|
|
807
557
|
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
558
|
+
* Mirrors `AccessControl.buildIdFilter` semantics for single-doc ops:
|
|
559
|
+
* - Always merge `_policyFilters` (from permission middleware)
|
|
560
|
+
* - When `tenantField` is set AND a `member` scope is present, add the
|
|
561
|
+
* org filter so cross-tenant data can't be touched.
|
|
562
|
+
* - When the scope is `elevated` (platform admin), no org filter is
|
|
563
|
+
* applied — admins can bulk-update across orgs intentionally.
|
|
564
|
+
* - When the scope is `public` on a tenant-scoped resource, deny.
|
|
565
|
+
* - When NO scope is present at all (e.g., direct controller calls in
|
|
566
|
+
* unit tests, or app routes without auth middleware), the controller
|
|
567
|
+
* stays lenient — it's the middleware layer's job to fail-close.
|
|
568
|
+
* Apps that want fail-close on bulk routes should run the multi-tenant
|
|
569
|
+
* preset middleware (or equivalent) ahead of these handlers.
|
|
570
|
+
*
|
|
571
|
+
* Returns the merged filter, or `null` when access must be denied.
|
|
815
572
|
*/
|
|
816
|
-
|
|
573
|
+
private buildBulkFilter;
|
|
817
574
|
/**
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
575
|
+
* Sanitize a bulk update data payload through the same write-permission
|
|
576
|
+
* pipeline as single-doc update(). Handles both shapes:
|
|
577
|
+
*
|
|
578
|
+
* - Flat: `{ name: 'x', status: 'y' }`
|
|
579
|
+
* - Mongo operator: `{ $set: { name: 'x' }, $inc: { views: 1 }, $unset: { tag: '' } }`
|
|
580
|
+
*
|
|
581
|
+
* For each operand, runs `bodySanitizer.sanitize('update', ...)` so that
|
|
582
|
+
* system fields, systemManaged/readonly/immutable rules, AND field-level
|
|
583
|
+
* write permissions are enforced. Without this, a tenant-scoped user could
|
|
584
|
+
* pass `{ $set: { organizationId: 'org-b' } }` to move records across orgs.
|
|
585
|
+
*
|
|
586
|
+
* Returns the sanitized payload along with the list of stripped fields for
|
|
587
|
+
* audit/error reporting.
|
|
821
588
|
*/
|
|
822
|
-
|
|
589
|
+
private sanitizeBulkUpdateData;
|
|
590
|
+
bulkUpdate(req: IRequestContext): Promise<IControllerResponse<{
|
|
591
|
+
matchedCount: number;
|
|
592
|
+
modifiedCount: number;
|
|
593
|
+
}>>;
|
|
823
594
|
/**
|
|
824
|
-
*
|
|
825
|
-
* from any handler without switching to `raw: true`.
|
|
595
|
+
* Bulk delete by `filter` or `ids`.
|
|
826
596
|
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
597
|
+
* Body shape (one of):
|
|
598
|
+
* - `{ filter: { status: 'archived' } }` — delete by query filter
|
|
599
|
+
* - `{ ids: ['id1', 'id2', 'id3'] }` — delete specific docs by id
|
|
600
|
+
*
|
|
601
|
+
* The `ids` form translates to `{ [idField]: { $in: ids } }` using the
|
|
602
|
+
* resource's `idField` (so it works with custom PKs like `slug`, `jobId`,
|
|
603
|
+
* UUID, etc.). Tenant scope and policy filters are merged in either way,
|
|
604
|
+
* so cross-tenant deletes are blocked at the controller layer.
|
|
605
|
+
*
|
|
606
|
+
* Both forms perform a single `repo.deleteMany()` DB call — no per-doc
|
|
607
|
+
* fetch loop. Per-doc lifecycle hooks (`before:delete`/`after:delete`) do
|
|
608
|
+
* NOT fire for bulk operations; use the single-doc `delete()` if you need
|
|
609
|
+
* them, or subscribe to the bulk lifecycle event from the events plugin.
|
|
835
610
|
*/
|
|
836
|
-
|
|
611
|
+
bulkDelete(req: IRequestContext): Promise<IControllerResponse<{
|
|
612
|
+
deletedCount: number;
|
|
613
|
+
}>>;
|
|
614
|
+
}
|
|
615
|
+
//#endregion
|
|
616
|
+
//#region src/types/base.d.ts
|
|
617
|
+
declare module "fastify" {
|
|
618
|
+
interface FastifyRequest {
|
|
619
|
+
/** Request scope — set by auth adapter, read by permissions/presets/guards */
|
|
620
|
+
scope: RequestScope;
|
|
621
|
+
/**
|
|
622
|
+
* Current user — set by auth adapter (Better Auth, JWT, custom).
|
|
623
|
+
* `undefined` on public routes (`auth: false`) or unauthenticated requests.
|
|
624
|
+
* Guard with `if (request.user)` on routes that allow anonymous access.
|
|
625
|
+
*
|
|
626
|
+
* Kept as required (not `user?`) because `@fastify/jwt` declares it
|
|
627
|
+
* as required — declaration merges must have identical modifiers.
|
|
628
|
+
* The `| undefined` in the type achieves the same DX.
|
|
629
|
+
*/
|
|
630
|
+
user: Record<string, unknown> | undefined;
|
|
631
|
+
/** Policy-injected query filters (e.g. ownership, org-scoping) */
|
|
632
|
+
_policyFilters?: Record<string, unknown>;
|
|
633
|
+
/** Field mask — fields to include/exclude in responses */
|
|
634
|
+
fieldMask?: {
|
|
635
|
+
include?: string[];
|
|
636
|
+
exclude?: string[];
|
|
637
|
+
};
|
|
638
|
+
/** Arbitrary policy metadata for downstream consumers */
|
|
639
|
+
policyMetadata?: Record<string, unknown>;
|
|
640
|
+
/** Document loaded by policy middleware for ownership checks */
|
|
641
|
+
document?: unknown;
|
|
642
|
+
/** Ownership check context (field name + user field) */
|
|
643
|
+
_ownershipCheck?: Record<string, unknown>;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
type AnyRecord = Record<string, unknown>;
|
|
647
|
+
/** MongoDB ObjectId — accepts string or any object with a `toString()` (e.g. mongoose ObjectId). */
|
|
648
|
+
type ObjectId = string | {
|
|
649
|
+
toString(): string;
|
|
650
|
+
};
|
|
651
|
+
/**
|
|
652
|
+
* Flexible user type that accepts any object with id/_id properties.
|
|
653
|
+
* The actual user structure is defined by your app's auth system.
|
|
654
|
+
*/
|
|
655
|
+
type UserLike = UserBase & {
|
|
656
|
+
/** User email (optional) */email?: string;
|
|
657
|
+
};
|
|
658
|
+
/** Extract user ID from a user object (supports both id and _id). */
|
|
659
|
+
declare function getUserId(user: UserLike | null | undefined): string | undefined;
|
|
660
|
+
interface UserOrganization {
|
|
661
|
+
userId: string;
|
|
662
|
+
organizationId: string;
|
|
663
|
+
[key: string]: unknown;
|
|
664
|
+
}
|
|
665
|
+
interface JWTPayload {
|
|
666
|
+
sub: string;
|
|
667
|
+
[key: string]: unknown;
|
|
837
668
|
}
|
|
838
669
|
/**
|
|
839
|
-
* Standard response
|
|
670
|
+
* Standard API response envelope — `{ success, data?, error?, message?, meta? }`.
|
|
671
|
+
* Used by Arc's default response shape.
|
|
840
672
|
*/
|
|
841
|
-
interface
|
|
842
|
-
/** Operation success status */
|
|
673
|
+
interface ApiResponse<T = unknown> {
|
|
843
674
|
success: boolean;
|
|
844
|
-
/** Response data */
|
|
845
675
|
data?: T;
|
|
846
|
-
/** Error message (when success is false) */
|
|
847
676
|
error?: string;
|
|
848
|
-
|
|
849
|
-
status?: number;
|
|
850
|
-
/** Additional metadata */
|
|
677
|
+
message?: string;
|
|
851
678
|
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
679
|
}
|
|
857
680
|
/**
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
* Receives a request context object, returns IControllerResponse.
|
|
861
|
-
* Use with `raw: false` in routes.
|
|
862
|
-
*
|
|
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>`)
|
|
868
|
-
*
|
|
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.
|
|
681
|
+
* Typed Fastify request with Arc decorations. Use in `raw: true` handlers
|
|
682
|
+
* instead of `(req as any).user`.
|
|
872
683
|
*
|
|
873
684
|
* @example
|
|
874
685
|
* ```typescript
|
|
875
|
-
*
|
|
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
|
-
* };
|
|
686
|
+
* import type { ArcRequest } from '@classytic/arc';
|
|
880
687
|
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
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
|
-
* }]
|
|
688
|
+
* handler: async (req: ArcRequest, reply) => {
|
|
689
|
+
* req.user?.id; // typed
|
|
690
|
+
* req.scope.organizationId; // typed (when member)
|
|
691
|
+
* req.signal; // AbortSignal (Fastify 5)
|
|
692
|
+
* }
|
|
900
693
|
* ```
|
|
901
694
|
*/
|
|
902
|
-
type
|
|
695
|
+
type ArcRequest = FastifyRequest & {
|
|
696
|
+
scope: RequestScope;
|
|
697
|
+
user: Record<string, unknown> | undefined;
|
|
698
|
+
signal: AbortSignal;
|
|
699
|
+
};
|
|
903
700
|
/**
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
* Standard Fastify request/reply pattern.
|
|
907
|
-
* Use with `raw: true` in routes.
|
|
701
|
+
* Wrap data in Arc's standard `{ success: true, data }` envelope.
|
|
908
702
|
*
|
|
909
703
|
* @example
|
|
910
704
|
* ```typescript
|
|
911
|
-
*
|
|
912
|
-
* const
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
* };
|
|
916
|
-
*
|
|
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
|
-
* }]
|
|
705
|
+
* handler: async (req, reply) => {
|
|
706
|
+
* const data = await getResults();
|
|
707
|
+
* return envelope(data); // → { success: true, data }
|
|
708
|
+
* }
|
|
924
709
|
* ```
|
|
925
710
|
*/
|
|
926
|
-
|
|
711
|
+
declare function envelope<T>(data: T, meta?: Record<string, unknown>): {
|
|
712
|
+
success: true;
|
|
713
|
+
data: T;
|
|
714
|
+
[key: string]: unknown;
|
|
715
|
+
};
|
|
716
|
+
//#endregion
|
|
717
|
+
//#region src/types/auth.d.ts
|
|
927
718
|
/**
|
|
928
|
-
*
|
|
719
|
+
* JWT utilities provided to authenticator. Arc provides the helpers;
|
|
720
|
+
* apps use them as needed.
|
|
929
721
|
*/
|
|
930
|
-
|
|
722
|
+
interface JwtContext {
|
|
723
|
+
/** Verify a JWT token and return decoded payload */
|
|
724
|
+
verify: <T = Record<string, unknown>>(token: string) => T;
|
|
725
|
+
/** Sign a payload and return JWT token */
|
|
726
|
+
sign: (payload: Record<string, unknown>, options?: {
|
|
727
|
+
expiresIn?: string;
|
|
728
|
+
}) => string;
|
|
729
|
+
/** Decode without verification (for inspection) */
|
|
730
|
+
decode: <T = Record<string, unknown>>(token: string) => T | null;
|
|
731
|
+
}
|
|
732
|
+
/** Context passed to app's authenticator function. */
|
|
733
|
+
interface AuthenticatorContext {
|
|
734
|
+
/** JWT utilities (available if `jwt.secret` provided) */
|
|
735
|
+
jwt: JwtContext | null;
|
|
736
|
+
/** Fastify instance for advanced use cases */
|
|
737
|
+
fastify: FastifyInstance;
|
|
738
|
+
}
|
|
931
739
|
/**
|
|
932
|
-
*
|
|
740
|
+
* App-provided authenticator function. Arc calls this for every
|
|
741
|
+
* non-public route. The app has full control over authentication logic.
|
|
742
|
+
*
|
|
743
|
+
* Return a user object to authenticate, `null`/`undefined` to reject.
|
|
744
|
+
*
|
|
745
|
+
* @example
|
|
746
|
+
* ```typescript
|
|
747
|
+
* authenticate: async (request, { jwt }) => {
|
|
748
|
+
* const token = request.headers.authorization?.split(' ')[1];
|
|
749
|
+
* if (!token || !jwt) return null;
|
|
750
|
+
* const decoded = jwt.verify(token);
|
|
751
|
+
* return userRepo.findById(decoded.id);
|
|
752
|
+
* }
|
|
753
|
+
* ```
|
|
933
754
|
*/
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
755
|
+
type Authenticator = (request: FastifyRequest, context: AuthenticatorContext) => Promise<unknown | null> | unknown | null;
|
|
756
|
+
/** Token pair returned by `issueTokens` helper. */
|
|
757
|
+
interface TokenPair {
|
|
758
|
+
/** Access token (JWT) */
|
|
759
|
+
accessToken: string;
|
|
760
|
+
/** Refresh token (JWT with longer expiry) */
|
|
761
|
+
refreshToken?: string;
|
|
762
|
+
/** Access token expiry in seconds */
|
|
763
|
+
expiresIn: number;
|
|
764
|
+
/** Refresh token expiry in seconds */
|
|
765
|
+
refreshExpiresIn?: number;
|
|
766
|
+
/** Token type (always 'Bearer') */
|
|
767
|
+
tokenType: "Bearer";
|
|
945
768
|
}
|
|
946
769
|
/**
|
|
947
|
-
*
|
|
948
|
-
*
|
|
770
|
+
* Auth helpers exposed on `fastify.auth`.
|
|
771
|
+
*
|
|
772
|
+
* @example
|
|
773
|
+
* ```typescript
|
|
774
|
+
* const tokens = fastify.auth.issueTokens({
|
|
775
|
+
* id: user._id,
|
|
776
|
+
* email: user.email,
|
|
777
|
+
* role: user.role,
|
|
778
|
+
* });
|
|
779
|
+
* return { success: true, ...tokens, user };
|
|
780
|
+
* ```
|
|
949
781
|
*/
|
|
950
|
-
interface
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
782
|
+
interface AuthHelpers {
|
|
783
|
+
/** JWT utilities (if configured) */
|
|
784
|
+
jwt: JwtContext | null;
|
|
785
|
+
/**
|
|
786
|
+
* Issue access + refresh tokens for a user. App calls this after
|
|
787
|
+
* validating credentials.
|
|
788
|
+
*/
|
|
789
|
+
issueTokens: (payload: Record<string, unknown>, options?: {
|
|
790
|
+
expiresIn?: string;
|
|
791
|
+
refreshExpiresIn?: string;
|
|
792
|
+
}) => TokenPair;
|
|
793
|
+
/** Verify a refresh token and return decoded payload. */
|
|
794
|
+
verifyRefreshToken: <T = Record<string, unknown>>(token: string) => T;
|
|
957
795
|
}
|
|
958
|
-
//#endregion
|
|
959
|
-
//#region src/types/resource.d.ts
|
|
960
|
-
/** Standard controller type alias for CRUD operations. */
|
|
961
|
-
type CrudController<TDoc> = IController<TDoc>;
|
|
962
796
|
/**
|
|
963
|
-
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
797
|
+
* Auth plugin options — clean, minimal configuration.
|
|
798
|
+
*
|
|
799
|
+
* Arc provides JWT infrastructure and calls your authenticator. You
|
|
800
|
+
* control all authentication logic.
|
|
801
|
+
*
|
|
802
|
+
* @example
|
|
803
|
+
* ```typescript
|
|
804
|
+
* auth: {
|
|
805
|
+
* jwt: { secret: process.env.JWT_SECRET },
|
|
806
|
+
* authenticate: async (request, { jwt }) => {
|
|
807
|
+
* const token = request.headers.authorization?.split(' ')[1];
|
|
808
|
+
* if (!token) return null;
|
|
809
|
+
* const decoded = jwt.verify(token);
|
|
810
|
+
* return userRepo.findById(decoded.id);
|
|
811
|
+
* },
|
|
812
|
+
* }
|
|
813
|
+
* ```
|
|
966
814
|
*/
|
|
967
|
-
interface
|
|
968
|
-
/**
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
gcTime?: number;
|
|
815
|
+
interface AuthPluginOptions {
|
|
816
|
+
/**
|
|
817
|
+
* JWT configuration (optional but recommended). If provided, JWT
|
|
818
|
+
* utilities are available in the authenticator context.
|
|
819
|
+
*/
|
|
820
|
+
jwt?: {
|
|
821
|
+
/** JWT secret (required for JWT features) */secret: string; /** Access token expiry (default: '15m') */
|
|
822
|
+
expiresIn?: string; /** Refresh token secret (defaults to main secret) */
|
|
823
|
+
refreshSecret?: string; /** Refresh token expiry (default: '7d') */
|
|
824
|
+
refreshExpiresIn?: string; /** Additional `@fastify/jwt` sign options */
|
|
825
|
+
sign?: Record<string, unknown>; /** Additional `@fastify/jwt` verify options */
|
|
826
|
+
verify?: Record<string, unknown>;
|
|
980
827
|
};
|
|
981
|
-
/** Tags for cross-resource invalidation grouping */
|
|
982
|
-
tags?: string[];
|
|
983
828
|
/**
|
|
984
|
-
*
|
|
985
|
-
*
|
|
829
|
+
* Custom authenticator function. Arc calls this for non-public routes.
|
|
830
|
+
* If not provided and `jwt.secret` is set, uses default `jwtVerify`.
|
|
986
831
|
*/
|
|
987
|
-
|
|
988
|
-
/** Disable caching for this resource */
|
|
989
|
-
disabled?: boolean;
|
|
990
|
-
}
|
|
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[];
|
|
832
|
+
authenticate?: Authenticator;
|
|
1003
833
|
/**
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
834
|
+
* Custom auth failure handler. Customize the 401 response when
|
|
835
|
+
* authentication fails.
|
|
1006
836
|
*/
|
|
1007
|
-
|
|
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>;
|
|
837
|
+
onFailure?: (request: FastifyRequest, reply: FastifyReply, error?: Error) => void | Promise<void>;
|
|
1025
838
|
/**
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
* `buildCrudSchemasFromTable`, pgkit's equivalent). Mongoose-based
|
|
1030
|
-
* generators may ignore it — Mongoose schemas are inherently strict at
|
|
1031
|
-
* the model level.
|
|
839
|
+
* Expose detailed auth error messages in 401 responses. When `false`
|
|
840
|
+
* (default), returns generic "Authentication required". Decoupled from
|
|
841
|
+
* log level — set explicitly per environment.
|
|
1032
842
|
*/
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
field: string;
|
|
1037
|
-
required?: boolean;
|
|
1038
|
-
readonly?: boolean;
|
|
1039
|
-
hidden?: boolean;
|
|
1040
|
-
}
|
|
1041
|
-
/**
|
|
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.
|
|
1046
|
-
*/
|
|
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;
|
|
1065
|
-
};
|
|
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;
|
|
1080
|
-
}
|
|
1081
|
-
interface OpenApiSchemas {
|
|
1082
|
-
entity?: unknown;
|
|
1083
|
-
createBody?: unknown;
|
|
1084
|
-
updateBody?: unknown;
|
|
1085
|
-
params?: unknown;
|
|
1086
|
-
listQuery?: unknown;
|
|
1087
|
-
/**
|
|
1088
|
-
* Explicit response schema for OpenAPI documentation. Auto-generated
|
|
1089
|
-
* from `createBody` if omitted. Does NOT affect Fastify serialization.
|
|
1090
|
-
*/
|
|
1091
|
-
response?: unknown;
|
|
1092
|
-
[key: string]: unknown;
|
|
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
|
-
}
|
|
1117
|
-
/**
|
|
1118
|
-
* Route definition — single custom-route shape (user-facing + internal).
|
|
1119
|
-
*
|
|
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
|
|
1123
|
-
*/
|
|
1124
|
-
interface RouteDefinition {
|
|
1125
|
-
readonly method: RouteMethod;
|
|
1126
|
-
/** Path relative to resource prefix */
|
|
1127
|
-
readonly path: string;
|
|
1128
|
-
/**
|
|
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)`
|
|
1133
|
-
*/
|
|
1134
|
-
readonly handler: string | ControllerHandler | RouteHandlerMethod | ((request: FastifyRequest<Record<string, unknown>>, reply: FastifyReply) => unknown);
|
|
1135
|
-
/** Permission check — REQUIRED */
|
|
1136
|
-
readonly permissions: PermissionCheck;
|
|
1137
|
-
/**
|
|
1138
|
-
* Raw mode — bypasses Arc pipeline. Handler receives raw Fastify
|
|
1139
|
-
* request/reply. Default: false.
|
|
1140
|
-
*/
|
|
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;
|
|
843
|
+
exposeAuthErrors?: boolean;
|
|
844
|
+
/** Property name to store user on request (default: 'user') */
|
|
845
|
+
userProperty?: string;
|
|
1156
846
|
/**
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
847
|
+
* Custom token extractor for the built-in JWT auth path. Defaults to
|
|
848
|
+
* extracting Bearer token from Authorization header. Use when tokens
|
|
849
|
+
* are in HttpOnly cookies, custom headers, or query params.
|
|
850
|
+
*
|
|
851
|
+
* @example
|
|
852
|
+
* ```typescript
|
|
853
|
+
* tokenExtractor: (request) => request.cookies?.['auth-token'] ?? null,
|
|
854
|
+
* ```
|
|
1160
855
|
*/
|
|
1161
|
-
|
|
1162
|
-
body?: unknown;
|
|
1163
|
-
querystring?: unknown;
|
|
1164
|
-
params?: unknown;
|
|
1165
|
-
headers?: unknown;
|
|
1166
|
-
response?: Record<number | string, unknown>;
|
|
1167
|
-
[key: string]: unknown;
|
|
1168
|
-
};
|
|
856
|
+
tokenExtractor?: (request: FastifyRequest) => string | null;
|
|
1169
857
|
/**
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1172
|
-
*
|
|
1173
|
-
* -
|
|
858
|
+
* Token revocation check — called after JWT verification succeeds.
|
|
859
|
+
* Return `true` to reject the token (revoked), `false` to allow.
|
|
860
|
+
*
|
|
861
|
+
* **Fail-closed**: if the check throws, the token is treated as revoked.
|
|
862
|
+
*
|
|
863
|
+
* @example
|
|
864
|
+
* ```typescript
|
|
865
|
+
* isRevoked: async (decoded) => {
|
|
866
|
+
* return await redis.sismember('revoked-tokens', decoded.jti ?? decoded.id);
|
|
867
|
+
* },
|
|
868
|
+
* ```
|
|
1174
869
|
*/
|
|
1175
|
-
|
|
870
|
+
isRevoked?: (decoded: Record<string, unknown>) => boolean | Promise<boolean>;
|
|
1176
871
|
/**
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
872
|
+
* Enforce strict JWT `type` claim validation (default: `true`).
|
|
873
|
+
*
|
|
874
|
+
* When enabled, `authenticate` requires `decoded.type === "access"`.
|
|
875
|
+
* Tokens with a missing or unexpected `type` claim are rejected —
|
|
876
|
+
* defence in depth for apps that reuse the JWT secret to sign other
|
|
877
|
+
* token kinds (invite links, one-time verification codes).
|
|
878
|
+
*
|
|
879
|
+
* Arc's own `issueTokens` always sets `type: "access"` or
|
|
880
|
+
* `type: "refresh"`, so this default is safe for Arc-generated tokens.
|
|
881
|
+
*
|
|
882
|
+
* Set to `false` ONLY when you must accept tokens signed without a
|
|
883
|
+
* `type` claim (e.g. a legacy issuer you don't control). In that mode
|
|
884
|
+
* Arc still rejects tokens explicitly marked `type: "refresh"`.
|
|
1179
885
|
*/
|
|
1180
|
-
|
|
1181
|
-
content: Array<{
|
|
1182
|
-
type: string;
|
|
1183
|
-
text: string;
|
|
1184
|
-
}>;
|
|
1185
|
-
isError?: boolean;
|
|
1186
|
-
}>;
|
|
886
|
+
strictTokenType?: boolean;
|
|
1187
887
|
}
|
|
888
|
+
//#endregion
|
|
889
|
+
//#region src/pipeline/types.d.ts
|
|
1188
890
|
/**
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
891
|
+
* Pipeline context passed to guards, transforms, and interceptors.
|
|
892
|
+
* Extends IRequestContext with pipeline-specific metadata.
|
|
1191
893
|
*/
|
|
1192
|
-
|
|
1193
|
-
/**
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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;
|
|
894
|
+
interface PipelineContext extends IRequestContext {
|
|
895
|
+
/** Resource name being accessed */
|
|
896
|
+
resource: string;
|
|
897
|
+
/** CRUD operation being performed */
|
|
898
|
+
operation: "list" | "get" | "create" | "update" | "delete" | string;
|
|
1213
899
|
}
|
|
1214
|
-
/** Action config: bare handler function OR full ActionDefinition. */
|
|
1215
|
-
type ActionEntry = ActionHandlerFn | ActionDefinition;
|
|
1216
|
-
/** Actions configuration map. */
|
|
1217
|
-
type ActionsMap = Record<string, ActionEntry>;
|
|
1218
900
|
/**
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
901
|
+
* Which operations a pipeline step applies to.
|
|
902
|
+
* If omitted, applies to ALL operations.
|
|
1221
903
|
*/
|
|
1222
|
-
|
|
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;
|
|
1229
|
-
}
|
|
904
|
+
type OperationFilter = Array<"list" | "get" | "create" | "update" | "delete" | string>;
|
|
1230
905
|
/**
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
* @example
|
|
1235
|
-
* ```typescript
|
|
1236
|
-
* defineResource({
|
|
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
|
-
* },
|
|
1244
|
-
* });
|
|
1245
|
-
* ```
|
|
906
|
+
* Guard — boolean check that short-circuits on failure.
|
|
907
|
+
* Return true to proceed, throw to deny.
|
|
1246
908
|
*/
|
|
1247
|
-
interface
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
beforeDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
1253
|
-
afterDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
909
|
+
interface Guard {
|
|
910
|
+
readonly _type: "guard";
|
|
911
|
+
readonly name: string;
|
|
912
|
+
readonly operations?: OperationFilter;
|
|
913
|
+
handler(ctx: PipelineContext): boolean | Promise<boolean>;
|
|
1254
914
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
915
|
+
/**
|
|
916
|
+
* Transform — modifies request data before the handler.
|
|
917
|
+
* Returns modified context (or mutates in place).
|
|
918
|
+
*/
|
|
919
|
+
interface Transform {
|
|
920
|
+
readonly _type: "transform";
|
|
921
|
+
readonly name: string;
|
|
922
|
+
readonly operations?: OperationFilter;
|
|
923
|
+
handler(ctx: PipelineContext): PipelineContext | undefined | Promise<PipelineContext | undefined>;
|
|
1260
924
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
925
|
+
/**
|
|
926
|
+
* Next function passed to interceptors — calls the handler (or next interceptor).
|
|
927
|
+
*/
|
|
928
|
+
type NextFunction = () => Promise<IControllerResponse<unknown>>;
|
|
929
|
+
/**
|
|
930
|
+
* Intercept — wraps handler execution (before + after pattern).
|
|
931
|
+
*/
|
|
932
|
+
interface Interceptor {
|
|
933
|
+
readonly _type: "interceptor";
|
|
934
|
+
readonly name: string;
|
|
935
|
+
readonly operations?: OperationFilter;
|
|
936
|
+
handler(ctx: PipelineContext, next: NextFunction): Promise<IControllerResponse<unknown>>;
|
|
1269
937
|
}
|
|
1270
|
-
type
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
938
|
+
type PipelineStep = Guard | Transform | Interceptor;
|
|
939
|
+
/**
|
|
940
|
+
* Pipeline configuration — can be a flat array or per-operation map.
|
|
941
|
+
*/
|
|
942
|
+
type PipelineConfig = PipelineStep[] | {
|
|
943
|
+
list?: PipelineStep[];
|
|
944
|
+
get?: PipelineStep[];
|
|
945
|
+
create?: PipelineStep[];
|
|
946
|
+
update?: PipelineStep[];
|
|
947
|
+
delete?: PipelineStep[];
|
|
948
|
+
[operation: string]: PipelineStep[] | undefined;
|
|
949
|
+
};
|
|
950
|
+
//#endregion
|
|
951
|
+
//#region src/types/handlers.d.ts
|
|
952
|
+
/**
|
|
953
|
+
* Minimal server accessor — exposes safe, read-only server decorators.
|
|
954
|
+
* Allows controller handlers to publish events, log, and audit
|
|
955
|
+
* without switching to `raw: true`.
|
|
956
|
+
*/
|
|
957
|
+
interface ServerAccessor {
|
|
958
|
+
/** Event bus — publish domain events from any handler */
|
|
959
|
+
events?: {
|
|
960
|
+
publish: <T>(type: string, payload: T, meta?: Partial<Record<string, unknown>>) => Promise<void>;
|
|
961
|
+
};
|
|
962
|
+
/** Audit logger — log custom audit entries */
|
|
963
|
+
audit?: {
|
|
964
|
+
create: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
965
|
+
update: (resource: string, documentId: string, before: Record<string, unknown>, after: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
966
|
+
delete: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
967
|
+
custom: (resource: string, documentId: string, action: string, data?: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
968
|
+
};
|
|
969
|
+
/** Logger — structured logging */
|
|
970
|
+
log?: {
|
|
971
|
+
info: (...args: unknown[]) => void;
|
|
972
|
+
warn: (...args: unknown[]) => void;
|
|
973
|
+
error: (...args: unknown[]) => void;
|
|
974
|
+
debug: (...args: unknown[]) => void;
|
|
975
|
+
};
|
|
976
|
+
/** QueryCache — stale-while-revalidate data cache */
|
|
977
|
+
queryCache?: {
|
|
978
|
+
get: <T>(key: string) => Promise<{
|
|
979
|
+
data: T;
|
|
980
|
+
status: "fresh" | "stale" | "miss";
|
|
981
|
+
}>;
|
|
982
|
+
set: <T>(key: string, data: T, config: {
|
|
983
|
+
staleTime?: number;
|
|
984
|
+
gcTime?: number;
|
|
985
|
+
tags?: string[];
|
|
986
|
+
}) => Promise<void>;
|
|
987
|
+
getResourceVersion: (resource: string) => Promise<number>;
|
|
988
|
+
bumpResourceVersion: (resource: string) => Promise<void>;
|
|
989
|
+
};
|
|
1278
990
|
}
|
|
1279
|
-
/**
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
991
|
+
/**
|
|
992
|
+
* Request context passed to controller handlers.
|
|
993
|
+
*
|
|
994
|
+
* **Generic parameters** (all default to safe permissive types so existing code keeps working):
|
|
995
|
+
* - `TBody` — request body shape (default: `unknown`)
|
|
996
|
+
* - `TParams` — route param shape (default: `Record<string, string>`)
|
|
997
|
+
* - `TQuery` — query string shape (default: `Record<string, unknown>`)
|
|
998
|
+
* - `TUser` — authenticated user shape (default: `UserBase`)
|
|
999
|
+
* - `TMetadata` — internal metadata shape (default: `Record<string, unknown>`;
|
|
1000
|
+
* override with `ArcInternalMetadata` or your own augmentation when you
|
|
1001
|
+
* need typed access to `_scope`, `_policyFilters`, custom hook context, etc.)
|
|
1002
|
+
*
|
|
1003
|
+
* @example
|
|
1004
|
+
* ```typescript
|
|
1005
|
+
* // Untyped (default) — req.body is `unknown`, must be narrowed
|
|
1006
|
+
* async create(req: IRequestContext) {
|
|
1007
|
+
* const data = req.body as Partial<Product>;
|
|
1008
|
+
* return { success: true, data: await productRepo.create(data) };
|
|
1009
|
+
* }
|
|
1010
|
+
*
|
|
1011
|
+
* // Typed body — req.body is `CreateProductInput`, narrowing not needed
|
|
1012
|
+
* async create(req: IRequestContext<CreateProductInput>) {
|
|
1013
|
+
* return { success: true, data: await productRepo.create(req.body) };
|
|
1014
|
+
* }
|
|
1015
|
+
*
|
|
1016
|
+
* // Fully typed — body, route params, query, and metadata
|
|
1017
|
+
* async update(
|
|
1018
|
+
* req: IRequestContext<
|
|
1019
|
+
* Partial<Product>,
|
|
1020
|
+
* { id: string },
|
|
1021
|
+
* { fields?: string },
|
|
1022
|
+
* ArcInternalMetadata
|
|
1023
|
+
* >,
|
|
1024
|
+
* ) {
|
|
1025
|
+
* const fields = req.query.fields?.split(',');
|
|
1026
|
+
* const orgId = req.metadata?._scope ? getOrgId(req.metadata._scope) : undefined;
|
|
1027
|
+
* return { success: true, data: await productRepo.update(req.params.id, req.body) };
|
|
1028
|
+
* }
|
|
1029
|
+
* ```
|
|
1030
|
+
*/
|
|
1031
|
+
/**
|
|
1032
|
+
* First-class projection of `request._scope` for controller handlers.
|
|
1033
|
+
*
|
|
1034
|
+
* **v2.10.6:** previously, pulling tenant/user/role info from inside a
|
|
1035
|
+
* controller override meant digging through `req.metadata._scope` and
|
|
1036
|
+
* calling `getOrgId(scope)` / `getUserId(user)` manually. This projection
|
|
1037
|
+
* lifts the two fields most hosts need directly onto `req` so cross-kit
|
|
1038
|
+
* controller code reads:
|
|
1039
|
+
*
|
|
1040
|
+
* ```ts
|
|
1041
|
+
* async create(req: IRequestContext) {
|
|
1042
|
+
* const flowCtx = { organizationId: req.scope?.organizationId, actorId: req.scope?.userId };
|
|
1043
|
+
* }
|
|
1044
|
+
* ```
|
|
1045
|
+
*
|
|
1046
|
+
* Full scope shape (discriminated union of `member` / `service` / `elevated` / `public`)
|
|
1047
|
+
* still lives on `req.metadata._scope` for code that needs to branch on
|
|
1048
|
+
* `scope.kind` — this projection just surfaces the two keys every
|
|
1049
|
+
* tenant-scoped resource reaches for.
|
|
1050
|
+
*/
|
|
1051
|
+
interface RequestScopeProjection {
|
|
1052
|
+
/** Tenant the caller is scoped to (org member, service key bound to an org, or elevated admin's target org). */
|
|
1053
|
+
organizationId?: string;
|
|
1054
|
+
/** Caller's user id when authenticated — undefined for public / service-only scopes. */
|
|
1055
|
+
userId?: string;
|
|
1056
|
+
/** Org-level roles (e.g. `['admin', 'warehouse-manager']`) — separate from global `user.roles`. */
|
|
1057
|
+
orgRoles?: string[];
|
|
1286
1058
|
}
|
|
1287
|
-
interface
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
* ```
|
|
1303
|
-
*/
|
|
1304
|
-
skipGlobalPrefix?: boolean;
|
|
1305
|
-
/** Optional for service-pattern resources */
|
|
1306
|
-
adapter?: DataAdapter<TDoc>;
|
|
1307
|
-
/** Controller instance — accepts any object with CRUD methods. */
|
|
1308
|
-
controller?: IController<TDoc> | ControllerLike;
|
|
1309
|
-
queryParser?: unknown;
|
|
1310
|
-
permissions?: ResourcePermissions;
|
|
1311
|
-
schemaOptions?: RouteSchemaOptions;
|
|
1312
|
-
openApiSchemas?: OpenApiSchemas;
|
|
1313
|
-
/** Custom JSON schemas (override Arc-generated). */
|
|
1314
|
-
customSchemas?: Partial<CrudSchemas>;
|
|
1315
|
-
/** Preset names, objects, or PresetResult values. */
|
|
1316
|
-
presets?: Array<string | PresetResult | {
|
|
1317
|
-
name: string;
|
|
1318
|
-
[key: string]: unknown;
|
|
1319
|
-
}>;
|
|
1320
|
-
hooks?: ResourceHooks;
|
|
1059
|
+
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>> {
|
|
1060
|
+
/** Route parameters (e.g., { id: '123' }) */
|
|
1061
|
+
params: TParams;
|
|
1062
|
+
/** Query string parameters */
|
|
1063
|
+
query: TQuery;
|
|
1064
|
+
/** Request body */
|
|
1065
|
+
body: TBody;
|
|
1066
|
+
/** Authenticated user or null */
|
|
1067
|
+
user: TUser | null;
|
|
1068
|
+
/** Request headers */
|
|
1069
|
+
headers: Record<string, string | undefined>;
|
|
1070
|
+
/** Organization ID (for multi-tenant apps) */
|
|
1071
|
+
organizationId?: string;
|
|
1072
|
+
/** Team ID (for team-scoped resources) */
|
|
1073
|
+
teamId?: string;
|
|
1321
1074
|
/**
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1325
|
-
*
|
|
1326
|
-
* ```typescript
|
|
1327
|
-
* pipe: pipe(isActive, slugify, timing),
|
|
1328
|
-
* pipe: { create: pipe(isActive, slugify), list: pipe(timing) },
|
|
1329
|
-
* ```
|
|
1075
|
+
* First-class tenant/user scope projection — lifted from `metadata._scope`
|
|
1076
|
+
* so controller overrides don't have to dig through Arc internals.
|
|
1077
|
+
* See {@link RequestScopeProjection}. `undefined` for routes that run
|
|
1078
|
+
* without auth / scope resolution.
|
|
1330
1079
|
*/
|
|
1331
|
-
|
|
1080
|
+
scope?: RequestScopeProjection;
|
|
1332
1081
|
/**
|
|
1333
|
-
*
|
|
1082
|
+
* Organization/auth context from middleware.
|
|
1083
|
+
* Contains orgRoles, orgScope, organizationId, and any custom fields
|
|
1084
|
+
* set by the auth adapter or org-scope plugin.
|
|
1334
1085
|
*
|
|
1335
1086
|
* @example
|
|
1336
1087
|
* ```typescript
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1088
|
+
* async create(req: IRequestContext) {
|
|
1089
|
+
* const roles = req.context?.orgRoles ?? [];
|
|
1090
|
+
* if (roles.includes('manager')) { ... }
|
|
1340
1091
|
* }
|
|
1341
1092
|
* ```
|
|
1342
1093
|
*/
|
|
1343
|
-
|
|
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";
|
|
1354
|
-
middlewares?: MiddlewareConfig;
|
|
1094
|
+
context?: RequestContext;
|
|
1355
1095
|
/**
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
* flags — anything that applies to every endpoint.
|
|
1096
|
+
* Internal metadata (includes context + Arc internals like `_policyFilters`,
|
|
1097
|
+
* `_scope`, `log`). Type as `ArcInternalMetadata` for typed access to Arc's
|
|
1098
|
+
* built-in fields, or supply your own interface to layer custom fields.
|
|
1360
1099
|
*/
|
|
1361
|
-
|
|
1100
|
+
metadata?: TMetadata;
|
|
1362
1101
|
/**
|
|
1363
|
-
*
|
|
1102
|
+
* Fastify server accessor — publish events, log, and audit
|
|
1103
|
+
* from any handler without switching to `raw: true`.
|
|
1364
1104
|
*
|
|
1365
1105
|
* @example
|
|
1366
1106
|
* ```typescript
|
|
1367
|
-
*
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1107
|
+
* async reschedule(req: IRequestContext) {
|
|
1108
|
+
* const result = await repo.reschedule(req.params.id, req.body);
|
|
1109
|
+
* await req.server?.events?.publish('interview.rescheduled', { data: result });
|
|
1110
|
+
* return { success: true, data: result };
|
|
1111
|
+
* }
|
|
1371
1112
|
* ```
|
|
1372
1113
|
*/
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1114
|
+
server?: ServerAccessor;
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Standard response from controller handlers
|
|
1118
|
+
*/
|
|
1119
|
+
interface IControllerResponse<T = unknown> {
|
|
1120
|
+
/** Operation success status */
|
|
1121
|
+
success: boolean;
|
|
1122
|
+
/** Response data */
|
|
1123
|
+
data?: T;
|
|
1124
|
+
/** Error message (when success is false) */
|
|
1125
|
+
error?: string;
|
|
1126
|
+
/** HTTP status code (default: 200 for success, 400 for error) */
|
|
1127
|
+
status?: number;
|
|
1128
|
+
/** Additional metadata */
|
|
1129
|
+
meta?: Record<string, unknown>;
|
|
1130
|
+
/** Error details (for debugging) */
|
|
1131
|
+
details?: Record<string, unknown>;
|
|
1132
|
+
/** Custom response headers (e.g., X-Total-Count, Link, ETag) */
|
|
1133
|
+
headers?: Record<string, string>;
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Controller handler — Arc's standard pattern.
|
|
1137
|
+
*
|
|
1138
|
+
* Receives a request context object, returns IControllerResponse.
|
|
1139
|
+
* Use with `raw: false` in routes.
|
|
1140
|
+
*
|
|
1141
|
+
* **Generic parameters:**
|
|
1142
|
+
* - `TResponse` — shape of `IControllerResponse.data` (default: `unknown`)
|
|
1143
|
+
* - `TBody` — shape of `req.body` (default: `unknown`)
|
|
1144
|
+
* - `TParams` — shape of `req.params` (default: `Record<string, string>`)
|
|
1145
|
+
* - `TQuery` — shape of `req.query` (default: `Record<string, unknown>`)
|
|
1146
|
+
*
|
|
1147
|
+
* Backward-compatible: `ControllerHandler<Product>` still works (only the
|
|
1148
|
+
* response data is typed); add more generics as needed when you want
|
|
1149
|
+
* type-safe access to the request body, params, or query string.
|
|
1150
|
+
*
|
|
1151
|
+
* @example
|
|
1152
|
+
* ```typescript
|
|
1153
|
+
* // Untyped req — body is unknown, must be narrowed
|
|
1154
|
+
* const createProduct: ControllerHandler<Product> = async (req) => {
|
|
1155
|
+
* const product = await productRepo.create(req.body as Partial<Product>);
|
|
1156
|
+
* return { success: true, data: product, status: 201 };
|
|
1157
|
+
* };
|
|
1158
|
+
*
|
|
1159
|
+
* // Fully typed — body, params, query, and response all inferred
|
|
1160
|
+
* const updateProduct: ControllerHandler<
|
|
1161
|
+
* Product,
|
|
1162
|
+
* Partial<Product>,
|
|
1163
|
+
* { id: string },
|
|
1164
|
+
* { upsert?: string }
|
|
1165
|
+
* > = async (req) => {
|
|
1166
|
+
* const upsert = req.query.upsert === "true";
|
|
1167
|
+
* const product = await productRepo.update(req.params.id, req.body, { upsert });
|
|
1168
|
+
* return { success: true, data: product };
|
|
1169
|
+
* };
|
|
1170
|
+
*
|
|
1171
|
+
* routes: [{
|
|
1172
|
+
* method: 'POST',
|
|
1173
|
+
* path: '/products',
|
|
1174
|
+
* handler: createProduct,
|
|
1175
|
+
* permissions: requireAuth(),
|
|
1176
|
+
* raw: false, // Arc wraps this into Fastify handler
|
|
1177
|
+
* }]
|
|
1178
|
+
* ```
|
|
1179
|
+
*/
|
|
1180
|
+
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>>;
|
|
1181
|
+
/**
|
|
1182
|
+
* Fastify native handler
|
|
1183
|
+
*
|
|
1184
|
+
* Standard Fastify request/reply pattern.
|
|
1185
|
+
* Use with `raw: true` in routes.
|
|
1186
|
+
*
|
|
1187
|
+
* @example
|
|
1188
|
+
* ```typescript
|
|
1189
|
+
* const downloadFile: FastifyHandler = async (request, reply) => {
|
|
1190
|
+
* const file = await getFile(request.params.id);
|
|
1191
|
+
* reply.header('Content-Type', file.mimeType);
|
|
1192
|
+
* return reply.send(file.buffer);
|
|
1193
|
+
* };
|
|
1194
|
+
*
|
|
1195
|
+
* routes: [{
|
|
1196
|
+
* method: 'GET',
|
|
1197
|
+
* path: '/files/:id/download',
|
|
1198
|
+
* handler: downloadFile,
|
|
1199
|
+
* permissions: requireAuth(),
|
|
1200
|
+
* raw: true, // Use as-is, no wrapping
|
|
1201
|
+
* }]
|
|
1202
|
+
* ```
|
|
1203
|
+
*/
|
|
1204
|
+
type FastifyHandler<RouteGeneric extends Record<string, unknown> = Record<string, unknown>> = (request: FastifyRequest<RouteGeneric>, reply: FastifyReply) => Promise<unknown> | unknown;
|
|
1205
|
+
/**
|
|
1206
|
+
* Union type for route handlers
|
|
1207
|
+
*/
|
|
1208
|
+
type RouteHandler = ControllerHandler | FastifyHandler;
|
|
1209
|
+
/**
|
|
1210
|
+
* Controller interface for CRUD operations (strict).
|
|
1211
|
+
*
|
|
1212
|
+
* `list`'s return type aligns with repo-core's `MinimalRepo.getAll()`
|
|
1213
|
+
* contract — the kit MAY return an offset envelope, a keyset envelope,
|
|
1214
|
+
* or a raw array. Arc's `BaseController` forwards the kit's response
|
|
1215
|
+
* verbatim; consumers narrow on shape
|
|
1216
|
+
* (`Array.isArray(data)` → bare array, presence of `total` → offset,
|
|
1217
|
+
* presence of `nextCursor` → keyset).
|
|
1218
|
+
*/
|
|
1219
|
+
interface IController<TDoc = unknown> {
|
|
1220
|
+
list(req: IRequestContext): Promise<IControllerResponse<unknown>>;
|
|
1221
|
+
get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1222
|
+
create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1223
|
+
update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1224
|
+
delete(req: IRequestContext): Promise<IControllerResponse<{
|
|
1225
|
+
message: string;
|
|
1226
|
+
}>>;
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Flexible controller interface — accepts controllers with any handler style,
|
|
1230
|
+
* including class instances with extra methods / private fields.
|
|
1231
|
+
*
|
|
1232
|
+
* **v2.10.6:** the previous `[key: string]: unknown` index signature made
|
|
1233
|
+
* real class instances fail structural assignment (`new ScrapController()`
|
|
1234
|
+
* needed a `as unknown as ControllerLike` cast, because class instances
|
|
1235
|
+
* don't have an index signature). Dropped — arc only invokes the CRUD
|
|
1236
|
+
* methods at runtime, so the rest of the shape is the caller's concern.
|
|
1237
|
+
*
|
|
1238
|
+
* The five CRUD slots stay optional so partial controllers (e.g. read-only)
|
|
1239
|
+
* assign too. Additional domain methods on the controller are allowed by
|
|
1240
|
+
* construction (they're just not part of this contract).
|
|
1241
|
+
*/
|
|
1242
|
+
interface ControllerLike {
|
|
1243
|
+
list?: unknown;
|
|
1244
|
+
get?: unknown;
|
|
1245
|
+
create?: unknown;
|
|
1246
|
+
update?: unknown;
|
|
1247
|
+
delete?: unknown;
|
|
1248
|
+
}
|
|
1249
|
+
//#endregion
|
|
1250
|
+
//#region src/types/resource.d.ts
|
|
1251
|
+
/** Standard controller type alias for CRUD operations. */
|
|
1252
|
+
type CrudController<TDoc> = IController<TDoc>;
|
|
1253
|
+
/**
|
|
1254
|
+
* Per-resource cache configuration for QueryCache. Enables
|
|
1255
|
+
* stale-while-revalidate, auto-invalidation on mutations, and
|
|
1256
|
+
* cross-resource tag-based invalidation.
|
|
1257
|
+
*/
|
|
1258
|
+
interface ResourceCacheConfig {
|
|
1259
|
+
/** Seconds data is "fresh" (no revalidation). Default: 0 */
|
|
1260
|
+
staleTime?: number;
|
|
1261
|
+
/** Seconds stale data stays cached (SWR window). Default: 60 */
|
|
1262
|
+
gcTime?: number;
|
|
1263
|
+
/** Per-operation overrides */
|
|
1264
|
+
list?: {
|
|
1265
|
+
staleTime?: number;
|
|
1266
|
+
gcTime?: number;
|
|
1267
|
+
};
|
|
1268
|
+
byId?: {
|
|
1269
|
+
staleTime?: number;
|
|
1270
|
+
gcTime?: number;
|
|
1271
|
+
};
|
|
1272
|
+
/** Tags for cross-resource invalidation grouping */
|
|
1273
|
+
tags?: string[];
|
|
1274
|
+
/**
|
|
1275
|
+
* Cross-resource invalidation: event pattern → tag targets.
|
|
1276
|
+
* @example { 'category.*': ['catalog'] }
|
|
1277
|
+
*/
|
|
1278
|
+
invalidateOn?: Record<string, string[]>;
|
|
1279
|
+
/** Disable caching for this resource */
|
|
1280
|
+
disabled?: boolean;
|
|
1281
|
+
}
|
|
1282
|
+
interface RateLimitConfig {
|
|
1283
|
+
/** Maximum number of requests allowed within the time window */
|
|
1284
|
+
max: number;
|
|
1285
|
+
/** Time window for rate limiting (e.g., '1 minute', '15 seconds') */
|
|
1286
|
+
timeWindow: string;
|
|
1287
|
+
}
|
|
1288
|
+
interface RouteSchemaOptions {
|
|
1289
|
+
hiddenFields?: string[];
|
|
1290
|
+
readonlyFields?: string[];
|
|
1291
|
+
requiredFields?: string[];
|
|
1292
|
+
optionalFields?: string[];
|
|
1293
|
+
excludeFields?: string[];
|
|
1294
|
+
/**
|
|
1295
|
+
* Fields allowed for filtering in list operations. MCP auto-derives
|
|
1296
|
+
* from `QueryParser.allowedFilterFields` when not set explicitly.
|
|
1297
|
+
*/
|
|
1298
|
+
filterableFields?: string[];
|
|
1299
|
+
fieldRules?: Record<string, {
|
|
1300
|
+
systemManaged?: boolean;
|
|
1301
|
+
/**
|
|
1302
|
+
* When `true`, bypass the `systemManaged` / `readonly` / `immutable`
|
|
1303
|
+
* strip in `BodySanitizer` for callers whose request scope is
|
|
1304
|
+
* `elevated`. Lets platform admins stamp the value from the request
|
|
1305
|
+
* body — needed for cross-tenant admin writes where the tenant field
|
|
1306
|
+
* is the only way to pick a target org.
|
|
1307
|
+
*
|
|
1308
|
+
* Auto-set by `defineResource` on the configured `tenantField`. Hosts
|
|
1309
|
+
* can set it manually on other fields (e.g. `createdBy`) if they want
|
|
1310
|
+
* elevation-only override semantics for those too.
|
|
1311
|
+
*
|
|
1312
|
+
* Has no effect when `isElevated(scope)` is false — member and
|
|
1313
|
+
* service callers continue to have the field stripped.
|
|
1314
|
+
*/
|
|
1315
|
+
preserveForElevated?: boolean;
|
|
1316
|
+
hidden?: boolean;
|
|
1317
|
+
immutable?: boolean;
|
|
1318
|
+
immutableAfterCreate?: boolean;
|
|
1319
|
+
optional?: boolean; /** String minimum length — auto-maps to OpenAPI `minLength` and MCP tool schema */
|
|
1320
|
+
minLength?: number; /** String maximum length — auto-maps to OpenAPI `maxLength` and MCP tool schema */
|
|
1321
|
+
maxLength?: number; /** Number minimum — auto-maps to OpenAPI `minimum` and MCP tool schema */
|
|
1322
|
+
min?: number; /** Number maximum — auto-maps to OpenAPI `maximum` and MCP tool schema */
|
|
1323
|
+
max?: number; /** Regex pattern — auto-maps to OpenAPI `pattern` and MCP tool schema */
|
|
1324
|
+
pattern?: string; /** Allowed values — auto-maps to OpenAPI `enum` and MCP tool schema */
|
|
1325
|
+
enum?: ReadonlyArray<string | number>; /** Human-readable description — auto-maps to OpenAPI `description` */
|
|
1326
|
+
description?: string;
|
|
1327
|
+
[key: string]: unknown;
|
|
1328
|
+
}>;
|
|
1329
|
+
/** Query parameter schema for OpenAPI */
|
|
1330
|
+
query?: Record<string, unknown>;
|
|
1331
|
+
/**
|
|
1332
|
+
* When `true`, emitted CRUD body schemas set `additionalProperties: false`
|
|
1333
|
+
* so AJV rejects unknown fields on create / update. Honored by kit schema
|
|
1334
|
+
* generators that receive this options bag (sqlitekit's
|
|
1335
|
+
* `buildCrudSchemasFromTable`, pgkit's equivalent). Mongoose-based
|
|
1336
|
+
* generators may ignore it — Mongoose schemas are inherently strict at
|
|
1337
|
+
* the model level.
|
|
1338
|
+
*/
|
|
1339
|
+
strictAdditionalProperties?: boolean;
|
|
1340
|
+
}
|
|
1341
|
+
interface FieldRule {
|
|
1342
|
+
field: string;
|
|
1343
|
+
required?: boolean;
|
|
1344
|
+
readonly?: boolean;
|
|
1345
|
+
hidden?: boolean;
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* CRUD route schemas (Fastify native format). Each slot accepts a plain
|
|
1349
|
+
* JSON Schema object **or** a Zod v4 schema — Arc's `convertRouteSchema`
|
|
1350
|
+
* feature-detects at runtime. Slot values are typed `unknown` so
|
|
1351
|
+
* class-based Zod schemas assign without casts.
|
|
1352
|
+
*/
|
|
1353
|
+
interface CrudSchemas {
|
|
1354
|
+
/** GET / — list */
|
|
1355
|
+
list?: {
|
|
1356
|
+
querystring?: unknown;
|
|
1357
|
+
response?: Record<number, unknown>;
|
|
1358
|
+
[key: string]: unknown;
|
|
1359
|
+
};
|
|
1360
|
+
/** GET /:id — get one */
|
|
1361
|
+
get?: {
|
|
1362
|
+
params?: unknown;
|
|
1363
|
+
response?: Record<number, unknown>;
|
|
1364
|
+
[key: string]: unknown;
|
|
1365
|
+
};
|
|
1366
|
+
/** POST / — create */
|
|
1367
|
+
create?: {
|
|
1368
|
+
body?: unknown;
|
|
1369
|
+
response?: Record<number, unknown>;
|
|
1370
|
+
[key: string]: unknown;
|
|
1371
|
+
};
|
|
1372
|
+
/** PATCH /:id — update */
|
|
1373
|
+
update?: {
|
|
1374
|
+
params?: unknown;
|
|
1375
|
+
body?: unknown;
|
|
1376
|
+
response?: Record<number, unknown>;
|
|
1377
|
+
[key: string]: unknown;
|
|
1378
|
+
};
|
|
1379
|
+
/** DELETE /:id — delete */
|
|
1380
|
+
delete?: {
|
|
1381
|
+
params?: unknown;
|
|
1382
|
+
response?: Record<number, unknown>;
|
|
1383
|
+
[key: string]: unknown;
|
|
1384
|
+
};
|
|
1385
|
+
[key: string]: unknown;
|
|
1386
|
+
}
|
|
1387
|
+
interface OpenApiSchemas {
|
|
1388
|
+
entity?: unknown;
|
|
1389
|
+
createBody?: unknown;
|
|
1390
|
+
updateBody?: unknown;
|
|
1391
|
+
params?: unknown;
|
|
1392
|
+
listQuery?: unknown;
|
|
1393
|
+
/**
|
|
1394
|
+
* Explicit response schema for OpenAPI documentation. Auto-generated
|
|
1395
|
+
* from `createBody` if omitted. Does NOT affect Fastify serialization.
|
|
1396
|
+
*/
|
|
1397
|
+
response?: unknown;
|
|
1398
|
+
[key: string]: unknown;
|
|
1399
|
+
}
|
|
1400
|
+
type CrudRouteKey = "list" | "get" | "create" | "update" | "delete";
|
|
1401
|
+
interface MiddlewareConfig {
|
|
1402
|
+
list?: MiddlewareHandler[];
|
|
1403
|
+
get?: MiddlewareHandler[];
|
|
1404
|
+
create?: MiddlewareHandler[];
|
|
1405
|
+
update?: MiddlewareHandler[];
|
|
1406
|
+
delete?: MiddlewareHandler[];
|
|
1407
|
+
[key: string]: MiddlewareHandler[] | undefined;
|
|
1408
|
+
}
|
|
1409
|
+
/** HTTP methods for custom routes. */
|
|
1410
|
+
type RouteMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
1411
|
+
/** MCP tool configuration for a route or action. */
|
|
1412
|
+
interface RouteMcpConfig {
|
|
1413
|
+
/** Override auto-generated tool description */
|
|
1414
|
+
readonly description?: string;
|
|
1415
|
+
/** MCP tool annotations */
|
|
1416
|
+
readonly annotations?: {
|
|
1417
|
+
readonly readOnlyHint?: boolean;
|
|
1418
|
+
readonly destructiveHint?: boolean;
|
|
1419
|
+
readonly idempotentHint?: boolean;
|
|
1420
|
+
readonly openWorldHint?: boolean;
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Route definition — single custom-route shape (user-facing + internal).
|
|
1425
|
+
*
|
|
1426
|
+
* - `handler: 'string'` → controller method → full Arc pipeline + MCP tool
|
|
1427
|
+
* - `handler: function` → inline handler → full Arc pipeline + MCP tool
|
|
1428
|
+
* - `raw: true` → raw Fastify handler → no pipeline, no MCP by default
|
|
1429
|
+
*/
|
|
1430
|
+
interface RouteDefinition {
|
|
1431
|
+
readonly method: RouteMethod;
|
|
1432
|
+
/** Path relative to resource prefix */
|
|
1433
|
+
readonly path: string;
|
|
1434
|
+
/**
|
|
1435
|
+
* Route handler.
|
|
1436
|
+
* - String: controller method name (Arc pipeline)
|
|
1437
|
+
* - Function without `raw: true`: receives IRequestContext, returns IControllerResponse (Arc pipeline)
|
|
1438
|
+
* - Function with `raw: true`: raw Fastify handler `(request, reply)`
|
|
1439
|
+
*/
|
|
1440
|
+
readonly handler: string | ControllerHandler | RouteHandlerMethod | ((request: FastifyRequest<Record<string, unknown>>, reply: FastifyReply) => unknown);
|
|
1441
|
+
/** Permission check — REQUIRED */
|
|
1442
|
+
readonly permissions: PermissionCheck;
|
|
1443
|
+
/**
|
|
1444
|
+
* Raw mode — bypasses Arc pipeline. Handler receives raw Fastify
|
|
1445
|
+
* request/reply. Default: false.
|
|
1446
|
+
*/
|
|
1447
|
+
readonly raw?: boolean;
|
|
1448
|
+
/** Logical operation name (pipeline keys, MCP tool naming). */
|
|
1449
|
+
readonly operation?: string;
|
|
1450
|
+
/** OpenAPI summary */
|
|
1451
|
+
readonly summary?: string;
|
|
1452
|
+
/** OpenAPI description */
|
|
1453
|
+
readonly description?: string;
|
|
1454
|
+
/** OpenAPI tags */
|
|
1455
|
+
readonly tags?: string[];
|
|
1456
|
+
/** Route-level middleware */
|
|
1457
|
+
readonly preHandler?: RouteHandlerMethod[] | ((fastify: FastifyInstance) => RouteHandlerMethod[]);
|
|
1458
|
+
/** Pre-auth handlers (run before authentication) */
|
|
1459
|
+
readonly preAuth?: RouteHandlerMethod[];
|
|
1460
|
+
/** SSE streaming mode */
|
|
1461
|
+
readonly streamResponse?: boolean;
|
|
1462
|
+
/**
|
|
1463
|
+
* Fastify route schema. Each slot (`body`, `querystring`, `params`,
|
|
1464
|
+
* `headers`, `response[status]`) accepts a plain JSON Schema object
|
|
1465
|
+
* **or** a Zod v4 schema — Arc auto-converts via `convertRouteSchema`.
|
|
1466
|
+
*/
|
|
1467
|
+
readonly schema?: {
|
|
1468
|
+
body?: unknown;
|
|
1469
|
+
querystring?: unknown;
|
|
1470
|
+
params?: unknown;
|
|
1471
|
+
headers?: unknown;
|
|
1472
|
+
response?: Record<number | string, unknown>;
|
|
1473
|
+
[key: string]: unknown;
|
|
1474
|
+
};
|
|
1475
|
+
/**
|
|
1476
|
+
* MCP tool generation:
|
|
1477
|
+
* - omitted/true: auto-generate (non-raw routes only)
|
|
1478
|
+
* - false: skip MCP
|
|
1479
|
+
* - object: explicit config
|
|
1480
|
+
*/
|
|
1481
|
+
readonly mcp?: boolean | RouteMcpConfig;
|
|
1482
|
+
/**
|
|
1483
|
+
* MCP handler for raw routes — parallel entry point for MCP without
|
|
1484
|
+
* changing the HTTP handler.
|
|
1485
|
+
*/
|
|
1486
|
+
readonly mcpHandler?: (input: Record<string, unknown>) => Promise<{
|
|
1487
|
+
content: Array<{
|
|
1488
|
+
type: string;
|
|
1489
|
+
text: string;
|
|
1490
|
+
}>;
|
|
1491
|
+
isError?: boolean;
|
|
1492
|
+
}>;
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Action handler function for state transitions. Receives the resource
|
|
1496
|
+
* ID, action-specific data, and the request.
|
|
1497
|
+
*/
|
|
1498
|
+
type ActionHandlerFn = (id: string, data: Record<string, unknown>, req: RequestWithExtras) => Promise<unknown>;
|
|
1499
|
+
/** Full action configuration with handler, permissions, and schema. */
|
|
1500
|
+
interface ActionDefinition {
|
|
1501
|
+
readonly handler: ActionHandlerFn;
|
|
1502
|
+
/** Per-action permission (overrides resource-level `actionPermissions`) */
|
|
1503
|
+
readonly permissions?: PermissionCheck;
|
|
1504
|
+
/**
|
|
1505
|
+
* JSON Schema or Zod v4 schema for action-specific body fields.
|
|
1506
|
+
* Per-field values are typed `unknown` so Zod class instances assign
|
|
1507
|
+
* without casts.
|
|
1508
|
+
*/
|
|
1509
|
+
readonly schema?: Record<string, unknown>;
|
|
1510
|
+
/** Description for OpenAPI docs and MCP tool */
|
|
1511
|
+
readonly description?: string;
|
|
1512
|
+
/**
|
|
1513
|
+
* MCP tool generation:
|
|
1514
|
+
* - omitted/true: auto-generate
|
|
1515
|
+
* - false: skip
|
|
1516
|
+
* - object: explicit config
|
|
1517
|
+
*/
|
|
1518
|
+
readonly mcp?: boolean | RouteMcpConfig;
|
|
1519
|
+
}
|
|
1520
|
+
/** Action config: bare handler function OR full ActionDefinition. */
|
|
1521
|
+
type ActionEntry = ActionHandlerFn | ActionDefinition;
|
|
1522
|
+
/** Actions configuration map. */
|
|
1523
|
+
type ActionsMap = Record<string, ActionEntry>;
|
|
1524
|
+
/**
|
|
1525
|
+
* Hook context passed to resource-level hook handlers. Mirrors
|
|
1526
|
+
* HookSystem's HookContext but with a simpler API for inline use.
|
|
1527
|
+
*
|
|
1528
|
+
* **v2.10.8:** `context` and a first-class `scope` projection are now
|
|
1529
|
+
* forwarded from the internal `HookContext`. Before this release, inline
|
|
1530
|
+
* `config.hooks` handlers had no way to reach the caller's tenant or
|
|
1531
|
+
* user info — they had to bypass the documented API and push directly
|
|
1532
|
+
* into `resource._pendingHooks` to get the raw internal shape. Now the
|
|
1533
|
+
* documented DX is complete:
|
|
1534
|
+
*
|
|
1535
|
+
* ```ts
|
|
1536
|
+
* hooks: {
|
|
1537
|
+
* afterCreate: (ctx) => {
|
|
1538
|
+
* auditLog.write({
|
|
1539
|
+
* org: ctx.scope?.organizationId,
|
|
1540
|
+
* actor: ctx.scope?.userId,
|
|
1541
|
+
* id: ctx.data._id,
|
|
1542
|
+
* });
|
|
1543
|
+
* },
|
|
1544
|
+
* }
|
|
1545
|
+
* ```
|
|
1546
|
+
*
|
|
1547
|
+
* The `scope` projection matches `IRequestContext.scope` (2.10.6) so
|
|
1548
|
+
* hosts read tenant/user the same way across controllers and hooks.
|
|
1549
|
+
* Use `context._scope` directly for advanced cases that need to
|
|
1550
|
+
* discriminate on `scope.kind` or reach auth-adapter-specific fields.
|
|
1551
|
+
*/
|
|
1552
|
+
interface ResourceHookContext {
|
|
1553
|
+
/** The document data (create/update body, or existing doc for delete / after-result) */
|
|
1554
|
+
data: AnyRecord;
|
|
1555
|
+
/** Authenticated user or null */
|
|
1556
|
+
user?: UserBase;
|
|
1557
|
+
/**
|
|
1558
|
+
* Full typed request context — includes `_scope`, `_policyFilters`,
|
|
1559
|
+
* `arc` metadata. Use `ctx.scope` for the common tenant/user projection;
|
|
1560
|
+
* reach for `ctx.context` when you need `_scope.kind` branching or
|
|
1561
|
+
* custom fields set by your auth adapter.
|
|
1562
|
+
*/
|
|
1563
|
+
context?: AnyRecord;
|
|
1564
|
+
/**
|
|
1565
|
+
* First-class projection of request scope — `{ organizationId?, userId?, orgRoles? }`.
|
|
1566
|
+
* Populated for every scoped request so multi-tenant hooks don't have to
|
|
1567
|
+
* drill into `context._scope.organizationId` themselves. Matches the
|
|
1568
|
+
* identically-named field on `IRequestContext` (v2.10.6) so the same
|
|
1569
|
+
* read pattern works in controllers and hooks.
|
|
1570
|
+
*/
|
|
1571
|
+
scope?: {
|
|
1572
|
+
organizationId?: string;
|
|
1573
|
+
userId?: string;
|
|
1574
|
+
orgRoles?: string[];
|
|
1575
|
+
};
|
|
1576
|
+
/** Additional metadata (e.g. `{ id, existing }` for update/delete) */
|
|
1577
|
+
meta?: AnyRecord;
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* Inline lifecycle hooks on a resource definition. Wired into the
|
|
1581
|
+
* HookSystem automatically — same pipeline as presets and app-level hooks.
|
|
1582
|
+
*
|
|
1583
|
+
* @example
|
|
1584
|
+
* ```typescript
|
|
1585
|
+
* defineResource({
|
|
1586
|
+
* name: 'chat',
|
|
1587
|
+
* hooks: {
|
|
1588
|
+
* afterCreate: async (ctx) => { analytics.track('chat.created', { id: ctx.data._id }); },
|
|
1589
|
+
* beforeDelete: async (ctx) => {
|
|
1590
|
+
* if (ctx.data.isProtected) throw new Error('Cannot delete protected chat');
|
|
1591
|
+
* },
|
|
1592
|
+
* },
|
|
1593
|
+
* });
|
|
1594
|
+
* ```
|
|
1595
|
+
*/
|
|
1596
|
+
interface ResourceHooks {
|
|
1597
|
+
beforeCreate?: (ctx: ResourceHookContext) => Promise<AnyRecord | void> | AnyRecord | void;
|
|
1598
|
+
afterCreate?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
1599
|
+
beforeUpdate?: (ctx: ResourceHookContext) => Promise<AnyRecord | void> | AnyRecord | void;
|
|
1600
|
+
afterUpdate?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
1601
|
+
beforeDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
1602
|
+
afterDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
1603
|
+
}
|
|
1604
|
+
interface PresetHook {
|
|
1605
|
+
operation: "create" | "update" | "delete" | "read" | "list";
|
|
1606
|
+
phase: "before" | "after";
|
|
1607
|
+
handler: (ctx: AnyRecord) => void | Promise<void> | AnyRecord | Promise<AnyRecord>;
|
|
1608
|
+
priority?: number;
|
|
1609
|
+
}
|
|
1610
|
+
interface PresetResult {
|
|
1611
|
+
name: string;
|
|
1612
|
+
/** Preset routes — merged into the resource's `routes` array. */
|
|
1613
|
+
routes?: RouteDefinition[] | ((permissions: ResourcePermissions) => RouteDefinition[]);
|
|
1614
|
+
middlewares?: MiddlewareConfig;
|
|
1615
|
+
schemaOptions?: RouteSchemaOptions;
|
|
1616
|
+
controllerOptions?: Record<string, unknown>;
|
|
1617
|
+
hooks?: PresetHook[];
|
|
1618
|
+
}
|
|
1619
|
+
type PresetFunction = (config: ResourceConfig) => PresetResult;
|
|
1620
|
+
interface EventDefinition {
|
|
1621
|
+
name: string;
|
|
1622
|
+
/** Optional handler — events are published via `fastify.events.publish()`. */
|
|
1623
|
+
handler?: (data: unknown) => Promise<void> | void;
|
|
1624
|
+
/** JSON schema for event payload */
|
|
1625
|
+
schema?: Record<string, unknown>;
|
|
1626
|
+
description?: string;
|
|
1627
|
+
}
|
|
1628
|
+
/** Resource-level permissions — only `PermissionCheck` functions allowed. */
|
|
1629
|
+
interface ResourcePermissions {
|
|
1630
|
+
list?: PermissionCheck;
|
|
1631
|
+
get?: PermissionCheck;
|
|
1632
|
+
create?: PermissionCheck;
|
|
1633
|
+
update?: PermissionCheck;
|
|
1634
|
+
delete?: PermissionCheck;
|
|
1635
|
+
}
|
|
1636
|
+
interface ResourceConfig<TDoc = AnyRecord> {
|
|
1637
|
+
name: string;
|
|
1638
|
+
displayName?: string;
|
|
1639
|
+
tag?: string;
|
|
1640
|
+
/** Defaults to `/${name}s` if not provided. */
|
|
1641
|
+
prefix?: string;
|
|
1642
|
+
/**
|
|
1643
|
+
* Skip the global `resourcePrefix` from `createApp()`. The resource
|
|
1644
|
+
* registers at its own `prefix` (or `/${name}s`) directly on root.
|
|
1645
|
+
* Useful for webhooks, health, admin routes that shouldn't be under
|
|
1646
|
+
* `/api/v1`.
|
|
1647
|
+
*
|
|
1648
|
+
* @example
|
|
1649
|
+
* ```typescript
|
|
1650
|
+
* defineResource({ name: 'webhook', prefix: '/webhooks', skipGlobalPrefix: true })
|
|
1651
|
+
* ```
|
|
1652
|
+
*/
|
|
1653
|
+
skipGlobalPrefix?: boolean;
|
|
1654
|
+
/** Optional for service-pattern resources */
|
|
1655
|
+
adapter?: DataAdapter<TDoc>;
|
|
1656
|
+
/** Controller instance — accepts any object with CRUD methods. */
|
|
1657
|
+
controller?: IController<TDoc> | ControllerLike;
|
|
1658
|
+
queryParser?: unknown;
|
|
1659
|
+
permissions?: ResourcePermissions;
|
|
1660
|
+
schemaOptions?: RouteSchemaOptions;
|
|
1661
|
+
openApiSchemas?: OpenApiSchemas;
|
|
1662
|
+
/** Custom JSON schemas (override Arc-generated). */
|
|
1663
|
+
customSchemas?: Partial<CrudSchemas>;
|
|
1664
|
+
/** Preset names, objects, or PresetResult values. */
|
|
1665
|
+
presets?: Array<string | PresetResult | {
|
|
1666
|
+
name: string;
|
|
1667
|
+
[key: string]: unknown;
|
|
1668
|
+
}>;
|
|
1669
|
+
hooks?: ResourceHooks;
|
|
1670
|
+
/**
|
|
1671
|
+
* Functional pipeline — guards, transforms, interceptors. Flat array
|
|
1672
|
+
* (all operations) or per-operation map.
|
|
1673
|
+
*
|
|
1674
|
+
* @example
|
|
1675
|
+
* ```typescript
|
|
1676
|
+
* pipe: pipe(isActive, slugify, timing),
|
|
1677
|
+
* pipe: { create: pipe(isActive, slugify), list: pipe(timing) },
|
|
1678
|
+
* ```
|
|
1679
|
+
*/
|
|
1680
|
+
pipe?: PipelineConfig;
|
|
1681
|
+
/**
|
|
1682
|
+
* Field-level permissions — control visibility and writability per role.
|
|
1683
|
+
*
|
|
1684
|
+
* @example
|
|
1685
|
+
* ```typescript
|
|
1686
|
+
* fields: {
|
|
1687
|
+
* salary: fields.visibleTo(['admin', 'hr']),
|
|
1688
|
+
* password: fields.hidden(),
|
|
1689
|
+
* }
|
|
1690
|
+
* ```
|
|
1691
|
+
*/
|
|
1692
|
+
fields?: FieldPermissionMap;
|
|
1693
|
+
/**
|
|
1694
|
+
* Policy for requests that include fields the caller can't write.
|
|
1695
|
+
*
|
|
1696
|
+
* - `'reject'` (default, secure): 403 with the denied field names.
|
|
1697
|
+
* Surfaces misconfigurations and write-side permission violations
|
|
1698
|
+
* instead of silently dropping them.
|
|
1699
|
+
* - `'strip'`: legacy silent-drop behaviour — only opt in when migrating
|
|
1700
|
+
* pre-2.9 code that relied on the permissive default.
|
|
1701
|
+
*/
|
|
1702
|
+
onFieldWriteDenied?: "reject" | "strip";
|
|
1703
|
+
middlewares?: MiddlewareConfig;
|
|
1704
|
+
/**
|
|
1705
|
+
* PreHandler guards auto-applied to **every** route on this resource
|
|
1706
|
+
* (CRUD + custom + preset). Runs after auth/permissions, before
|
|
1707
|
+
* per-route `preHandler`. Use for mode gates, tenant checks, feature
|
|
1708
|
+
* flags — anything that applies to every endpoint.
|
|
1709
|
+
*/
|
|
1710
|
+
routeGuards?: RouteHandlerMethod[];
|
|
1711
|
+
/**
|
|
1712
|
+
* Custom routes beyond CRUD. Presets also merge their routes here.
|
|
1713
|
+
*
|
|
1714
|
+
* @example
|
|
1715
|
+
* ```typescript
|
|
1716
|
+
* routes: [
|
|
1717
|
+
* { method: 'GET', path: '/stats', handler: 'getStats', permissions: auth() },
|
|
1718
|
+
* { method: 'POST', path: '/webhook', handler: webhookFn, raw: true, permissions: auth() },
|
|
1719
|
+
* ]
|
|
1720
|
+
* ```
|
|
1721
|
+
*/
|
|
1722
|
+
routes?: RouteDefinition[];
|
|
1723
|
+
/**
|
|
1724
|
+
* State-transition actions → unified `POST /:id/action` endpoint.
|
|
1725
|
+
* Each action can be a bare handler or full config with permissions
|
|
1726
|
+
* + schema.
|
|
1727
|
+
*
|
|
1728
|
+
* @example
|
|
1729
|
+
* ```typescript
|
|
1381
1730
|
* actions: {
|
|
1382
1731
|
* approve: async (id, data, req) => service.approve(id, req.user._id),
|
|
1383
1732
|
* cancel: {
|
|
@@ -1404,6 +1753,24 @@ interface ResourceConfig<TDoc = AnyRecord> {
|
|
|
1404
1753
|
* Override to match your schema: 'workspaceId', 'tenantId', etc.
|
|
1405
1754
|
*/
|
|
1406
1755
|
tenantField?: string | false;
|
|
1756
|
+
/**
|
|
1757
|
+
* Default sort applied to `list` responses when the request doesn't
|
|
1758
|
+
* specify one. Arc's built-in default is `-createdAt` (Mongo convention).
|
|
1759
|
+
*
|
|
1760
|
+
* - `string` — override (e.g. `'-created_at'`, `'-id'`).
|
|
1761
|
+
* - `false` — disable the default entirely. The adapter returns rows
|
|
1762
|
+
* in its native order (primary-key order on most kits). **Use this
|
|
1763
|
+
* for SQL/Drizzle resources that don't declare a `createdAt`
|
|
1764
|
+
* column** — without it, the framework default would compile to
|
|
1765
|
+
* `ORDER BY "createdAt" DESC` against a missing column.
|
|
1766
|
+
*
|
|
1767
|
+
* @example
|
|
1768
|
+
* ```ts
|
|
1769
|
+
* defineResource({ name: 'metric', defaultSort: '-recordedAt' });
|
|
1770
|
+
* defineResource({ name: 'tag', defaultSort: false }); // no default sort
|
|
1771
|
+
* ```
|
|
1772
|
+
*/
|
|
1773
|
+
defaultSort?: string | false;
|
|
1407
1774
|
/**
|
|
1408
1775
|
* Primary key field name (default: '_id').
|
|
1409
1776
|
*
|
|
@@ -1467,1035 +1834,875 @@ interface ResourceConfig<TDoc = AnyRecord> {
|
|
|
1467
1834
|
};
|
|
1468
1835
|
}
|
|
1469
1836
|
//#endregion
|
|
1470
|
-
//#region src/
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
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;
|
|
1535
|
-
/**
|
|
1536
|
-
* Get event definitions for registry
|
|
1537
|
-
*/
|
|
1538
|
-
getEvents(): Array<{
|
|
1539
|
-
name: string;
|
|
1540
|
-
module: string;
|
|
1541
|
-
schema?: AnyRecord;
|
|
1542
|
-
description?: string;
|
|
1543
|
-
}>;
|
|
1544
|
-
/**
|
|
1545
|
-
* Get resource metadata
|
|
1546
|
-
*/
|
|
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();
|
|
1560
|
-
/**
|
|
1561
|
-
* Register a resource
|
|
1562
|
-
*/
|
|
1563
|
-
register(resource: ResourceDefinition<unknown>, options?: RegisterOptions): this;
|
|
1564
|
-
/**
|
|
1565
|
-
* Get resource by name
|
|
1566
|
-
*/
|
|
1567
|
-
get(name: string): RegistryEntry | undefined;
|
|
1568
|
-
/**
|
|
1569
|
-
* Get all resources
|
|
1570
|
-
*/
|
|
1571
|
-
getAll(): RegistryEntry[];
|
|
1572
|
-
/**
|
|
1573
|
-
* Get resources by module
|
|
1574
|
-
*/
|
|
1575
|
-
getByModule(moduleName: string): RegistryEntry[];
|
|
1576
|
-
/**
|
|
1577
|
-
* Get resources by preset
|
|
1578
|
-
*/
|
|
1579
|
-
getByPreset(presetName: string): RegistryEntry[];
|
|
1580
|
-
/**
|
|
1581
|
-
* Check if resource exists
|
|
1582
|
-
*/
|
|
1583
|
-
has(name: string): boolean;
|
|
1584
|
-
/**
|
|
1585
|
-
* Get registry statistics
|
|
1586
|
-
*/
|
|
1587
|
-
getStats(): RegistryStats;
|
|
1588
|
-
/**
|
|
1589
|
-
* Get full introspection data
|
|
1590
|
-
*/
|
|
1591
|
-
getIntrospection(): IntrospectionData;
|
|
1837
|
+
//#region src/hooks/HookSystem.d.ts
|
|
1838
|
+
type HookPhase = "before" | "around" | "after";
|
|
1839
|
+
type HookOperation = "create" | "update" | "delete" | "restore" | "read" | "list";
|
|
1840
|
+
interface HookContext<T = AnyRecord> {
|
|
1841
|
+
resource: string;
|
|
1842
|
+
operation: HookOperation;
|
|
1843
|
+
phase: HookPhase;
|
|
1844
|
+
data?: T;
|
|
1845
|
+
result?: T | T[];
|
|
1846
|
+
user?: UserBase;
|
|
1847
|
+
context?: RequestContext;
|
|
1848
|
+
meta?: AnyRecord;
|
|
1849
|
+
}
|
|
1850
|
+
type HookHandler<T = AnyRecord> = (ctx: HookContext<T>) => void | Promise<void> | T | Promise<T>;
|
|
1851
|
+
/**
|
|
1852
|
+
* Around hook handler — wraps the core operation.
|
|
1853
|
+
* Call `next()` to proceed to the next around hook or the actual operation.
|
|
1854
|
+
*/
|
|
1855
|
+
type AroundHookHandler<T = AnyRecord> = (ctx: HookContext<T>, next: () => Promise<T | undefined>) => T | undefined | Promise<T | undefined>;
|
|
1856
|
+
interface HookRegistration {
|
|
1857
|
+
/** Hook name for dependency resolution and debugging */
|
|
1858
|
+
name?: string;
|
|
1859
|
+
resource: string;
|
|
1860
|
+
operation: HookOperation;
|
|
1861
|
+
phase: HookPhase;
|
|
1862
|
+
handler: HookHandler;
|
|
1863
|
+
priority: number;
|
|
1864
|
+
/** Names of hooks that must execute before this one */
|
|
1865
|
+
dependsOn?: string[];
|
|
1866
|
+
}
|
|
1867
|
+
interface HookSystemOptions {
|
|
1868
|
+
/** Custom logger for error/warning reporting. Defaults to console */
|
|
1869
|
+
logger?: {
|
|
1870
|
+
error: (message: string, ...args: unknown[]) => void;
|
|
1871
|
+
warn?: (message: string, ...args: unknown[]) => void;
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
declare class HookSystem {
|
|
1875
|
+
private hooks;
|
|
1876
|
+
private logger;
|
|
1877
|
+
private warn;
|
|
1878
|
+
constructor(options?: HookSystemOptions);
|
|
1592
1879
|
/**
|
|
1593
|
-
*
|
|
1880
|
+
* Generate hook key
|
|
1594
1881
|
*/
|
|
1595
|
-
|
|
1882
|
+
private getKey;
|
|
1596
1883
|
/**
|
|
1597
|
-
*
|
|
1884
|
+
* Register a hook
|
|
1885
|
+
* Supports both object parameter and positional arguments
|
|
1598
1886
|
*/
|
|
1599
|
-
|
|
1887
|
+
register<T = AnyRecord>(resourceOrOptions: string | {
|
|
1888
|
+
name?: string;
|
|
1889
|
+
resource: string;
|
|
1890
|
+
operation: HookOperation;
|
|
1891
|
+
phase: HookPhase;
|
|
1892
|
+
handler: HookHandler<T>;
|
|
1893
|
+
priority?: number;
|
|
1894
|
+
dependsOn?: string[];
|
|
1895
|
+
}, operation?: HookOperation, phase?: HookPhase, handler?: HookHandler<T>, priority?: number): () => void;
|
|
1600
1896
|
/**
|
|
1601
|
-
*
|
|
1897
|
+
* Register before hook
|
|
1602
1898
|
*/
|
|
1603
|
-
|
|
1899
|
+
before<T = AnyRecord>(resource: string, operation: HookOperation, handler: HookHandler<T>, priority?: number): () => void;
|
|
1604
1900
|
/**
|
|
1605
|
-
*
|
|
1901
|
+
* Register after hook
|
|
1606
1902
|
*/
|
|
1607
|
-
|
|
1608
|
-
/** @internal Alias for unfreeze() */
|
|
1609
|
-
_unfreeze(): void;
|
|
1610
|
-
/** @internal Alias for reset() */
|
|
1611
|
-
_clear(): void;
|
|
1903
|
+
after<T = AnyRecord>(resource: string, operation: HookOperation, handler: HookHandler<T>, priority?: number): () => void;
|
|
1612
1904
|
/**
|
|
1613
|
-
*
|
|
1905
|
+
* Register around hook — wraps the core operation.
|
|
1906
|
+
* Call `next()` inside the handler to proceed.
|
|
1614
1907
|
*/
|
|
1615
|
-
|
|
1616
|
-
}
|
|
1617
|
-
//#endregion
|
|
1618
|
-
//#region src/types/fastify.d.ts
|
|
1619
|
-
interface FastifyRequestExtras {
|
|
1620
|
-
user?: Record<string, unknown>;
|
|
1621
|
-
}
|
|
1622
|
-
interface RequestWithExtras extends FastifyRequest {
|
|
1908
|
+
around<T = AnyRecord>(resource: string, operation: HookOperation, handler: AroundHookHandler<T>, priority?: number): () => void;
|
|
1623
1909
|
/**
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1910
|
+
* Execute around hooks as a nested middleware chain.
|
|
1911
|
+
* Each around hook receives `next()` to call the next hook or the core operation.
|
|
1626
1912
|
*/
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
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>;
|
|
1639
|
-
}
|
|
1640
|
-
type FastifyWithAuth = FastifyInstance & {
|
|
1641
|
-
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
1642
|
-
};
|
|
1643
|
-
/**
|
|
1644
|
-
* Arc core decorator — added by `arcCorePlugin`. Provides instance-scoped
|
|
1645
|
-
* hooks and resource registry.
|
|
1646
|
-
*/
|
|
1647
|
-
interface ArcDecorator {
|
|
1648
|
-
hooks: HookSystem;
|
|
1649
|
-
registry: ResourceRegistry;
|
|
1650
|
-
/** Whether event emission is enabled */
|
|
1651
|
-
emitEvents: boolean;
|
|
1652
|
-
}
|
|
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>;
|
|
1913
|
+
executeAround<T = AnyRecord>(resource: string, operation: HookOperation, data: T, execute: () => Promise<T | undefined>, options?: {
|
|
1914
|
+
user?: UserBase;
|
|
1915
|
+
context?: RequestContext;
|
|
1916
|
+
meta?: AnyRecord;
|
|
1917
|
+
}): Promise<T | undefined>;
|
|
1659
1918
|
/**
|
|
1660
|
-
*
|
|
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.
|
|
1919
|
+
* Execute hooks for a given context
|
|
1665
1920
|
*/
|
|
1666
|
-
|
|
1667
|
-
transportName: string;
|
|
1668
|
-
}
|
|
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. */
|
|
1683
|
-
type MiddlewareHandler = (request: RequestWithExtras, reply: FastifyReply) => Promise<unknown>;
|
|
1684
|
-
//#endregion
|
|
1685
|
-
//#region src/types/auth.d.ts
|
|
1686
|
-
/**
|
|
1687
|
-
* JWT utilities provided to authenticator. Arc provides the helpers;
|
|
1688
|
-
* apps use them as needed.
|
|
1689
|
-
*/
|
|
1690
|
-
interface JwtContext {
|
|
1691
|
-
/** Verify a JWT token and return decoded payload */
|
|
1692
|
-
verify: <T = Record<string, unknown>>(token: string) => T;
|
|
1693
|
-
/** Sign a payload and return JWT token */
|
|
1694
|
-
sign: (payload: Record<string, unknown>, options?: {
|
|
1695
|
-
expiresIn?: string;
|
|
1696
|
-
}) => string;
|
|
1697
|
-
/** Decode without verification (for inspection) */
|
|
1698
|
-
decode: <T = Record<string, unknown>>(token: string) => T | null;
|
|
1699
|
-
}
|
|
1700
|
-
/** Context passed to app's authenticator function. */
|
|
1701
|
-
interface AuthenticatorContext {
|
|
1702
|
-
/** JWT utilities (available if `jwt.secret` provided) */
|
|
1703
|
-
jwt: JwtContext | null;
|
|
1704
|
-
/** Fastify instance for advanced use cases */
|
|
1705
|
-
fastify: FastifyInstance;
|
|
1706
|
-
}
|
|
1707
|
-
/**
|
|
1708
|
-
* App-provided authenticator function. Arc calls this for every
|
|
1709
|
-
* non-public route. The app has full control over authentication logic.
|
|
1710
|
-
*
|
|
1711
|
-
* Return a user object to authenticate, `null`/`undefined` to reject.
|
|
1712
|
-
*
|
|
1713
|
-
* @example
|
|
1714
|
-
* ```typescript
|
|
1715
|
-
* authenticate: async (request, { jwt }) => {
|
|
1716
|
-
* const token = request.headers.authorization?.split(' ')[1];
|
|
1717
|
-
* if (!token || !jwt) return null;
|
|
1718
|
-
* const decoded = jwt.verify(token);
|
|
1719
|
-
* return userRepo.findById(decoded.id);
|
|
1720
|
-
* }
|
|
1721
|
-
* ```
|
|
1722
|
-
*/
|
|
1723
|
-
type Authenticator = (request: FastifyRequest, context: AuthenticatorContext) => Promise<unknown | null> | unknown | null;
|
|
1724
|
-
/** Token pair returned by `issueTokens` helper. */
|
|
1725
|
-
interface TokenPair {
|
|
1726
|
-
/** Access token (JWT) */
|
|
1727
|
-
accessToken: string;
|
|
1728
|
-
/** Refresh token (JWT with longer expiry) */
|
|
1729
|
-
refreshToken?: string;
|
|
1730
|
-
/** Access token expiry in seconds */
|
|
1731
|
-
expiresIn: number;
|
|
1732
|
-
/** Refresh token expiry in seconds */
|
|
1733
|
-
refreshExpiresIn?: number;
|
|
1734
|
-
/** Token type (always 'Bearer') */
|
|
1735
|
-
tokenType: "Bearer";
|
|
1736
|
-
}
|
|
1737
|
-
/**
|
|
1738
|
-
* Auth helpers exposed on `fastify.auth`.
|
|
1739
|
-
*
|
|
1740
|
-
* @example
|
|
1741
|
-
* ```typescript
|
|
1742
|
-
* const tokens = fastify.auth.issueTokens({
|
|
1743
|
-
* id: user._id,
|
|
1744
|
-
* email: user.email,
|
|
1745
|
-
* role: user.role,
|
|
1746
|
-
* });
|
|
1747
|
-
* return { success: true, ...tokens, user };
|
|
1748
|
-
* ```
|
|
1749
|
-
*/
|
|
1750
|
-
interface AuthHelpers {
|
|
1751
|
-
/** JWT utilities (if configured) */
|
|
1752
|
-
jwt: JwtContext | null;
|
|
1921
|
+
execute<T = AnyRecord>(ctx: HookContext<T>): Promise<T | undefined>;
|
|
1753
1922
|
/**
|
|
1754
|
-
*
|
|
1755
|
-
* validating credentials.
|
|
1923
|
+
* Execute before hooks
|
|
1756
1924
|
*/
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
verifyRefreshToken: <T = Record<string, unknown>>(token: string) => T;
|
|
1763
|
-
}
|
|
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 {
|
|
1925
|
+
executeBefore<T = AnyRecord>(resource: string, operation: HookOperation, data: T, options?: {
|
|
1926
|
+
user?: UserBase;
|
|
1927
|
+
context?: RequestContext;
|
|
1928
|
+
meta?: AnyRecord;
|
|
1929
|
+
}): Promise<T>;
|
|
1784
1930
|
/**
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
1931
|
+
* Execute after hooks
|
|
1932
|
+
* Errors in after hooks are logged but don't fail the request
|
|
1787
1933
|
*/
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
sign?: Record<string, unknown>; /** Additional `@fastify/jwt` verify options */
|
|
1794
|
-
verify?: Record<string, unknown>;
|
|
1795
|
-
};
|
|
1934
|
+
executeAfter<T = AnyRecord>(resource: string, operation: HookOperation, result: T | T[], options?: {
|
|
1935
|
+
user?: UserBase;
|
|
1936
|
+
context?: RequestContext;
|
|
1937
|
+
meta?: AnyRecord;
|
|
1938
|
+
}): Promise<void>;
|
|
1796
1939
|
/**
|
|
1797
|
-
*
|
|
1798
|
-
*
|
|
1940
|
+
* Topological sort with Kahn's algorithm.
|
|
1941
|
+
* Hooks with `dependsOn` are ordered after their dependencies.
|
|
1942
|
+
* Within the same dependency level, priority ordering is preserved.
|
|
1943
|
+
* Hooks without names or dependencies pass through in their original order.
|
|
1799
1944
|
*/
|
|
1800
|
-
|
|
1945
|
+
private topologicalSort;
|
|
1801
1946
|
/**
|
|
1802
|
-
*
|
|
1803
|
-
* authentication fails.
|
|
1947
|
+
* Get all registered hooks
|
|
1804
1948
|
*/
|
|
1805
|
-
|
|
1949
|
+
getAll(): HookRegistration[];
|
|
1806
1950
|
/**
|
|
1807
|
-
*
|
|
1808
|
-
* (default), returns generic "Authentication required". Decoupled from
|
|
1809
|
-
* log level — set explicitly per environment.
|
|
1951
|
+
* Get hooks for a specific resource
|
|
1810
1952
|
*/
|
|
1811
|
-
|
|
1812
|
-
/** Property name to store user on request (default: 'user') */
|
|
1813
|
-
userProperty?: string;
|
|
1953
|
+
getForResource(resource: string): HookRegistration[];
|
|
1814
1954
|
/**
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
1817
|
-
* are in HttpOnly cookies, custom headers, or query params.
|
|
1955
|
+
* Get hooks matching filter criteria.
|
|
1956
|
+
* Useful for debugging and testing specific hook combinations.
|
|
1818
1957
|
*
|
|
1819
1958
|
* @example
|
|
1820
1959
|
* ```typescript
|
|
1821
|
-
*
|
|
1960
|
+
* // Find all before-create hooks for products (including wildcards)
|
|
1961
|
+
* const hooks = hookSystem.getRegistered({
|
|
1962
|
+
* resource: 'product',
|
|
1963
|
+
* operation: 'create',
|
|
1964
|
+
* phase: 'before',
|
|
1965
|
+
* });
|
|
1822
1966
|
* ```
|
|
1823
1967
|
*/
|
|
1824
|
-
|
|
1968
|
+
getRegistered(filter?: {
|
|
1969
|
+
resource?: string;
|
|
1970
|
+
operation?: HookOperation;
|
|
1971
|
+
phase?: HookPhase;
|
|
1972
|
+
}): HookRegistration[];
|
|
1825
1973
|
/**
|
|
1826
|
-
*
|
|
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.
|
|
1974
|
+
* Get a structured summary of all registered hooks for debugging.
|
|
1830
1975
|
*
|
|
1831
1976
|
* @example
|
|
1832
1977
|
* ```typescript
|
|
1833
|
-
*
|
|
1834
|
-
*
|
|
1835
|
-
* },
|
|
1978
|
+
* const info = hookSystem.inspect();
|
|
1979
|
+
* // { total: 12, resources: { product: [...], '*': [...] }, summary: [...] }
|
|
1836
1980
|
* ```
|
|
1837
1981
|
*/
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
*
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
onShutdown?: () => Promise<void> | void;
|
|
1861
|
-
signals?: NodeJS.Signals[];
|
|
1862
|
-
logEvents?: boolean;
|
|
1863
|
-
}
|
|
1864
|
-
interface RequestIdOptions {
|
|
1865
|
-
headerName?: string;
|
|
1866
|
-
generator?: () => string;
|
|
1867
|
-
}
|
|
1868
|
-
interface HealthOptions {
|
|
1869
|
-
path?: string;
|
|
1870
|
-
check?: () => Promise<unknown>;
|
|
1871
|
-
}
|
|
1872
|
-
interface HealthCheck {
|
|
1873
|
-
healthy: boolean;
|
|
1874
|
-
timestamp: string;
|
|
1875
|
-
[key: string]: unknown;
|
|
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;
|
|
1982
|
+
inspect(): {
|
|
1983
|
+
total: number;
|
|
1984
|
+
resources: Record<string, HookRegistration[]>;
|
|
1985
|
+
summary: Array<{
|
|
1986
|
+
name?: string;
|
|
1987
|
+
key: string;
|
|
1988
|
+
priority: number;
|
|
1989
|
+
dependsOn?: string[];
|
|
1990
|
+
}>;
|
|
1991
|
+
};
|
|
1992
|
+
/**
|
|
1993
|
+
* Check if any hooks exist for a specific resource/operation/phase combination.
|
|
1994
|
+
*/
|
|
1995
|
+
has(resource: string, operation: HookOperation, phase: HookPhase): boolean;
|
|
1996
|
+
/**
|
|
1997
|
+
* Clear all hooks
|
|
1998
|
+
*/
|
|
1999
|
+
clear(): void;
|
|
2000
|
+
/**
|
|
2001
|
+
* Clear hooks for a specific resource
|
|
2002
|
+
*/
|
|
2003
|
+
clearResource(resource: string): void;
|
|
2034
2004
|
}
|
|
2035
|
-
//#endregion
|
|
2036
|
-
//#region src/types/utility.d.ts
|
|
2037
2005
|
/**
|
|
2038
|
-
*
|
|
2039
|
-
*
|
|
2006
|
+
* Create a new isolated HookSystem instance
|
|
2007
|
+
*
|
|
2008
|
+
* Use this for:
|
|
2009
|
+
* - Test isolation (parallel test suites)
|
|
2010
|
+
* - Multiple app instances with independent hooks
|
|
2040
2011
|
*
|
|
2041
2012
|
* @example
|
|
2042
|
-
*
|
|
2043
|
-
*
|
|
2044
|
-
*
|
|
2045
|
-
*
|
|
2013
|
+
* const hooks = createHookSystem();
|
|
2014
|
+
* await app.register(arcCorePlugin, { hookSystem: hooks });
|
|
2015
|
+
*
|
|
2016
|
+
* @example With custom logger
|
|
2017
|
+
* const hooks = createHookSystem({ logger: fastify.log });
|
|
2046
2018
|
*/
|
|
2047
|
-
|
|
2019
|
+
declare function createHookSystem(options?: HookSystemOptions): HookSystem;
|
|
2020
|
+
interface DefineHookOptions<T = AnyRecord> {
|
|
2021
|
+
/** Unique hook name (required for dependency resolution) */
|
|
2022
|
+
name: string;
|
|
2023
|
+
/** Target resource */
|
|
2024
|
+
resource: string;
|
|
2025
|
+
/** CRUD operation */
|
|
2026
|
+
operation: HookOperation;
|
|
2027
|
+
/** before or after */
|
|
2028
|
+
phase: HookPhase;
|
|
2029
|
+
/** Hook handler */
|
|
2030
|
+
handler: HookHandler<T>;
|
|
2031
|
+
/** Priority (lower = earlier, default: 10) */
|
|
2032
|
+
priority?: number;
|
|
2033
|
+
/** Names of hooks that must execute before this one */
|
|
2034
|
+
dependsOn?: string[];
|
|
2035
|
+
}
|
|
2048
2036
|
/**
|
|
2049
|
-
*
|
|
2050
|
-
*
|
|
2037
|
+
* Define a named hook with optional dependencies.
|
|
2038
|
+
* Returns a registration object — call `register(hookSystem)` to activate.
|
|
2051
2039
|
*
|
|
2052
2040
|
* @example
|
|
2053
2041
|
* ```typescript
|
|
2054
|
-
* const
|
|
2055
|
-
*
|
|
2042
|
+
* const generateSlug = defineHook({
|
|
2043
|
+
* name: 'generateSlug',
|
|
2044
|
+
* resource: 'product', operation: 'create', phase: 'before',
|
|
2045
|
+
* handler: (ctx) => ({ ...ctx.data, slug: slugify(ctx.data.name) }),
|
|
2046
|
+
* });
|
|
2047
|
+
*
|
|
2048
|
+
* const validateUniqueSlug = defineHook({
|
|
2049
|
+
* name: 'validateUniqueSlug',
|
|
2050
|
+
* resource: 'product', operation: 'create', phase: 'before',
|
|
2051
|
+
* dependsOn: ['generateSlug'],
|
|
2052
|
+
* handler: async (ctx) => { // check uniqueness },
|
|
2053
|
+
* });
|
|
2054
|
+
*
|
|
2055
|
+
* // Register on a hook system
|
|
2056
|
+
* generateSlug.register(hooks);
|
|
2057
|
+
* validateUniqueSlug.register(hooks);
|
|
2056
2058
|
* ```
|
|
2057
2059
|
*/
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2060
|
+
declare function defineHook<T = AnyRecord>(options: DefineHookOptions<T>): DefineHookOptions<T> & {
|
|
2061
|
+
register: (hooks: HookSystem) => () => void;
|
|
2062
|
+
};
|
|
2063
|
+
/**
|
|
2064
|
+
* Create a before-create hook registration for a given hook system
|
|
2065
|
+
*/
|
|
2066
|
+
declare function beforeCreate<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
2067
|
+
/**
|
|
2068
|
+
* Create an after-create hook registration for a given hook system
|
|
2069
|
+
*/
|
|
2070
|
+
declare function afterCreate<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
2071
|
+
/**
|
|
2072
|
+
* Create a before-update hook registration for a given hook system
|
|
2073
|
+
*/
|
|
2074
|
+
declare function beforeUpdate<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
2075
|
+
/**
|
|
2076
|
+
* Create an after-update hook registration for a given hook system
|
|
2077
|
+
*/
|
|
2078
|
+
declare function afterUpdate<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
2079
|
+
/**
|
|
2080
|
+
* Create a before-delete hook registration for a given hook system
|
|
2081
|
+
*/
|
|
2082
|
+
declare function beforeDelete<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
2083
|
+
/**
|
|
2084
|
+
* Create an after-delete hook registration for a given hook system
|
|
2085
|
+
*/
|
|
2086
|
+
declare function afterDelete<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
2063
2087
|
//#endregion
|
|
2064
|
-
//#region src/
|
|
2088
|
+
//#region src/core/defineResource.d.ts
|
|
2065
2089
|
/**
|
|
2066
|
-
*
|
|
2067
|
-
*
|
|
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.
|
|
2090
|
+
* Define a resource with database adapter
|
|
2072
2091
|
*
|
|
2073
|
-
*
|
|
2074
|
-
*
|
|
2075
|
-
* const result = await repo.getAll(params);
|
|
2076
|
-
* if (result.method === 'keyset') {
|
|
2077
|
-
* result.next; // keyset cursor
|
|
2078
|
-
* } else {
|
|
2079
|
-
* result.page; // offset number
|
|
2080
|
-
* }
|
|
2081
|
-
* ```
|
|
2092
|
+
* This is the MAIN entry point for creating Arc resources.
|
|
2093
|
+
* The adapter provides both repository and schema metadata.
|
|
2082
2094
|
*/
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2095
|
+
declare function defineResource<TDoc = AnyRecord>(config: ResourceConfig<TDoc>): ResourceDefinition<TDoc>;
|
|
2096
|
+
interface ResolvedResourceConfig<TDoc = AnyRecord> extends ResourceConfig<TDoc> {
|
|
2097
|
+
_appliedPresets?: string[];
|
|
2098
|
+
_controllerOptions?: {
|
|
2099
|
+
slugField?: string;
|
|
2100
|
+
parentField?: string;
|
|
2101
|
+
[key: string]: unknown;
|
|
2102
|
+
};
|
|
2103
|
+
_pendingHooks?: Array<{
|
|
2104
|
+
operation: "create" | "update" | "delete" | "read" | "list";
|
|
2105
|
+
phase: "before" | "after";
|
|
2106
|
+
handler: (ctx: AnyRecord) => unknown;
|
|
2107
|
+
priority: number;
|
|
2108
|
+
}>;
|
|
2094
2109
|
}
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2110
|
+
declare class ResourceDefinition<TDoc = AnyRecord> {
|
|
2111
|
+
readonly name: string;
|
|
2112
|
+
readonly displayName: string;
|
|
2113
|
+
readonly tag: string;
|
|
2114
|
+
readonly prefix: string;
|
|
2115
|
+
readonly adapter?: DataAdapter<TDoc>;
|
|
2116
|
+
readonly controller?: IController<TDoc>;
|
|
2117
|
+
readonly schemaOptions: RouteSchemaOptions;
|
|
2118
|
+
readonly customSchemas: CrudSchemas;
|
|
2119
|
+
readonly permissions: ResourcePermissions;
|
|
2120
|
+
readonly routes: readonly RouteDefinition[];
|
|
2121
|
+
readonly middlewares: MiddlewareConfig;
|
|
2122
|
+
readonly routeGuards?: RouteHandlerMethod$1[];
|
|
2123
|
+
readonly disableDefaultRoutes: boolean;
|
|
2124
|
+
readonly disabledRoutes: CrudRouteKey[];
|
|
2125
|
+
readonly actions?: ActionsMap;
|
|
2126
|
+
readonly actionPermissions?: PermissionCheck;
|
|
2127
|
+
readonly events: Record<string, EventDefinition>;
|
|
2128
|
+
readonly rateLimit?: RateLimitConfig | false;
|
|
2129
|
+
readonly audit?: boolean | {
|
|
2130
|
+
operations?: ("create" | "update" | "delete")[];
|
|
2131
|
+
};
|
|
2132
|
+
readonly updateMethod?: "PUT" | "PATCH" | "both";
|
|
2133
|
+
readonly pipe?: PipelineConfig;
|
|
2134
|
+
readonly fields?: FieldPermissionMap;
|
|
2135
|
+
readonly cache?: ResourceCacheConfig;
|
|
2136
|
+
readonly skipGlobalPrefix: boolean;
|
|
2137
|
+
readonly tenantField?: string | false;
|
|
2138
|
+
readonly idField?: string;
|
|
2139
|
+
readonly queryParser?: QueryParserInterface;
|
|
2140
|
+
readonly _appliedPresets: string[];
|
|
2141
|
+
_pendingHooks: Array<{
|
|
2142
|
+
operation: "create" | "update" | "delete" | "read" | "list";
|
|
2143
|
+
phase: "before" | "after";
|
|
2144
|
+
handler: (ctx: AnyRecord) => unknown;
|
|
2145
|
+
priority: number;
|
|
2146
|
+
}>;
|
|
2147
|
+
_registryMeta?: RegisterOptions;
|
|
2148
|
+
constructor(config: ResolvedResourceConfig<TDoc>);
|
|
2149
|
+
/** Get repository from adapter (if available) */
|
|
2150
|
+
get repository(): _$_classytic_repo_core_repository0.StandardRepo<TDoc> | RepositoryLike<TDoc> | undefined;
|
|
2151
|
+
_validateControllerMethods(): void;
|
|
2152
|
+
toPlugin(): FastifyPluginAsync;
|
|
2100
2153
|
/**
|
|
2101
|
-
*
|
|
2102
|
-
* Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
|
|
2103
|
-
* Falls back to built-in MongoDB-style matching if not provided.
|
|
2154
|
+
* Get event definitions for registry
|
|
2104
2155
|
*/
|
|
2105
|
-
|
|
2156
|
+
getEvents(): Array<{
|
|
2157
|
+
name: string;
|
|
2158
|
+
module: string;
|
|
2159
|
+
schema?: AnyRecord;
|
|
2160
|
+
description?: string;
|
|
2161
|
+
}>;
|
|
2162
|
+
/**
|
|
2163
|
+
* Get resource metadata
|
|
2164
|
+
*/
|
|
2165
|
+
getMetadata(): ResourceMetadata;
|
|
2106
2166
|
}
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2167
|
+
//#endregion
|
|
2168
|
+
//#region src/registry/ResourceRegistry.d.ts
|
|
2169
|
+
interface RegisterOptions {
|
|
2170
|
+
module?: string;
|
|
2171
|
+
/** Pre-generated OpenAPI schemas */
|
|
2172
|
+
openApiSchemas?: OpenApiSchemas;
|
|
2111
2173
|
}
|
|
2112
|
-
declare class
|
|
2113
|
-
private
|
|
2114
|
-
private
|
|
2115
|
-
|
|
2116
|
-
/**
|
|
2117
|
-
*
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2174
|
+
declare class ResourceRegistry {
|
|
2175
|
+
private _resources;
|
|
2176
|
+
private _frozen;
|
|
2177
|
+
constructor();
|
|
2178
|
+
/**
|
|
2179
|
+
* Register a resource
|
|
2180
|
+
*/
|
|
2181
|
+
register(resource: ResourceDefinition<unknown>, options?: RegisterOptions): this;
|
|
2182
|
+
/**
|
|
2183
|
+
* Get resource by name
|
|
2184
|
+
*/
|
|
2185
|
+
get(name: string): RegistryEntry | undefined;
|
|
2186
|
+
/**
|
|
2187
|
+
* Get all resources
|
|
2188
|
+
*/
|
|
2189
|
+
getAll(): RegistryEntry[];
|
|
2190
|
+
/**
|
|
2191
|
+
* Get resources by module
|
|
2192
|
+
*/
|
|
2193
|
+
getByModule(moduleName: string): RegistryEntry[];
|
|
2194
|
+
/**
|
|
2195
|
+
* Get resources by preset
|
|
2196
|
+
*/
|
|
2197
|
+
getByPreset(presetName: string): RegistryEntry[];
|
|
2198
|
+
/**
|
|
2199
|
+
* Check if resource exists
|
|
2200
|
+
*/
|
|
2201
|
+
has(name: string): boolean;
|
|
2202
|
+
/**
|
|
2203
|
+
* Get registry statistics
|
|
2204
|
+
*/
|
|
2205
|
+
getStats(): RegistryStats;
|
|
2122
2206
|
/**
|
|
2123
|
-
*
|
|
2124
|
-
* Combines ID filter with policy/org filters for proper security enforcement
|
|
2207
|
+
* Get full introspection data
|
|
2125
2208
|
*/
|
|
2126
|
-
|
|
2209
|
+
getIntrospection(): IntrospectionData;
|
|
2127
2210
|
/**
|
|
2128
|
-
*
|
|
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.
|
|
2211
|
+
* Freeze registry (prevent further registrations)
|
|
2133
2212
|
*/
|
|
2134
|
-
|
|
2213
|
+
freeze(): void;
|
|
2135
2214
|
/**
|
|
2136
|
-
* Check
|
|
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.
|
|
2215
|
+
* Check if frozen
|
|
2141
2216
|
*/
|
|
2142
|
-
|
|
2143
|
-
/** Check ownership for update/delete (ownedByUser preset) */
|
|
2144
|
-
checkOwnership(item: AnyRecord | null, req: IRequestContext): boolean;
|
|
2217
|
+
isFrozen(): boolean;
|
|
2145
2218
|
/**
|
|
2146
|
-
*
|
|
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)
|
|
2219
|
+
* Unfreeze registry (allow new registrations)
|
|
2153
2220
|
*/
|
|
2154
|
-
|
|
2221
|
+
unfreeze(): void;
|
|
2155
2222
|
/**
|
|
2156
|
-
*
|
|
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)
|
|
2223
|
+
* Reset registry — clear all resources and unfreeze
|
|
2166
2224
|
*/
|
|
2167
|
-
|
|
2225
|
+
reset(): void;
|
|
2226
|
+
/** @internal Alias for unfreeze() */
|
|
2227
|
+
_unfreeze(): void;
|
|
2228
|
+
/** @internal Alias for reset() */
|
|
2229
|
+
_clear(): void;
|
|
2168
2230
|
/**
|
|
2169
|
-
*
|
|
2170
|
-
* (e.g., getBySlug, restore). Applies org scope, policy filters, and
|
|
2171
|
-
* ownership checks — the same guarantees as fetchWithAccessControl.
|
|
2231
|
+
* Group by key
|
|
2172
2232
|
*/
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2233
|
+
private _groupBy;
|
|
2234
|
+
}
|
|
2235
|
+
//#endregion
|
|
2236
|
+
//#region src/types/fastify.d.ts
|
|
2237
|
+
interface FastifyRequestExtras {
|
|
2238
|
+
user?: Record<string, unknown>;
|
|
2239
|
+
}
|
|
2240
|
+
interface RequestWithExtras extends FastifyRequest {
|
|
2176
2241
|
/**
|
|
2177
|
-
*
|
|
2242
|
+
* Arc metadata — set by createCrudRouter. Contains resource configuration
|
|
2243
|
+
* and schema options.
|
|
2178
2244
|
*/
|
|
2179
|
-
|
|
2245
|
+
arc?: {
|
|
2246
|
+
resourceName?: string;
|
|
2247
|
+
schemaOptions?: RouteSchemaOptions;
|
|
2248
|
+
permissions?: ResourcePermissions;
|
|
2249
|
+
};
|
|
2250
|
+
context?: Record<string, unknown>;
|
|
2251
|
+
_policyFilters?: Record<string, unknown>;
|
|
2252
|
+
fieldMask?: {
|
|
2253
|
+
include?: string[];
|
|
2254
|
+
exclude?: string[];
|
|
2255
|
+
};
|
|
2256
|
+
_ownershipCheck?: Record<string, unknown>;
|
|
2257
|
+
}
|
|
2258
|
+
type FastifyWithAuth = FastifyInstance & {
|
|
2259
|
+
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
2260
|
+
};
|
|
2261
|
+
/**
|
|
2262
|
+
* Arc core decorator — added by `arcCorePlugin`. Provides instance-scoped
|
|
2263
|
+
* hooks and resource registry.
|
|
2264
|
+
*/
|
|
2265
|
+
interface ArcDecorator {
|
|
2266
|
+
hooks: HookSystem;
|
|
2267
|
+
registry: ResourceRegistry;
|
|
2268
|
+
/** Whether event emission is enabled */
|
|
2269
|
+
emitEvents: boolean;
|
|
2270
|
+
}
|
|
2271
|
+
/** Events decorator — added by `eventPlugin`. Provides event pub/sub. */
|
|
2272
|
+
interface EventsDecorator {
|
|
2273
|
+
publish: <T>(type: string, payload: T, meta?: Partial<{
|
|
2274
|
+
id: string;
|
|
2275
|
+
timestamp: Date;
|
|
2276
|
+
}>) => Promise<void>;
|
|
2180
2277
|
/**
|
|
2181
|
-
*
|
|
2182
|
-
*
|
|
2278
|
+
* Subscribe to an event pattern. Handler receives the full
|
|
2279
|
+
* `DomainEvent<T> = { type, payload, meta }` envelope — destructure
|
|
2280
|
+
* `payload` if you only need the body, or read `meta.correlationId` /
|
|
2281
|
+
* `meta.timestamp` for tracing. See CHANGELOG 2.10 for the migration
|
|
2282
|
+
* note from 2.9.x's loose `unknown` type.
|
|
2183
2283
|
*/
|
|
2184
|
-
|
|
2284
|
+
subscribe: <T = unknown>(pattern: string, handler: (event: DomainEvent<T>) => void | Promise<void>) => Promise<() => void>;
|
|
2285
|
+
transportName: string;
|
|
2286
|
+
}
|
|
2287
|
+
/**
|
|
2288
|
+
* Fastify instance with Arc decorators. Arc adds these via plugins/presets.
|
|
2289
|
+
*/
|
|
2290
|
+
type FastifyWithDecorators = FastifyInstance & {
|
|
2291
|
+
arc?: ArcDecorator;
|
|
2292
|
+
events?: EventsDecorator;
|
|
2293
|
+
authenticate?: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
2294
|
+
optionalAuthenticate?: (request: FastifyRequest, reply: FastifyReply) => Promise<void>; /** Organization-scoped filtering — from `multiTenant` preset */
|
|
2295
|
+
organizationScoped?: (options?: {
|
|
2296
|
+
required?: boolean;
|
|
2297
|
+
}) => RouteHandlerMethod;
|
|
2298
|
+
[key: string]: unknown;
|
|
2299
|
+
};
|
|
2300
|
+
/** Handler signature for middleware functions. */
|
|
2301
|
+
type MiddlewareHandler = (request: RequestWithExtras, reply: FastifyReply) => Promise<unknown>;
|
|
2302
|
+
//#endregion
|
|
2303
|
+
//#region src/types/plugins.d.ts
|
|
2304
|
+
interface GracefulShutdownOptions {
|
|
2305
|
+
timeout?: number;
|
|
2306
|
+
onShutdown?: () => Promise<void> | void;
|
|
2307
|
+
signals?: NodeJS.Signals[];
|
|
2308
|
+
logEvents?: boolean;
|
|
2309
|
+
}
|
|
2310
|
+
interface RequestIdOptions {
|
|
2311
|
+
headerName?: string;
|
|
2312
|
+
generator?: () => string;
|
|
2313
|
+
}
|
|
2314
|
+
interface HealthOptions {
|
|
2315
|
+
path?: string;
|
|
2316
|
+
check?: () => Promise<unknown>;
|
|
2317
|
+
}
|
|
2318
|
+
interface HealthCheck {
|
|
2319
|
+
healthy: boolean;
|
|
2320
|
+
timestamp: string;
|
|
2321
|
+
[key: string]: unknown;
|
|
2322
|
+
}
|
|
2323
|
+
interface IntrospectionPluginOptions {
|
|
2324
|
+
path?: string;
|
|
2325
|
+
prefix?: string;
|
|
2326
|
+
enabled?: boolean;
|
|
2327
|
+
authRoles?: string[];
|
|
2328
|
+
}
|
|
2329
|
+
interface CrudRouterOptions {
|
|
2330
|
+
/** Route prefix */
|
|
2331
|
+
prefix?: string;
|
|
2332
|
+
/** Permission checks for CRUD operations */
|
|
2333
|
+
permissions?: ResourcePermissions;
|
|
2334
|
+
/** OpenAPI tag for grouping routes */
|
|
2335
|
+
tag?: string;
|
|
2336
|
+
/** JSON schemas for CRUD operations */
|
|
2337
|
+
schemas?: Partial<CrudSchemas>;
|
|
2338
|
+
/** Middlewares for each CRUD operation */
|
|
2339
|
+
middlewares?: MiddlewareConfig;
|
|
2340
|
+
/** Custom routes (from presets or user-defined) */
|
|
2341
|
+
routes?: readonly RouteDefinition[];
|
|
2342
|
+
/** Disable all default CRUD routes */
|
|
2343
|
+
disableDefaultRoutes?: boolean;
|
|
2344
|
+
/** Disable specific CRUD routes */
|
|
2345
|
+
disabledRoutes?: CrudRouteKey[];
|
|
2346
|
+
/** Functional pipeline (guard/transform/intercept) */
|
|
2347
|
+
pipe?: PipelineConfig;
|
|
2348
|
+
/** Resource name for lifecycle hooks */
|
|
2349
|
+
resourceName?: string;
|
|
2350
|
+
/** Schema generation options */
|
|
2351
|
+
schemaOptions?: RouteSchemaOptions;
|
|
2352
|
+
/** Field-level permissions (visibility, writability per role) */
|
|
2353
|
+
fields?: FieldPermissionMap;
|
|
2354
|
+
/** HTTP method for update routes. Default: 'PATCH' */
|
|
2355
|
+
updateMethod?: "PUT" | "PATCH" | "both";
|
|
2185
2356
|
/**
|
|
2186
|
-
*
|
|
2187
|
-
*
|
|
2357
|
+
* Per-resource rate limiting. Requires `@fastify/rate-limit` to be
|
|
2358
|
+
* registered. Set to `false` to disable for this resource.
|
|
2188
2359
|
*/
|
|
2189
|
-
|
|
2360
|
+
rateLimit?: RateLimitConfig | false;
|
|
2361
|
+
/** PreHandler guards applied to every route (CRUD + custom + preset). */
|
|
2362
|
+
routeGuards?: RouteHandlerMethod[];
|
|
2363
|
+
}
|
|
2364
|
+
//#endregion
|
|
2365
|
+
//#region src/types/query.d.ts
|
|
2366
|
+
/**
|
|
2367
|
+
* Request-shaped context object passed to controller methods. Apps and
|
|
2368
|
+
* adapters extend it freely via the index signature.
|
|
2369
|
+
*/
|
|
2370
|
+
interface RequestContext {
|
|
2371
|
+
operation?: string;
|
|
2372
|
+
user?: unknown;
|
|
2373
|
+
filters?: Record<string, unknown>;
|
|
2374
|
+
[key: string]: unknown;
|
|
2375
|
+
}
|
|
2376
|
+
/**
|
|
2377
|
+
* Internal metadata shape injected by Arc's Fastify adapter. Extends
|
|
2378
|
+
* RequestContext with known internal fields so controllers can access
|
|
2379
|
+
* them without `as AnyRecord` casts.
|
|
2380
|
+
*/
|
|
2381
|
+
interface ArcInternalMetadata extends RequestContext {
|
|
2382
|
+
/** Policy filters from permission middleware */
|
|
2383
|
+
_policyFilters?: Record<string, unknown>;
|
|
2384
|
+
/** Request scope from scope resolution */
|
|
2385
|
+
_scope?: RequestScope;
|
|
2386
|
+
/** Ownership check config from ownedByUser preset */
|
|
2387
|
+
_ownershipCheck?: {
|
|
2388
|
+
field: string;
|
|
2389
|
+
userId: string;
|
|
2390
|
+
};
|
|
2391
|
+
/** Arc instance references (hooks, field permissions, etc.) */
|
|
2392
|
+
arc?: {
|
|
2393
|
+
hooks?: HookSystem;
|
|
2394
|
+
fields?: FieldPermissionMap;
|
|
2395
|
+
[key: string]: unknown;
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
/**
|
|
2399
|
+
* Controller-level query options — parsed from request query string.
|
|
2400
|
+
* Includes pagination, filtering, populate/lookup, and context data.
|
|
2401
|
+
*/
|
|
2402
|
+
interface ControllerQueryOptions {
|
|
2403
|
+
page?: number;
|
|
2404
|
+
limit?: number;
|
|
2405
|
+
sort?: string | Record<string, 1 | -1>;
|
|
2406
|
+
/** Simple populate (comma-separated string or array) */
|
|
2407
|
+
populate?: string | string[] | Record<string, unknown>;
|
|
2190
2408
|
/**
|
|
2191
|
-
*
|
|
2192
|
-
*
|
|
2409
|
+
* Advanced populate options (Mongoose-compatible). When set, takes
|
|
2410
|
+
* precedence over simple `populate`.
|
|
2193
2411
|
*/
|
|
2194
|
-
|
|
2412
|
+
populateOptions?: PopulateOption[];
|
|
2195
2413
|
/**
|
|
2196
|
-
*
|
|
2197
|
-
*
|
|
2414
|
+
* Lookup/join options (database-agnostic). MongoKit maps these to
|
|
2415
|
+
* `$lookup`; future SQL adapters would map to JOINs.
|
|
2416
|
+
*
|
|
2417
|
+
* @example
|
|
2418
|
+
* URL: ?lookup[category][from]=categories&lookup[category][localField]=categorySlug&lookup[category][foreignField]=slug
|
|
2198
2419
|
*/
|
|
2199
|
-
|
|
2420
|
+
lookups?: LookupOption[];
|
|
2421
|
+
select?: string | string[] | Record<string, 0 | 1>;
|
|
2422
|
+
filters?: Record<string, unknown>;
|
|
2423
|
+
search?: string;
|
|
2424
|
+
lean?: boolean;
|
|
2425
|
+
after?: string;
|
|
2426
|
+
user?: unknown;
|
|
2427
|
+
context?: Record<string, unknown>;
|
|
2428
|
+
[key: string]: unknown;
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* Database-agnostic lookup/join option. Parsed from URL:
|
|
2432
|
+
* `?lookup[alias][from]=...&lookup[alias][localField]=...&lookup[alias][foreignField]=...`
|
|
2433
|
+
*/
|
|
2434
|
+
interface LookupOption {
|
|
2435
|
+
/** Source collection/table to join from */
|
|
2436
|
+
from: string;
|
|
2437
|
+
/** Local field to match on */
|
|
2438
|
+
localField: string;
|
|
2439
|
+
/** Foreign field to match on */
|
|
2440
|
+
foreignField: string;
|
|
2441
|
+
/** Alias for the joined data (defaults to the lookup key) */
|
|
2442
|
+
as?: string;
|
|
2443
|
+
/** Return a single object instead of array (default: false) */
|
|
2444
|
+
single?: boolean;
|
|
2445
|
+
/** Field selection on the joined collection */
|
|
2446
|
+
select?: string | Record<string, 0 | 1>;
|
|
2200
2447
|
}
|
|
2201
|
-
//#endregion
|
|
2202
|
-
//#region src/core/BodySanitizer.d.ts
|
|
2203
2448
|
/**
|
|
2204
|
-
*
|
|
2449
|
+
* Mongoose-compatible populate option for advanced field selection.
|
|
2205
2450
|
*
|
|
2206
|
-
*
|
|
2207
|
-
*
|
|
2208
|
-
*
|
|
2209
|
-
*
|
|
2451
|
+
* @example
|
|
2452
|
+
* ```typescript
|
|
2453
|
+
* // URL: ?populate[author][select]=name,email
|
|
2454
|
+
* // Generates: { path: 'author', select: 'name email' }
|
|
2455
|
+
* ```
|
|
2210
2456
|
*/
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
* Strip readonly and system-managed fields from request body.
|
|
2227
|
-
* Prevents clients from overwriting _id, timestamps, __v, etc.
|
|
2228
|
-
*
|
|
2229
|
-
* Also applies field-level write permissions when the request has
|
|
2230
|
-
* field permission metadata.
|
|
2231
|
-
*/
|
|
2232
|
-
sanitize(body: AnyRecord, _operation: "create" | "update", req?: IRequestContext, meta?: ArcInternalMetadata): AnyRecord;
|
|
2457
|
+
interface PopulateOption {
|
|
2458
|
+
/** Field path to populate */
|
|
2459
|
+
path: string;
|
|
2460
|
+
/** Fields to select (space-separated) */
|
|
2461
|
+
select?: string;
|
|
2462
|
+
/** Filter conditions for populated documents */
|
|
2463
|
+
match?: Record<string, unknown>;
|
|
2464
|
+
/** Query options (limit, sort, skip) */
|
|
2465
|
+
options?: {
|
|
2466
|
+
limit?: number;
|
|
2467
|
+
sort?: Record<string, 1 | -1>;
|
|
2468
|
+
skip?: number;
|
|
2469
|
+
};
|
|
2470
|
+
/** Nested populate configuration */
|
|
2471
|
+
populate?: PopulateOption;
|
|
2233
2472
|
}
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2473
|
+
/**
|
|
2474
|
+
* Parsed query result from QueryParser. The index signature lets custom
|
|
2475
|
+
* parsers (MongoKit, PrismaKit) add fields without breaking Arc's types.
|
|
2476
|
+
*/
|
|
2477
|
+
interface ParsedQuery {
|
|
2478
|
+
filters?: Record<string, unknown>;
|
|
2479
|
+
limit?: number;
|
|
2480
|
+
sort?: string | Record<string, 1 | -1>;
|
|
2481
|
+
populate?: string | string[] | Record<string, unknown>;
|
|
2482
|
+
populateOptions?: PopulateOption[];
|
|
2483
|
+
lookups?: LookupOption[];
|
|
2484
|
+
search?: string;
|
|
2485
|
+
page?: number;
|
|
2486
|
+
after?: string;
|
|
2487
|
+
select?: string | string[] | Record<string, 0 | 1>;
|
|
2488
|
+
[key: string]: unknown;
|
|
2249
2489
|
}
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2490
|
+
/**
|
|
2491
|
+
* Query Parser interface. Implement to create custom query parsers.
|
|
2492
|
+
*
|
|
2493
|
+
* @example MongoKit
|
|
2494
|
+
* ```typescript
|
|
2495
|
+
* import { QueryParser } from '@classytic/mongokit';
|
|
2496
|
+
* const queryParser = new QueryParser();
|
|
2497
|
+
* ```
|
|
2498
|
+
*/
|
|
2499
|
+
interface QueryParserInterface {
|
|
2500
|
+
parse(query: Record<string, unknown> | null | undefined): ParsedQuery;
|
|
2501
|
+
/** Optional: Export OpenAPI schema for query parameters. */
|
|
2502
|
+
getQuerySchema?(): {
|
|
2503
|
+
type: "object";
|
|
2504
|
+
properties: Record<string, unknown>;
|
|
2505
|
+
required?: string[];
|
|
2506
|
+
};
|
|
2258
2507
|
/**
|
|
2259
|
-
*
|
|
2260
|
-
*
|
|
2508
|
+
* Optional: Allowed filter fields whitelist. MCP auto-derives
|
|
2509
|
+
* `filterableFields` from this if `schemaOptions.filterableFields`
|
|
2510
|
+
* is not explicitly configured.
|
|
2261
2511
|
*/
|
|
2262
|
-
|
|
2512
|
+
allowedFilterFields?: readonly string[];
|
|
2263
2513
|
/**
|
|
2264
|
-
*
|
|
2265
|
-
*
|
|
2266
|
-
*
|
|
2514
|
+
* Optional: Allowed filter operators whitelist. Used by MCP to enrich
|
|
2515
|
+
* list-tool descriptions. Values are human-readable keys: 'eq', 'ne',
|
|
2516
|
+
* 'gt', 'gte', 'lt', 'lte', 'in', 'nin', etc.
|
|
2267
2517
|
*/
|
|
2268
|
-
|
|
2269
|
-
/** Sanitize populate fields */
|
|
2270
|
-
private sanitizePopulate;
|
|
2271
|
-
/** Sanitize advanced populate options against allowedPopulate */
|
|
2272
|
-
private sanitizePopulateOptions;
|
|
2518
|
+
allowedOperators?: readonly string[];
|
|
2273
2519
|
/**
|
|
2274
|
-
*
|
|
2275
|
-
*
|
|
2276
|
-
* Validates lookup structure to prevent injection.
|
|
2520
|
+
* Optional: Allowed sort fields whitelist. Used by MCP to describe
|
|
2521
|
+
* available sort options in list-tool descriptions.
|
|
2277
2522
|
*/
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2523
|
+
allowedSortFields?: readonly string[];
|
|
2524
|
+
}
|
|
2525
|
+
/** Ownership-check config used by `ownedByUser` preset / middleware. */
|
|
2526
|
+
interface OwnershipCheck {
|
|
2527
|
+
field: string;
|
|
2528
|
+
userField?: string;
|
|
2529
|
+
}
|
|
2530
|
+
/** Service-layer context — passed to repository / service calls. */
|
|
2531
|
+
interface ServiceContext {
|
|
2532
|
+
user?: unknown;
|
|
2533
|
+
requestId?: string;
|
|
2534
|
+
/** Field projection for responses */
|
|
2535
|
+
select?: string[] | Record<string, 0 | 1>;
|
|
2536
|
+
/** Relations to populate */
|
|
2537
|
+
populate?: string | string[];
|
|
2538
|
+
/** Return plain objects */
|
|
2539
|
+
lean?: boolean;
|
|
2281
2540
|
}
|
|
2282
2541
|
//#endregion
|
|
2283
|
-
//#region src/
|
|
2284
|
-
interface
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
/**
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2542
|
+
//#region src/types/registry.d.ts
|
|
2543
|
+
interface ResourceMetadata {
|
|
2544
|
+
name: string;
|
|
2545
|
+
displayName?: string;
|
|
2546
|
+
tag?: string;
|
|
2547
|
+
prefix: string;
|
|
2548
|
+
module?: string;
|
|
2549
|
+
permissions?: ResourcePermissions;
|
|
2550
|
+
presets: string[];
|
|
2551
|
+
customRoutes?: Array<{
|
|
2552
|
+
method: string;
|
|
2553
|
+
path: string;
|
|
2554
|
+
handler: string;
|
|
2555
|
+
operation?: string;
|
|
2556
|
+
summary?: string;
|
|
2557
|
+
description?: string;
|
|
2558
|
+
permissions?: PermissionCheck;
|
|
2559
|
+
raw?: boolean;
|
|
2560
|
+
schema?: Record<string, unknown>;
|
|
2561
|
+
}>;
|
|
2562
|
+
routes: Array<{
|
|
2563
|
+
method: string;
|
|
2564
|
+
path: string;
|
|
2565
|
+
handler?: string;
|
|
2566
|
+
operation?: string;
|
|
2567
|
+
summary?: string;
|
|
2568
|
+
}>;
|
|
2569
|
+
events?: string[];
|
|
2570
|
+
}
|
|
2571
|
+
interface RegistryEntry extends ResourceMetadata {
|
|
2572
|
+
plugin: unknown;
|
|
2573
|
+
adapter?: {
|
|
2574
|
+
type: string;
|
|
2575
|
+
name: string;
|
|
2576
|
+
} | null;
|
|
2577
|
+
events?: string[];
|
|
2578
|
+
disableDefaultRoutes?: boolean;
|
|
2579
|
+
openApiSchemas?: OpenApiSchemas;
|
|
2580
|
+
registeredAt?: string;
|
|
2581
|
+
/** Field-level permissions metadata (for OpenAPI docs) */
|
|
2582
|
+
fieldPermissions?: Record<string, {
|
|
2583
|
+
type: string;
|
|
2584
|
+
roles?: readonly string[];
|
|
2585
|
+
redactValue?: unknown;
|
|
2586
|
+
}>;
|
|
2587
|
+
/** Pipeline step names (for OpenAPI docs) */
|
|
2588
|
+
pipelineSteps?: Array<{
|
|
2589
|
+
type: string;
|
|
2590
|
+
name: string;
|
|
2591
|
+
operations?: string[];
|
|
2592
|
+
}>;
|
|
2593
|
+
/** Update HTTP method(s) used for this resource */
|
|
2594
|
+
updateMethod?: "PUT" | "PATCH" | "both";
|
|
2595
|
+
/** Routes disabled for this resource */
|
|
2596
|
+
disabledRoutes?: string[];
|
|
2597
|
+
/** Rate limit config */
|
|
2598
|
+
rateLimit?: RateLimitConfig | false;
|
|
2599
|
+
/** Per-resource audit opt-in flag (read by `auditPlugin` perResource mode) */
|
|
2600
|
+
audit?: boolean | {
|
|
2601
|
+
operations?: ("create" | "update" | "delete")[];
|
|
2332
2602
|
};
|
|
2333
2603
|
/**
|
|
2334
|
-
*
|
|
2335
|
-
*
|
|
2336
|
-
*
|
|
2337
|
-
*
|
|
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.
|
|
2604
|
+
* v2.8 declarative actions metadata — populated from
|
|
2605
|
+
* `ResourceConfig.actions`. Consumed by OpenAPI generation (renders
|
|
2606
|
+
* `POST /:id/action` with a discriminated body schema) and MCP tool
|
|
2607
|
+
* generation. Added in 2.8.1.
|
|
2341
2608
|
*/
|
|
2342
|
-
|
|
2609
|
+
actions?: Array<{
|
|
2610
|
+
readonly name: string;
|
|
2611
|
+
readonly description?: string; /** Raw per-action schema (JSON Schema, Zod v4, or legacy field map) */
|
|
2612
|
+
readonly schema?: Record<string, unknown>; /** Per-action permission check (if different from resource-level `actionPermissions`) */
|
|
2613
|
+
readonly permissions?: PermissionCheck; /** MCP tool generation flag — `false` to skip, object for overrides */
|
|
2614
|
+
readonly mcp?: boolean | {
|
|
2615
|
+
readonly description?: string;
|
|
2616
|
+
readonly annotations?: Record<string, unknown>;
|
|
2617
|
+
};
|
|
2618
|
+
}>;
|
|
2619
|
+
/**
|
|
2620
|
+
* Resource-level fallback permission for actions without per-action
|
|
2621
|
+
* permissions. Used by OpenAPI to determine auth requirements and by
|
|
2622
|
+
* MCP as the fallback in `createActionToolHandler`. Added in 2.8.1.
|
|
2623
|
+
*/
|
|
2624
|
+
actionPermissions?: PermissionCheck;
|
|
2625
|
+
}
|
|
2626
|
+
interface RegistryStats {
|
|
2627
|
+
total?: number;
|
|
2628
|
+
totalResources: number;
|
|
2629
|
+
byTag?: Record<string, number>;
|
|
2630
|
+
byModule?: Record<string, number>;
|
|
2631
|
+
presetUsage?: Record<string, number>;
|
|
2632
|
+
totalRoutes?: number;
|
|
2633
|
+
totalEvents?: number;
|
|
2343
2634
|
}
|
|
2635
|
+
interface IntrospectionData {
|
|
2636
|
+
resources: ResourceMetadata[];
|
|
2637
|
+
stats: RegistryStats;
|
|
2638
|
+
generatedAt?: string;
|
|
2639
|
+
}
|
|
2640
|
+
//#endregion
|
|
2641
|
+
//#region src/types/repository.d.ts
|
|
2344
2642
|
/**
|
|
2345
|
-
*
|
|
2643
|
+
* Discriminated union of pagination result shapes. Narrow on `method`.
|
|
2346
2644
|
*
|
|
2347
|
-
*
|
|
2348
|
-
*
|
|
2349
|
-
*
|
|
2645
|
+
* repo-core ships the individual shapes (`OffsetPaginationResult` /
|
|
2646
|
+
* `KeysetPaginationResult`) but no combined union — arc needs one for the
|
|
2647
|
+
* BaseController's `list` / `getDeleted` return signatures, where either
|
|
2648
|
+
* shape is valid depending on the caller's pagination params.
|
|
2350
2649
|
*
|
|
2351
|
-
* @
|
|
2352
|
-
*
|
|
2650
|
+
* @example
|
|
2651
|
+
* ```ts
|
|
2652
|
+
* const result = await repo.getAll(params);
|
|
2653
|
+
* if (result.method === 'keyset') {
|
|
2654
|
+
* result.next; // keyset cursor
|
|
2655
|
+
* } else {
|
|
2656
|
+
* result.page; // offset number
|
|
2657
|
+
* }
|
|
2658
|
+
* ```
|
|
2353
2659
|
*/
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
* aliasing that Arc 2.6.3 introduced for repos keyed on `_id`.
|
|
2401
|
-
*/
|
|
2402
|
-
private resolveRepoId;
|
|
2403
|
-
/**
|
|
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.
|
|
2411
|
-
*/
|
|
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[]>>;
|
|
2440
|
-
/**
|
|
2441
|
-
* Build a tenant-scoped filter for bulk update/delete.
|
|
2442
|
-
*
|
|
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.
|
|
2455
|
-
*
|
|
2456
|
-
* Returns the merged filter, or `null` when access must be denied.
|
|
2457
|
-
*/
|
|
2458
|
-
private buildBulkFilter;
|
|
2459
|
-
/**
|
|
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.
|
|
2473
|
-
*/
|
|
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
|
-
}>>;
|
|
2660
|
+
type PaginationResult<TDoc, TExtra extends Record<string, unknown> = Record<string, never>> = OffsetPaginationResult<TDoc, TExtra> | KeysetPaginationResult<TDoc, TExtra>;
|
|
2661
|
+
//#endregion
|
|
2662
|
+
//#region src/types/utility.d.ts
|
|
2663
|
+
/**
|
|
2664
|
+
* Infer document type from a `DataAdapter` or `ResourceConfig`. Smart
|
|
2665
|
+
* inference that works with multiple sources.
|
|
2666
|
+
*
|
|
2667
|
+
* @example
|
|
2668
|
+
* ```typescript
|
|
2669
|
+
* type Doc1 = InferDocType<typeof adapter>; // From DataAdapter
|
|
2670
|
+
* type Doc2 = InferDocType<typeof resource>; // From ResourceConfig
|
|
2671
|
+
* ```
|
|
2672
|
+
*/
|
|
2673
|
+
type InferDocType<T> = T extends DataAdapter<infer D> ? D : T extends ResourceConfig<infer D> ? D : never;
|
|
2674
|
+
/**
|
|
2675
|
+
* Infer document type from a `DataAdapter`. Falls back to `unknown`
|
|
2676
|
+
* (not `never`) — safe for generic constraints.
|
|
2677
|
+
*
|
|
2678
|
+
* @example
|
|
2679
|
+
* ```typescript
|
|
2680
|
+
* const adapter = createMongooseAdapter({ model: ProductModel, repository: productRepo });
|
|
2681
|
+
* type ProductDoc = InferAdapterDoc<typeof adapter>;
|
|
2682
|
+
* ```
|
|
2683
|
+
*/
|
|
2684
|
+
type InferAdapterDoc<A> = A extends DataAdapter<infer D> ? D : unknown;
|
|
2685
|
+
type InferResourceDoc<T> = T extends ResourceConfig<infer D> ? D : never;
|
|
2686
|
+
type TypedResourceConfig<TDoc> = ResourceConfig<TDoc>;
|
|
2687
|
+
type TypedController<TDoc> = IController<TDoc>;
|
|
2688
|
+
type TypedRepository<TDoc> = StandardRepo<TDoc>;
|
|
2689
|
+
//#endregion
|
|
2690
|
+
//#region src/types/validation.d.ts
|
|
2691
|
+
/**
|
|
2692
|
+
* Validation result types — produced by `validateResourceConfig` and
|
|
2693
|
+
* consumed by `assertValidConfig` / `formatValidationErrors`.
|
|
2694
|
+
*/
|
|
2695
|
+
interface ConfigError {
|
|
2696
|
+
field: string;
|
|
2697
|
+
message: string;
|
|
2698
|
+
code?: string;
|
|
2699
|
+
}
|
|
2700
|
+
interface ValidationResult {
|
|
2701
|
+
valid: boolean;
|
|
2702
|
+
errors: ConfigError[];
|
|
2703
|
+
}
|
|
2704
|
+
interface ValidateOptions {
|
|
2705
|
+
strict?: boolean;
|
|
2499
2706
|
}
|
|
2500
2707
|
//#endregion
|
|
2501
|
-
export {
|
|
2708
|
+
export { beforeCreate as $, ObjectId as $t, RequestIdOptions as A, FastifyHandler as At, ResourceDefinition as B, PipelineContext as Bt, RequestContext as C, ResourcePermissions as Ct, HealthCheck as D, RouteSchemaOptions as Dt, GracefulShutdownOptions as E, RouteMethod as Et, FastifyWithDecorators as F, Guard as Ft, HookOperation as G, Authenticator as Gt, DefineHookOptions as H, Transform as Ht, MiddlewareHandler as I, Interceptor as It, HookSystem as J, TokenPair as Jt, HookPhase as K, AuthenticatorContext as Kt, RequestWithExtras as L, NextFunction as Lt, EventsDecorator as M, IControllerResponse as Mt, FastifyRequestExtras as N, IRequestContext as Nt, HealthOptions as O, ControllerHandler as Ot, FastifyWithAuth as P, RouteHandler as Pt, afterUpdate as Q, JWTPayload as Qt, RegisterOptions as R, OperationFilter as Rt, QueryParserInterface as S, ResourceHooks as St, CrudRouterOptions as T, RouteMcpConfig as Tt, HookContext as U, AuthHelpers as Ut, defineResource as V, PipelineStep as Vt, HookHandler as W, AuthPluginOptions as Wt, afterCreate as X, ApiResponse as Xt, HookSystemOptions as Y, AnyRecord as Yt, afterDelete as Z, ArcRequest as Zt, ControllerQueryOptions as _, RepositoryLike as _n, PresetResult as _t, InferAdapterDoc as a, BaseControllerOptions as an, ActionEntry as at, ParsedQuery as b, ResourceConfig as bt, TypedController as c, BodySanitizer as cn, CrudController as ct, PaginationResult as d, AccessControlConfig as dn, EventDefinition as dt, UserLike as en, beforeDelete as et, IntrospectionData as f, AdapterFactory as fn, FieldRule as ft, ArcInternalMetadata as g, RelationMetadata as gn, PresetHook as gt, ResourceMetadata as h, FieldMetadata as hn, PresetFunction as ht, ValidationResult as i, BaseController as in, ActionDefinition as it, ArcDecorator as j, IController as jt, IntrospectionPluginOptions as k, ControllerLike as kt, TypedRepository as l, BodySanitizerConfig as ln, CrudRouteKey as lt, RegistryStats as m, DataAdapter as mn, OpenApiSchemas as mt, ConfigError as n, envelope as nn, createHookSystem as nt, InferDocType as o, QueryResolver as on, ActionHandlerFn as ot, RegistryEntry as p, AdapterSchemaContext as pn, MiddlewareConfig as pt, HookRegistration as q, JwtContext as qt, ValidateOptions as r, getUserId as rn, defineHook as rt, InferResourceDoc as s, QueryResolverConfig as sn, ActionsMap as st, RouteHandlerMethod$1 as t, UserOrganization as tn, beforeUpdate as tt, TypedResourceConfig as u, AccessControl as un, CrudSchemas as ut, LookupOption as v, SchemaMetadata as vn, RateLimitConfig as vt, ServiceContext as w, RouteDefinition as wt, PopulateOption as x, ResourceHookContext as xt, OwnershipCheck as y, ValidationResult$1 as yn, ResourceCacheConfig as yt, ResourceRegistry as z, PipelineConfig as zt };
|