@classytic/arc 2.10.3 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/{BaseController-CbKKIflT.mjs → BaseController-JNV08qOT.mjs} +595 -537
- package/dist/{queryCachePlugin-BKbWjgDG.d.mts → QueryCache-DOBNHBE0.d.mts} +2 -32
- package/dist/actionPermissions-C8YYU92K.mjs +22 -0
- package/dist/adapters/index.d.mts +2 -2
- package/dist/adapters/index.mjs +1 -1
- package/dist/{adapters-BXY4i-hw.mjs → adapters-D0tT2Tyo.mjs} +54 -0
- 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 +3 -3
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi-BBRVhjQN.mjs → betterAuthOpenApi-DwxtK3uG.mjs} +1 -1
- package/dist/cache/index.d.mts +3 -2
- package/dist/cache/index.mjs +3 -3
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +37 -27
- package/dist/cli/commands/init.mjs +47 -34
- 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 +3 -3
- package/dist/core/index.mjs +4 -3
- package/dist/core-DXdSSFW-.mjs +1037 -0
- package/dist/createActionRouter-BwaSM0No.mjs +166 -0
- package/dist/{createApp-BuvPma24.mjs → createApp-DvNYEhpb.mjs} +118 -36
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +1 -1
- package/dist/{elevation-C7hgL_aI.mjs → elevation-DOFoxoDs.mjs} +1 -1
- package/dist/errorHandler-Co3lnVmJ.d.mts +114 -0
- package/dist/{eventPlugin-DCUjuiQT.mjs → eventPlugin--5HIkdPU.mjs} +1 -1
- package/dist/{eventPlugin-CxWgpd6K.d.mts → eventPlugin-CUNjYYRY.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-ChIw3776.d.mts → index-BYCqHCVu.d.mts} +4 -4
- package/dist/{index-Cl0uoKd5.d.mts → index-Cm0vUrr_.d.mts} +2100 -1688
- package/dist/{index-DStwgFUK.d.mts → index-DAushRTt.d.mts} +29 -10
- package/dist/index-DsJ1MNfC.d.mts +1179 -0
- package/dist/{index-8qw4y6ff.d.mts → index-t8pLpPFW.d.mts} +13 -10
- package/dist/index.d.mts +7 -251
- package/dist/index.mjs +8 -128
- package/dist/integrations/event-gateway.d.mts +2 -2
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +2 -2
- 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/integrations/streamline.d.mts +46 -5
- package/dist/integrations/streamline.mjs +50 -21
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +2 -154
- package/dist/integrations/websocket.mjs +292 -224
- package/dist/{keys-qcD-TVJl.mjs → keys-CARyUjiR.mjs} +2 -0
- package/dist/{loadResources-BAzJItAJ.mjs → loadResources-YNwKHvRA.mjs} +3 -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-CvTR1Un6.mjs +123 -0
- package/dist/{openapi-B5F8AddX.mjs → openapi-C0L9ar7m.mjs} +9 -7
- package/dist/org/index.d.mts +2 -2
- package/dist/permissions/index.d.mts +2 -2
- package/dist/permissions/index.mjs +1 -3
- package/dist/{permissions-Dk6mshja.mjs → permissions-B4vU9L0Q.mjs} +220 -2
- package/dist/pipe-DVoIheVC.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 +10 -10
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +42 -24
- 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 +48 -8
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-fLJVXdVn.mjs → presets-k604Lj99.mjs} +1 -1
- package/dist/queryCachePlugin-BUXBSm4F.d.mts +34 -0
- package/dist/{queryCachePlugin-DQCEfJis.mjs → queryCachePlugin-Bq6bO6vc.mjs} +3 -3
- package/dist/{redis-DqyeggCa.d.mts → redis-Cm1gnRDf.d.mts} +1 -1
- package/dist/{redis-stream-CakIQmwR.d.mts → redis-stream-CM8TXTix.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-CfRkaxwf.mjs} +1 -1
- package/dist/{resourceToTools-BElv3xPT.mjs → resourceToTools--okX6QBr.mjs} +534 -415
- package/dist/routerShared-DeESFp4a.mjs +515 -0
- package/dist/schemaIR-BlG9bY7v.mjs +137 -0
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +1 -1
- package/dist/{sse-yBCgOLGu.mjs → sse-V7aXc3bW.mjs} +1 -1
- package/dist/{store-helpers-ZCSMJJAX.mjs → store-helpers-BhrzxvyQ.mjs} +4 -0
- package/dist/testing/index.d.mts +367 -711
- package/dist/testing/index.mjs +646 -1434
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/{tracing-65B51Dw3.d.mts → tracing-DokiEsuz.d.mts} +9 -4
- package/dist/types/index.d.mts +5 -5
- package/dist/types/index.mjs +1 -3
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-Co8k3NyS.d.mts → types-CgikqKAj.d.mts} +133 -21
- package/dist/{types-Btdda02s.d.mts → types-D9NqiYIw.d.mts} +1 -1
- package/dist/utils/index.d.mts +2 -898
- package/dist/utils/index.mjs +4 -5
- package/dist/utils-D3Yxnrwr.mjs +1639 -0
- package/dist/versioning-M9lNLhO8.d.mts +117 -0
- package/dist/websocket-CyJ1VIFI.d.mts +186 -0
- package/package.json +26 -8
- package/skills/arc/SKILL.md +124 -39
- package/skills/arc/references/testing.md +212 -183
- package/dist/applyPermissionResult-QhV1Pa-g.mjs +0 -37
- package/dist/core-CcR01lup.mjs +0 -1411
- package/dist/createActionRouter-Bp_5c_2b.mjs +0 -249
- package/dist/errorHandler-DRQ3EqfL.d.mts +0 -218
- package/dist/errors-CCSsMpXE.d.mts +0 -140
- package/dist/fields-bxkeltzz.mjs +0 -126
- package/dist/filesUpload-t21LS-py.mjs +0 -377
- package/dist/queryParser-DBqBB6AC.mjs +0 -352
- package/dist/types-Csi3FLfq.mjs +0 -27
- package/dist/utils-B2fNOD_i.mjs +0 -929
- /package/dist/{EventTransport-CUw5NNWe.d.mts → EventTransport-CfVEGaEl.d.mts} +0 -0
- /package/dist/{HookSystem-BNYKnrXF.mjs → HookSystem-CGsMd6oK.mjs} +0 -0
- /package/dist/{ResourceRegistry-BPd6NQDm.mjs → ResourceRegistry-DkAeAuTX.mjs} +0 -0
- /package/dist/{caching-CBpK_SCM.mjs → caching-CheW3m-S.mjs} +0 -0
- /package/dist/{elevation-C5SwtkAn.d.mts → elevation-s5ykdNHr.d.mts} +0 -0
- /package/dist/{errorHandler-Bb49BvPD.mjs → errorHandler-BQm8ZxTK.mjs} +0 -0
- /package/dist/{externalPaths-BQ8QijNH.d.mts → externalPaths-Bapitwvd.d.mts} +0 -0
- /package/dist/{interface-CSbZdv_3.d.mts → interface-CkkWm5uR.d.mts} +0 -0
- /package/dist/{interface-D218ikEo.d.mts → interface-Da0r7Lna.d.mts} +0 -0
- /package/dist/{memory-B5Amv9A1.mjs → memory-DikHSvWa.mjs} +0 -0
- /package/dist/{metrics-DuhiSEZI.mjs → metrics-Csh4nsvv.mjs} +0 -0
- /package/dist/{pluralize-A0tWEl1K.mjs → pluralize-BneOJkpi.mjs} +0 -0
- /package/dist/{registry-B3lRFBWo.mjs → registry-D63ee7fl.mjs} +0 -0
- /package/dist/{replyHelpers-CXtJDAZ0.mjs → replyHelpers-ByllIXXV.mjs} +0 -0
- /package/dist/{schemaConverter-BxFDdtXu.mjs → schemaConverter-B0oKLuqI.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/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
- /package/dist/{types-BD85MlEK.d.mts → types-tgR4Pt8F.d.mts} +0 -0
- /package/dist/{versioning-C2U_bLY0.mjs → versioning-CGPjkqAg.mjs} +0 -0
|
@@ -1,111 +1,151 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { n as
|
|
1
|
+
import { a as QueryCacheConfig } from "./QueryCache-DOBNHBE0.mjs";
|
|
2
|
+
import { r as RequestScope } from "./types-tgR4Pt8F.mjs";
|
|
3
|
+
import { c as PermissionCheck, d as UserBase, n as FieldPermissionMap } from "./fields-C8Y0XLAu.mjs";
|
|
4
|
+
import { n as DomainEvent } from "./EventTransport-CfVEGaEl.mjs";
|
|
4
5
|
import { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest, RouteHandlerMethod, RouteHandlerMethod as RouteHandlerMethod$1 } from "fastify";
|
|
5
|
-
import * as _$_classytic_repo_core_repository0 from "@classytic/repo-core/repository";
|
|
6
|
-
import { MinimalRepo, QueryOptions, StandardRepo } from "@classytic/repo-core/repository";
|
|
7
6
|
import { KeysetPaginationResult, OffsetPaginationResult } from "@classytic/repo-core/pagination";
|
|
7
|
+
import { MinimalRepo, QueryOptions, StandardRepo } from "@classytic/repo-core/repository";
|
|
8
|
+
import { FieldRule, SchemaBuilderOptions } from "@classytic/repo-core/schema";
|
|
8
9
|
|
|
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
|
-
}
|
|
10
|
+
//#region src/adapters/interface.d.ts
|
|
73
11
|
/**
|
|
74
|
-
*
|
|
75
|
-
* instead of `(req as any).user`.
|
|
12
|
+
* Arc's structural repository contract.
|
|
76
13
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
14
|
+
* Defined as `MinimalRepo<TDoc> & Partial<StandardRepo<TDoc>>` — the
|
|
15
|
+
* repo-core 5-method floor (required) plus every other `StandardRepo`
|
|
16
|
+
* method (optional). This compound is the single shape arc accepts
|
|
17
|
+
* across its entire API surface:
|
|
80
18
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
19
|
+
* - `defineResource({ adapter: { repository, ... } })`
|
|
20
|
+
* - `auditPlugin({ repository })`, `idempotencyPlugin({ repository })`
|
|
21
|
+
* - `new EventOutbox({ repository, transport })`
|
|
22
|
+
*
|
|
23
|
+
* **Why compound and not `StandardRepo` alone:** forcing every kit to
|
|
24
|
+
* implement the full `StandardRepo` surface would break kits with
|
|
25
|
+
* partial capabilities (sqlitekit has no aggregation, prismakit has no
|
|
26
|
+
* native atomic CAS the same way). Feature-detection at the arc layer
|
|
27
|
+
* lets each kit declare only what it implements; arc's audit / outbox /
|
|
28
|
+
* idempotency plugins check `typeof repo.method === 'function'` at
|
|
29
|
+
* construction and throw with the list of missing primitives if a
|
|
30
|
+
* required subset isn't covered. See the store-backing contract matrix
|
|
31
|
+
* in the file header.
|
|
32
|
+
*
|
|
33
|
+
* **Why compound and not `MinimalRepo` alone:** arc's internal plugins
|
|
34
|
+
* still need the `StandardRepo` type info at call sites where they use
|
|
35
|
+
* optionals like `findOneAndUpdate` / `deleteMany`. Without the
|
|
36
|
+
* `Partial<StandardRepo>` half, every access would require
|
|
37
|
+
* `as StandardRepo` casts and the feature-detect pattern would be
|
|
38
|
+
* runtime-only with no type-level backing.
|
|
39
|
+
*
|
|
40
|
+
* **Hosts importing repo-core directly.** `MinimalRepo` and
|
|
41
|
+
* `StandardRepo` are repo-core's contract — hosts that want to reference
|
|
42
|
+
* them by name should `import type { MinimalRepo, StandardRepo } from
|
|
43
|
+
* '@classytic/repo-core/repository'` rather than go through arc. Arc
|
|
44
|
+
* doesn't re-export them from its root barrel on purpose: creating a
|
|
45
|
+
* second source of truth would force arc to either drift from repo-core
|
|
46
|
+
* or force-sync every time the contract iterates.
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* const adapter: DataAdapter<Product> = {
|
|
50
|
+
* repository: myRepo, // any MinimalRepo<Product> — kit-agnostic
|
|
51
|
+
* type: 'drizzle', // or 'mongoose' | 'prisma' | 'custom'
|
|
52
|
+
* name: 'products',
|
|
53
|
+
* };
|
|
54
|
+
* defineResource({ adapter, ... });
|
|
86
55
|
* ```
|
|
87
56
|
*/
|
|
88
|
-
type
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
57
|
+
type RepositoryLike<TDoc = unknown> = MinimalRepo<TDoc> & Partial<StandardRepo<TDoc>>;
|
|
58
|
+
interface DataAdapter<TDoc = unknown> {
|
|
59
|
+
/**
|
|
60
|
+
* Repository implementing CRUD operations. Any value that satisfies
|
|
61
|
+
* `RepositoryLike<TDoc>` — which includes `StandardRepo<TDoc>` (all
|
|
62
|
+
* methods implemented), `MinimalRepo<TDoc>` (5-method floor), or
|
|
63
|
+
* anything in between a kit declares. Arc feature-detects optional
|
|
64
|
+
* methods at runtime — kits only declare what they support.
|
|
65
|
+
*/
|
|
66
|
+
repository: RepositoryLike<TDoc>;
|
|
67
|
+
/** Adapter identifier for introspection */
|
|
68
|
+
readonly type: "mongoose" | "prisma" | "drizzle" | "typeorm" | "custom";
|
|
69
|
+
/** Human-readable name */
|
|
70
|
+
readonly name: string;
|
|
71
|
+
/**
|
|
72
|
+
* Generate OpenAPI schemas for CRUD operations. Each adapter produces
|
|
73
|
+
* schemas appropriate to its ORM/database (mongoose kits use mongokit's
|
|
74
|
+
* `buildCrudSchemasFromModel`; SQL kits introspect columns).
|
|
75
|
+
*
|
|
76
|
+
* @param options - Schema generation options (field rules, populate hints)
|
|
77
|
+
* @param context - Resource-level context (idField for params schema, name for logs).
|
|
78
|
+
* Adapters should honor `context.idField` when producing the params
|
|
79
|
+
* schema (e.g., skip the ObjectId pattern when idField is a custom
|
|
80
|
+
* string field).
|
|
81
|
+
*/
|
|
82
|
+
generateSchemas?(options?: RouteSchemaOptions, context?: AdapterSchemaContext): OpenApiSchemas | Record<string, unknown> | null;
|
|
83
|
+
/** Extract schema metadata for OpenAPI/introspection. */
|
|
84
|
+
getSchemaMetadata?(): SchemaMetadata | null;
|
|
85
|
+
/** Validate data against schema before persistence. */
|
|
86
|
+
validate?(data: unknown): Promise<ValidationResult$1> | ValidationResult$1;
|
|
87
|
+
/** Health check for database connection. */
|
|
88
|
+
healthCheck?(): Promise<boolean>;
|
|
89
|
+
/**
|
|
90
|
+
* Custom filter matching for in-memory policy enforcement. Falls back
|
|
91
|
+
* to arc's built-in shallow matcher when omitted. Override for SQL
|
|
92
|
+
* adapters, non-Mongo operators, or kits that compile Filter IR.
|
|
93
|
+
*/
|
|
94
|
+
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
95
|
+
/** Close / cleanup resources. */
|
|
96
|
+
close?(): Promise<void>;
|
|
97
|
+
}
|
|
93
98
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* ```typescript
|
|
98
|
-
* handler: async (req, reply) => {
|
|
99
|
-
* const data = await getResults();
|
|
100
|
-
* return envelope(data); // → { success: true, data }
|
|
101
|
-
* }
|
|
102
|
-
* ```
|
|
99
|
+
* Context passed to `adapter.generateSchemas()` so adapters shape output
|
|
100
|
+
* to match resource-level configuration. All fields optional — adapters
|
|
101
|
+
* that ignore this still work; arc applies safety-net normalization.
|
|
103
102
|
*/
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
interface AdapterSchemaContext {
|
|
104
|
+
/** The idField configured on the resource. Defaults to "_id". */
|
|
105
|
+
idField?: string;
|
|
106
|
+
/** Resource name (for error messages / logging). */
|
|
107
|
+
resourceName?: string;
|
|
108
|
+
}
|
|
109
|
+
interface SchemaMetadata {
|
|
110
|
+
name: string;
|
|
111
|
+
fields: Record<string, FieldMetadata>;
|
|
112
|
+
indexes?: Array<{
|
|
113
|
+
fields: string[];
|
|
114
|
+
unique?: boolean;
|
|
115
|
+
sparse?: boolean;
|
|
116
|
+
}>;
|
|
117
|
+
relations?: Record<string, RelationMetadata>;
|
|
118
|
+
}
|
|
119
|
+
interface FieldMetadata {
|
|
120
|
+
type: "string" | "number" | "boolean" | "date" | "object" | "array" | "objectId" | "enum";
|
|
121
|
+
required?: boolean;
|
|
122
|
+
unique?: boolean;
|
|
123
|
+
default?: unknown;
|
|
124
|
+
enum?: Array<string | number>;
|
|
125
|
+
min?: number;
|
|
126
|
+
max?: number;
|
|
127
|
+
minLength?: number;
|
|
128
|
+
maxLength?: number;
|
|
129
|
+
pattern?: string;
|
|
130
|
+
description?: string;
|
|
131
|
+
ref?: string;
|
|
132
|
+
array?: boolean;
|
|
133
|
+
}
|
|
134
|
+
interface RelationMetadata {
|
|
135
|
+
type: "one-to-one" | "one-to-many" | "many-to-many";
|
|
136
|
+
target: string;
|
|
137
|
+
foreignKey?: string;
|
|
138
|
+
through?: string;
|
|
139
|
+
}
|
|
140
|
+
interface ValidationResult$1 {
|
|
141
|
+
valid: boolean;
|
|
142
|
+
errors?: Array<{
|
|
143
|
+
field: string;
|
|
144
|
+
message: string;
|
|
145
|
+
code?: string;
|
|
146
|
+
}>;
|
|
147
|
+
}
|
|
148
|
+
type AdapterFactory<TDoc> = (config: unknown) => DataAdapter<TDoc>;
|
|
109
149
|
//#endregion
|
|
110
150
|
//#region src/hooks/HookSystem.d.ts
|
|
111
151
|
type HookPhase = "before" | "around" | "after";
|
|
@@ -358,1052 +398,1818 @@ declare function beforeDelete<T = AnyRecord>(hooks: HookSystem, resource: string
|
|
|
358
398
|
*/
|
|
359
399
|
declare function afterDelete<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
|
|
360
400
|
//#endregion
|
|
361
|
-
//#region src/
|
|
362
|
-
/**
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
};
|
|
401
|
+
//#region src/core/AccessControl.d.ts
|
|
402
|
+
/** Denial reason codes returned by `fetchDetailed()`. */
|
|
403
|
+
type FetchDenialReason = "NOT_FOUND" | "POLICY_FILTERED" | "ORG_SCOPE_DENIED";
|
|
404
|
+
/** Result of a detailed fetch with access control. */
|
|
405
|
+
interface FetchResult<TDoc> {
|
|
406
|
+
/** The document, or null if denied. */
|
|
407
|
+
doc: TDoc | null;
|
|
408
|
+
/** Null when the doc was found. A string code when denied. */
|
|
409
|
+
reason: FetchDenialReason | null;
|
|
393
410
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
*/
|
|
398
|
-
|
|
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>;
|
|
411
|
+
interface AccessControlConfig {
|
|
412
|
+
/** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable org filtering. */
|
|
413
|
+
tenantField: string | false;
|
|
414
|
+
/** Primary key field name (default: '_id') */
|
|
415
|
+
idField: string;
|
|
404
416
|
/**
|
|
405
|
-
*
|
|
406
|
-
*
|
|
417
|
+
* Custom filter matching for policy enforcement.
|
|
418
|
+
* Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
|
|
419
|
+
* Falls back to built-in MongoDB-style matching if not provided.
|
|
407
420
|
*/
|
|
408
|
-
|
|
421
|
+
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
422
|
+
}
|
|
423
|
+
/** Minimal repository interface for access-controlled fetch operations */
|
|
424
|
+
interface AccessControlRepository {
|
|
425
|
+
getById(id: string, options?: QueryOptions): Promise<unknown>;
|
|
426
|
+
getOne?: (filter: AnyRecord, options?: QueryOptions) => Promise<unknown>;
|
|
427
|
+
}
|
|
428
|
+
declare class AccessControl {
|
|
429
|
+
private readonly tenantField;
|
|
430
|
+
private readonly idField;
|
|
431
|
+
private readonly _adapterMatchesFilter?;
|
|
409
432
|
/**
|
|
410
|
-
*
|
|
411
|
-
*
|
|
433
|
+
* One-shot latch for the "adapter didn't supply matchesFilter, in-memory
|
|
434
|
+
* policy-filter re-check is skipped" warning. The primary fetch path
|
|
435
|
+
* (`getOne(compoundFilter)`) already applied filters at the DB layer;
|
|
436
|
+
* this warn only fires when `validateItemAccess` runs and the adapter
|
|
437
|
+
* hasn't provided a native matcher for the post-hoc re-check.
|
|
438
|
+
*/
|
|
439
|
+
private _warnedNoMatcher;
|
|
440
|
+
constructor(config: AccessControlConfig);
|
|
441
|
+
/**
|
|
442
|
+
* Build filter for single-item operations (get/update/delete)
|
|
443
|
+
* Combines ID filter with policy/org filters for proper security enforcement
|
|
444
|
+
*/
|
|
445
|
+
buildIdFilter(id: string, req: IRequestContext): AnyRecord;
|
|
446
|
+
/**
|
|
447
|
+
* Check if a post-fetch item matches the request's `_policyFilters`.
|
|
412
448
|
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
449
|
+
* **When this runs:** only on paths where the primary fetch path did NOT
|
|
450
|
+
* apply policy filters at the DB layer — notably `validateItemAccess`
|
|
451
|
+
* (used by `getBySlug` and cache revalidation). The main `fetchDetailed`
|
|
452
|
+
* path builds a compound filter (`buildIdFilter`) and passes it to
|
|
453
|
+
* `repository.getOne(compoundFilter)`, so the DB has already enforced
|
|
454
|
+
* the filter and an in-memory re-check would be redundant.
|
|
455
|
+
*
|
|
456
|
+
* **Evaluation order (fail-closed):**
|
|
457
|
+
* 1. No `_policyFilters` set → `true` (nothing to enforce).
|
|
458
|
+
* 2. Adapter supplied `matchesFilter` → delegate to it verbatim. Adapters
|
|
459
|
+
* are expected to handle every filter shape the host emits
|
|
460
|
+
* (mongokit/sqlitekit evaluate at the DB layer; Prisma/custom engines
|
|
461
|
+
* can wrap their own predicate engine).
|
|
462
|
+
* 3. No adapter matcher → fall back to `simpleEqualityMatcher` — arc's
|
|
463
|
+
* built-in flat-key equality helper. This is defense-in-depth for the
|
|
464
|
+
* common case: arc's own permission helpers emit flat filters
|
|
465
|
+
* (`{userId: …}`, `{organizationId: …}`), which this matcher evaluates
|
|
466
|
+
* correctly. Operator-shaped filters (`$in`, `$ne`, `$regex`, `$and`,
|
|
467
|
+
* `$or`) are **rejected** (the matcher returns `false`) — fail-closed
|
|
468
|
+
* rather than fail-open. A one-shot warn flags the gap so adapter
|
|
469
|
+
* authors can wire a richer matcher.
|
|
470
|
+
*
|
|
471
|
+
* Arc deliberately does NOT ship a full MongoDB-syntax matcher:
|
|
472
|
+
* re-implementing Mongo in JS was dead code for mongokit users (the DB
|
|
473
|
+
* did it) and silently wrong for non-Mongo adapters. The flat-equality
|
|
474
|
+
* fallback is small (~20 LOC), correct in both dialects, and closes the
|
|
475
|
+
* previous `getBySlug`-style policy-bypass path.
|
|
415
476
|
*/
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
477
|
+
checkPolicyFilters(item: AnyRecord, req: IRequestContext): boolean;
|
|
478
|
+
/**
|
|
479
|
+
* Emit a one-shot warn when policy filters contain operators (`$in`,
|
|
480
|
+
* `$ne`, `$regex`, etc.) and no `DataAdapter.matchesFilter` is wired —
|
|
481
|
+
* arc's flat-equality fallback fail-closes on operators, so the host
|
|
482
|
+
* sees 404s on docs that should match. Latched on `_warnedNoMatcher`
|
|
483
|
+
* so subsequent requests stay quiet.
|
|
484
|
+
*/
|
|
485
|
+
private _warnNoMatcher;
|
|
486
|
+
/**
|
|
487
|
+
* Check org/tenant scope for a document — uses configurable tenantField.
|
|
488
|
+
*
|
|
489
|
+
* SECURITY: When org scope is active (orgId present), documents that are
|
|
490
|
+
* missing the tenant field are DENIED by default. This prevents legacy or
|
|
491
|
+
* unscoped records from leaking across tenants.
|
|
492
|
+
*/
|
|
493
|
+
checkOrgScope(item: AnyRecord | null, arcContext: ArcInternalMetadata | RequestContext | undefined): boolean;
|
|
494
|
+
/** Check ownership for update/delete (ownedByUser preset) */
|
|
495
|
+
checkOwnership(item: AnyRecord | null, req: IRequestContext): boolean;
|
|
496
|
+
/**
|
|
497
|
+
* Fetch a single document with full access control enforcement.
|
|
498
|
+
* Combines compound DB filter (ID + org + policy) with post-hoc fallback.
|
|
499
|
+
*
|
|
500
|
+
* Takes repository as a parameter to avoid coupling.
|
|
501
|
+
*
|
|
502
|
+
* Replaces the duplicated pattern in get/update/delete:
|
|
503
|
+
* buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
|
|
504
|
+
*/
|
|
505
|
+
fetchWithAccessControl<TDoc>(id: string, req: IRequestContext, repository: AccessControlRepository, queryOptions?: QueryOptions): Promise<TDoc | null>;
|
|
506
|
+
/**
|
|
507
|
+
* Same as `fetchWithAccessControl` but returns a structured result with
|
|
508
|
+
* a denial reason so callers can distinguish "doc doesn't exist" from
|
|
509
|
+
* "doc exists but was filtered by policy/org scope" from "repo threw".
|
|
510
|
+
*
|
|
511
|
+
* Codes:
|
|
512
|
+
* - `null` — doc was found, no denial
|
|
513
|
+
* - `'NOT_FOUND'` — doc genuinely doesn't exist in the DB
|
|
514
|
+
* - `'POLICY_FILTERED'` — doc exists but the request's policy filters exclude it
|
|
515
|
+
* - `'ORG_SCOPE_DENIED'` — doc exists but the caller's org context doesn't match
|
|
516
|
+
* - `'REPO_ERROR'` — the repository threw a "not found" error (mongokit style)
|
|
517
|
+
*/
|
|
518
|
+
fetchDetailed<TDoc>(id: string, req: IRequestContext, repository: AccessControlRepository, queryOptions?: QueryOptions): Promise<FetchResult<TDoc>>;
|
|
519
|
+
/**
|
|
520
|
+
* Post-fetch access control validation for items fetched by non-ID queries
|
|
521
|
+
* (e.g., getBySlug, restore). Applies org scope, policy filters, and
|
|
522
|
+
* ownership checks — the same guarantees as fetchWithAccessControl.
|
|
523
|
+
*/
|
|
524
|
+
validateItemAccess(item: AnyRecord | null, req: IRequestContext): boolean;
|
|
525
|
+
/** Extract typed Arc internal metadata from request */
|
|
526
|
+
private _meta;
|
|
443
527
|
}
|
|
528
|
+
//#endregion
|
|
529
|
+
//#region src/core/BodySanitizer.d.ts
|
|
444
530
|
/**
|
|
445
|
-
*
|
|
531
|
+
* Policy for handling fields the caller lacks write permission for.
|
|
446
532
|
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
* ```
|
|
533
|
+
* - `'reject'` (default, secure): throw 403 listing the denied fields so
|
|
534
|
+
* misconfigurations and attacks surface instead of silently disappearing.
|
|
535
|
+
* - `'strip'` (legacy): silently drop the field and continue. Preserved for
|
|
536
|
+
* apps that relied on the pre-2.9 behaviour — new code should not use it.
|
|
452
537
|
*/
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
limit?: number;
|
|
463
|
-
sort?: Record<string, 1 | -1>;
|
|
464
|
-
skip?: number;
|
|
465
|
-
};
|
|
466
|
-
/** Nested populate configuration */
|
|
467
|
-
populate?: PopulateOption;
|
|
538
|
+
type FieldWriteDenialPolicy = "reject" | "strip";
|
|
539
|
+
interface BodySanitizerConfig {
|
|
540
|
+
/** Schema options for field sanitization */
|
|
541
|
+
schemaOptions: RouteSchemaOptions;
|
|
542
|
+
/**
|
|
543
|
+
* What to do when a request contains fields the caller can't write.
|
|
544
|
+
* Default: `'reject'` — surface the misconfiguration as a 403.
|
|
545
|
+
*/
|
|
546
|
+
onFieldWriteDenied?: FieldWriteDenialPolicy;
|
|
468
547
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
page?: number;
|
|
482
|
-
after?: string;
|
|
483
|
-
select?: string | string[] | Record<string, 0 | 1>;
|
|
484
|
-
[key: string]: unknown;
|
|
548
|
+
declare class BodySanitizer {
|
|
549
|
+
private schemaOptions;
|
|
550
|
+
private onFieldWriteDenied;
|
|
551
|
+
constructor(config: BodySanitizerConfig);
|
|
552
|
+
/**
|
|
553
|
+
* Strip readonly and system-managed fields from request body.
|
|
554
|
+
* Prevents clients from overwriting _id, timestamps, __v, etc.
|
|
555
|
+
*
|
|
556
|
+
* Also applies field-level write permissions when the request has
|
|
557
|
+
* field permission metadata.
|
|
558
|
+
*/
|
|
559
|
+
sanitize(body: AnyRecord, _operation: "create" | "update", req?: IRequestContext, meta?: ArcInternalMetadata): AnyRecord;
|
|
485
560
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
};
|
|
561
|
+
//#endregion
|
|
562
|
+
//#region src/core/QueryResolver.d.ts
|
|
563
|
+
interface QueryResolverConfig {
|
|
564
|
+
/** Query parser instance (default: Arc built-in parser) */
|
|
565
|
+
queryParser?: QueryParserInterface;
|
|
566
|
+
/** Maximum limit for pagination (default: 100) */
|
|
567
|
+
maxLimit?: number;
|
|
568
|
+
/** Default limit for pagination (default: 20) */
|
|
569
|
+
defaultLimit?: number;
|
|
503
570
|
/**
|
|
504
|
-
*
|
|
505
|
-
* `
|
|
506
|
-
*
|
|
571
|
+
* Default sort applied when the request doesn't specify one.
|
|
572
|
+
* - `string` — e.g. `'-createdAt'` (Mongo convention: leading `-` = DESC).
|
|
573
|
+
* - `false` — disable the default; resolved query has no `sort` clause.
|
|
574
|
+
* Use for SQL kits without a `createdAt` column.
|
|
575
|
+
* Defaults to `'-createdAt'` for back-compat with mongokit consumers.
|
|
507
576
|
*/
|
|
508
|
-
|
|
577
|
+
defaultSort?: string | false;
|
|
578
|
+
/** Schema options for field sanitization */
|
|
579
|
+
schemaOptions?: RouteSchemaOptions;
|
|
580
|
+
/** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable. */
|
|
581
|
+
tenantField?: string | false;
|
|
582
|
+
}
|
|
583
|
+
declare class QueryResolver {
|
|
584
|
+
private queryParser;
|
|
585
|
+
private maxLimit;
|
|
586
|
+
private defaultLimit;
|
|
587
|
+
/** `undefined` means "no default sort" (caller passed `false`). */
|
|
588
|
+
private defaultSort;
|
|
589
|
+
private schemaOptions;
|
|
590
|
+
private tenantField;
|
|
591
|
+
constructor(config?: QueryResolverConfig);
|
|
509
592
|
/**
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
* 'gt', 'gte', 'lt', 'lte', 'in', 'nin', etc.
|
|
593
|
+
* Resolve a request into parsed query options -- ONE parse per request.
|
|
594
|
+
* Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
|
|
513
595
|
*/
|
|
514
|
-
|
|
596
|
+
resolve(req: IRequestContext, meta?: ArcInternalMetadata): ControllerQueryOptions;
|
|
515
597
|
/**
|
|
516
|
-
*
|
|
517
|
-
*
|
|
598
|
+
* Sanitize select — preserves the input format (string, array, or object).
|
|
599
|
+
* This is critical for db-agnostic support: MongoKit returns object projections,
|
|
600
|
+
* Mongoose uses space-separated strings, SQL adapters may use arrays.
|
|
518
601
|
*/
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
/**
|
|
531
|
-
|
|
532
|
-
/** Relations to populate */
|
|
533
|
-
populate?: string | string[];
|
|
534
|
-
/** Return plain objects */
|
|
535
|
-
lean?: boolean;
|
|
602
|
+
private sanitizeSelectAny;
|
|
603
|
+
/** Sanitize populate fields */
|
|
604
|
+
private sanitizePopulate;
|
|
605
|
+
/** Sanitize advanced populate options against allowedPopulate */
|
|
606
|
+
private sanitizePopulateOptions;
|
|
607
|
+
/**
|
|
608
|
+
* Sanitize lookup/join options.
|
|
609
|
+
* If schemaOptions.query.allowedLookups is set, only those collections are allowed.
|
|
610
|
+
* Validates lookup structure to prevent injection.
|
|
611
|
+
*/
|
|
612
|
+
private sanitizeLookups;
|
|
613
|
+
/** Get blocked fields from schema options */
|
|
614
|
+
private getBlockedFields;
|
|
536
615
|
}
|
|
537
616
|
//#endregion
|
|
538
|
-
//#region src/
|
|
617
|
+
//#region src/core/BaseCrudController.d.ts
|
|
539
618
|
/**
|
|
540
|
-
*
|
|
541
|
-
*
|
|
619
|
+
* Union of every return shape repo-core's `MinimalRepo.getAll()` is
|
|
620
|
+
* contractually allowed to produce. See repo-core's `MinimalRepo.getAll`
|
|
621
|
+
* docstring for the three-way split:
|
|
622
|
+
*
|
|
623
|
+
* - `OffsetPaginationResult<TDoc>` — `page` param drives pagination.
|
|
624
|
+
* - `KeysetPaginationResult<TDoc>` — `sort` + optional `after` drives pagination.
|
|
625
|
+
* - `TDoc[]` — raw array when neither drives pagination.
|
|
626
|
+
*
|
|
627
|
+
* Arc passes the kit's response verbatim; consumers narrow on shape.
|
|
542
628
|
*/
|
|
543
|
-
|
|
544
|
-
/** Resource name being accessed */
|
|
545
|
-
resource: string;
|
|
546
|
-
/** CRUD operation being performed */
|
|
547
|
-
operation: "list" | "get" | "create" | "update" | "delete" | string;
|
|
548
|
-
}
|
|
629
|
+
type ListResult<TDoc> = OffsetPaginationResult<TDoc> | KeysetPaginationResult<TDoc> | TDoc[];
|
|
549
630
|
/**
|
|
550
|
-
*
|
|
551
|
-
*
|
|
631
|
+
* Controller-shape surface that the `Arc*Result` utilities read return
|
|
632
|
+
* types from. Internal — exported so the utility types can reference
|
|
633
|
+
* the minimal shape without a circular dependency on the full
|
|
634
|
+
* `BaseCrudController` / `BaseController` declarations.
|
|
552
635
|
*/
|
|
553
|
-
type
|
|
636
|
+
type ArcControllerLike = {
|
|
637
|
+
list: (...args: any[]) => unknown;
|
|
638
|
+
get: (...args: any[]) => unknown;
|
|
639
|
+
create: (...args: any[]) => unknown;
|
|
640
|
+
update: (...args: any[]) => unknown;
|
|
641
|
+
delete: (...args: any[]) => unknown;
|
|
642
|
+
};
|
|
554
643
|
/**
|
|
555
|
-
*
|
|
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.
|
|
644
|
+
* Return type of the controller's `list` method.
|
|
606
645
|
*
|
|
646
|
+
* @example
|
|
607
647
|
* ```ts
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
648
|
+
* class ProductController extends BaseController<Product> {
|
|
649
|
+
* async list(ctx: IRequestContext): ArcListResult<this> {
|
|
650
|
+
* // return shape inferred from BaseController.list — no need to
|
|
651
|
+
* // restate `Promise<IControllerResponse<ListResult<Product>>>`
|
|
652
|
+
* return super.list(ctx);
|
|
653
|
+
* }
|
|
654
|
+
* }
|
|
614
655
|
* ```
|
|
615
656
|
*/
|
|
616
|
-
type
|
|
617
|
-
|
|
657
|
+
type ArcListResult<TCtrl extends ArcControllerLike> = ReturnType<TCtrl["list"]>;
|
|
658
|
+
/** Return type of the controller's `get` method. See {@link ArcListResult}. */
|
|
659
|
+
type ArcGetResult<TCtrl extends ArcControllerLike> = ReturnType<TCtrl["get"]>;
|
|
660
|
+
/** Return type of the controller's `create` method. See {@link ArcListResult}. */
|
|
661
|
+
type ArcCreateResult<TCtrl extends ArcControllerLike> = ReturnType<TCtrl["create"]>;
|
|
662
|
+
/** Return type of the controller's `update` method. See {@link ArcListResult}. */
|
|
663
|
+
type ArcUpdateResult<TCtrl extends ArcControllerLike> = ReturnType<TCtrl["update"]>;
|
|
664
|
+
/** Return type of the controller's `delete` method. See {@link ArcListResult}. */
|
|
665
|
+
type ArcDeleteResult<TCtrl extends ArcControllerLike> = ReturnType<TCtrl["delete"]>;
|
|
666
|
+
interface BaseControllerOptions {
|
|
667
|
+
/** Schema options for field sanitization */
|
|
668
|
+
schemaOptions?: RouteSchemaOptions;
|
|
618
669
|
/**
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
* methods at runtime — kits only declare what they support.
|
|
670
|
+
* Query parser instance.
|
|
671
|
+
* Default: Arc built-in query parser (adapter-agnostic).
|
|
672
|
+
* Swap in MongoKit QueryParser, pgkit parser, etc.
|
|
623
673
|
*/
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
|
|
674
|
+
queryParser?: QueryParserInterface;
|
|
675
|
+
/** Maximum limit for pagination (default: 100) */
|
|
676
|
+
maxLimit?: number;
|
|
677
|
+
/** Default limit for pagination (default: 20) */
|
|
678
|
+
defaultLimit?: number;
|
|
629
679
|
/**
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
* `
|
|
633
|
-
*
|
|
634
|
-
* @param options - Schema generation options (field rules, populate hints)
|
|
635
|
-
* @param context - Resource-level context (idField for params schema, name for logs).
|
|
636
|
-
* Adapters should honor `context.idField` when producing the params
|
|
637
|
-
* schema (e.g., skip the ObjectId pattern when idField is a custom
|
|
638
|
-
* string field).
|
|
680
|
+
* Default sort applied when the request doesn't specify one.
|
|
681
|
+
* - `string` (default: `'-createdAt'`) — Mongo `-field` DESC convention.
|
|
682
|
+
* - `false` — disable the default sort entirely (SQL/Drizzle resources
|
|
683
|
+
* without a `createdAt` column).
|
|
639
684
|
*/
|
|
640
|
-
|
|
641
|
-
/**
|
|
642
|
-
|
|
643
|
-
/** Validate data against schema before persistence. */
|
|
644
|
-
validate?(data: unknown): Promise<ValidationResult$1> | ValidationResult$1;
|
|
645
|
-
/** Health check for database connection. */
|
|
646
|
-
healthCheck?(): Promise<boolean>;
|
|
685
|
+
defaultSort?: string | false;
|
|
686
|
+
/** Resource name for hook execution (e.g., 'product' -> 'product.created') */
|
|
687
|
+
resourceName?: string;
|
|
647
688
|
/**
|
|
648
|
-
*
|
|
649
|
-
* to
|
|
650
|
-
*
|
|
689
|
+
* Field name used for multi-tenant scoping (default: 'organizationId').
|
|
690
|
+
* Override to match your schema: 'workspaceId', 'tenantId', 'teamId', etc.
|
|
691
|
+
* Set to `false` to disable org filtering for platform-universal resources.
|
|
692
|
+
*/
|
|
693
|
+
tenantField?: string | false;
|
|
694
|
+
/**
|
|
695
|
+
* Primary key field name (default: '_id'). Auto-derives from the repo's
|
|
696
|
+
* own `idField` when unset.
|
|
697
|
+
*/
|
|
698
|
+
idField?: string;
|
|
699
|
+
/**
|
|
700
|
+
* Custom filter matching for policy enforcement.
|
|
701
|
+
* Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
|
|
651
702
|
*/
|
|
652
703
|
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
653
|
-
/**
|
|
654
|
-
|
|
704
|
+
/** Cache configuration for the resource */
|
|
705
|
+
cache?: ResourceCacheConfig;
|
|
706
|
+
/** Internal preset fields map (slug, tree, etc.) */
|
|
707
|
+
presetFields?: {
|
|
708
|
+
slugField?: string;
|
|
709
|
+
parentField?: string;
|
|
710
|
+
};
|
|
711
|
+
/**
|
|
712
|
+
* Policy for requests that include fields the caller can't write.
|
|
713
|
+
* - `'reject'` (default): 403 with denied field names.
|
|
714
|
+
* - `'strip'`: legacy silent-drop.
|
|
715
|
+
*/
|
|
716
|
+
onFieldWriteDenied?: FieldWriteDenialPolicy;
|
|
655
717
|
}
|
|
656
718
|
/**
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
719
|
+
* Framework-agnostic CRUD controller implementing IController.
|
|
720
|
+
*
|
|
721
|
+
* Composes AccessControl, BodySanitizer, and QueryResolver. All shared
|
|
722
|
+
* state and helpers are `protected` so the preset mixins (SoftDelete,
|
|
723
|
+
* Tree, Slug, Bulk) can extend cleanly.
|
|
724
|
+
*
|
|
725
|
+
* @template TDoc - The document type.
|
|
726
|
+
* @template TRepository - The repository type (defaults to RepositoryLike).
|
|
660
727
|
*/
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
728
|
+
declare class BaseCrudController<TDoc = AnyRecord, TRepository extends RepositoryLike = RepositoryLike> implements IController<TDoc> {
|
|
729
|
+
protected repository: TRepository;
|
|
730
|
+
protected schemaOptions: RouteSchemaOptions;
|
|
731
|
+
protected queryParser: QueryParserInterface;
|
|
732
|
+
protected maxLimit: number;
|
|
733
|
+
protected defaultLimit: number;
|
|
734
|
+
/** `undefined` means "no default sort" (caller passed `false`). */
|
|
735
|
+
protected defaultSort: string | undefined;
|
|
736
|
+
protected resourceName?: string;
|
|
737
|
+
protected tenantField: string | false;
|
|
738
|
+
protected idField: string;
|
|
739
|
+
/** Composable access control (ID filtering, policy checks, org scope, ownership) */
|
|
740
|
+
readonly accessControl: AccessControl;
|
|
741
|
+
/** Composable body sanitization (field permissions, system fields) */
|
|
742
|
+
readonly bodySanitizer: BodySanitizer;
|
|
743
|
+
/**
|
|
744
|
+
* Composable query resolution (parsing, pagination, sort, select/populate).
|
|
745
|
+
*
|
|
746
|
+
* Not `readonly` — `setQueryParser()` rebuilds this resolver to swap in a
|
|
747
|
+
* different parser (e.g. mongokit's `QueryParser`). `defineResource` calls
|
|
748
|
+
* it automatically when a resource supplies both `controller` and
|
|
749
|
+
* `queryParser`.
|
|
750
|
+
*/
|
|
751
|
+
queryResolver: QueryResolver;
|
|
752
|
+
protected _matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
753
|
+
protected _presetFields: {
|
|
754
|
+
slugField?: string;
|
|
755
|
+
parentField?: string;
|
|
756
|
+
};
|
|
757
|
+
protected _cacheConfig?: ResourceCacheConfig;
|
|
758
|
+
constructor(repository: TRepository, options?: BaseControllerOptions);
|
|
759
|
+
/**
|
|
760
|
+
* Swap the controller's query parser. Rebuilds the internal `QueryResolver`
|
|
761
|
+
* with the new parser while preserving every other config.
|
|
762
|
+
*
|
|
763
|
+
* Closes the v2.10.9 gap where `defineResource({ controller, queryParser })`
|
|
764
|
+
* forwarded the parser only to auto-constructed controllers; user-supplied
|
|
765
|
+
* controllers kept their default `ArcQueryParser`. `defineResource` calls
|
|
766
|
+
* this via duck-typing when both `controller` and `queryParser` are
|
|
767
|
+
* supplied — controllers that don't implement `setQueryParser` are left
|
|
768
|
+
* untouched.
|
|
769
|
+
*
|
|
770
|
+
* Idempotent + safe to call repeatedly. Does NOT touch `maxLimit` or
|
|
771
|
+
* `defaultLimit` — those are construction-time decisions.
|
|
772
|
+
*/
|
|
773
|
+
setQueryParser(queryParser: QueryParserInterface): void;
|
|
774
|
+
/**
|
|
775
|
+
* Get the tenant field name if multi-tenant scoping is enabled.
|
|
776
|
+
* Returns `undefined` when `tenantField` is `false`.
|
|
777
|
+
*/
|
|
778
|
+
protected getTenantField(): string | undefined;
|
|
779
|
+
/**
|
|
780
|
+
* Build top-level tenant options to thread into the repository call.
|
|
781
|
+
*
|
|
782
|
+
* Plugin-scoped repos (mongokit's `multiTenantPlugin`) read tenant scope
|
|
783
|
+
* from the TOP of the operation context — `context.organizationId`, not
|
|
784
|
+
* `context.data.organizationId`. Without this stamping, a tenant-scoped
|
|
785
|
+
* repo throws "Missing 'organizationId' in context" even when arc has
|
|
786
|
+
* injected the tenant into the request body.
|
|
787
|
+
*
|
|
788
|
+
* Returns `{ [tenantField]: orgId }` for tenant-scoped + org-carrying
|
|
789
|
+
* requests, `{}` otherwise. Merges multi-field tenancy from
|
|
790
|
+
* `_tenantFields` (populated by `multiTenantPreset`).
|
|
791
|
+
*/
|
|
792
|
+
protected tenantRepoOptions(req: IRequestContext): AnyRecord;
|
|
793
|
+
/** Extract typed Arc internal metadata from request */
|
|
794
|
+
protected meta(req: IRequestContext): ArcInternalMetadata | undefined;
|
|
795
|
+
/** Get hook system from request context (instance-scoped) */
|
|
796
|
+
protected getHooks(req: IRequestContext): HookSystem | null;
|
|
797
|
+
/**
|
|
798
|
+
* Resolve the repository primary key for mutation calls.
|
|
799
|
+
*
|
|
800
|
+
* When the resource declares a custom `idField` (slug, jobId, UUID), the
|
|
801
|
+
* default behavior is to translate the route id → the fetched doc's `_id`
|
|
802
|
+
* because most Mongo repositories key mutation methods off `_id`.
|
|
803
|
+
*
|
|
804
|
+
* Exception: if the repo exposes a matching `idField` property (e.g.
|
|
805
|
+
* MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
|
|
806
|
+
* repo handles lookup itself — pass the route id through unchanged.
|
|
807
|
+
*/
|
|
808
|
+
protected resolveRepoId(id: string, existing: AnyRecord | null): string;
|
|
809
|
+
/**
|
|
810
|
+
* Centralized 404 response builder. Maps the denial reason from
|
|
811
|
+
* `fetchDetailed()` into a structured `details.code` so consumers can
|
|
812
|
+
* distinguish "doc doesn't exist" from "doc filtered by policy/org scope"
|
|
813
|
+
* without parsing error strings.
|
|
814
|
+
*/
|
|
815
|
+
protected notFoundResponse(reason?: FetchDenialReason | null): IControllerResponse<never>;
|
|
816
|
+
/** Resolve cache config for a specific operation, merging per-op overrides */
|
|
817
|
+
protected resolveCacheConfig(operation: "list" | "byId"): QueryCacheConfig | null;
|
|
818
|
+
/**
|
|
819
|
+
* Extract user/org IDs from request for cache key scoping.
|
|
820
|
+
* Only includes orgId when the resource uses tenant-scoped data (tenantField is set).
|
|
821
|
+
* Universal resources (tenantField: false) get shared cache keys.
|
|
822
|
+
*/
|
|
823
|
+
protected cacheScope(req: IRequestContext): {
|
|
824
|
+
userId?: string;
|
|
825
|
+
orgId?: string;
|
|
826
|
+
};
|
|
827
|
+
list(req: IRequestContext): Promise<IControllerResponse<ListResult<TDoc>>>;
|
|
828
|
+
/** Execute list query through hooks (extracted for cache revalidation) */
|
|
829
|
+
protected executeListQuery(options: ParsedQuery, req: IRequestContext): Promise<ListResult<TDoc>>;
|
|
830
|
+
get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
831
|
+
/** Execute get query through hooks (extracted for cache revalidation) */
|
|
832
|
+
protected executeGetQuery(id: string, options: ParsedQuery, req: IRequestContext): Promise<{
|
|
833
|
+
doc: TDoc | null;
|
|
834
|
+
reason: FetchDenialReason | null;
|
|
674
835
|
}>;
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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;
|
|
836
|
+
create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
837
|
+
update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
838
|
+
delete(req: IRequestContext): Promise<IControllerResponse<{
|
|
839
|
+
message: string;
|
|
840
|
+
id?: string;
|
|
841
|
+
soft?: boolean;
|
|
842
|
+
}>>;
|
|
691
843
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
844
|
+
//#endregion
|
|
845
|
+
//#region src/core/mixins/bulk.d.ts
|
|
846
|
+
type Constructor$3<T> = new (...args: any[]) => T;
|
|
847
|
+
/** Public surface contributed by BulkMixin. */
|
|
848
|
+
interface BulkExt {
|
|
849
|
+
bulkCreate(req: IRequestContext): Promise<IControllerResponse<AnyRecord[]>>;
|
|
850
|
+
bulkUpdate(req: IRequestContext): Promise<IControllerResponse<{
|
|
851
|
+
matchedCount: number;
|
|
852
|
+
modifiedCount: number;
|
|
853
|
+
}>>;
|
|
854
|
+
bulkDelete(req: IRequestContext): Promise<IControllerResponse<{
|
|
855
|
+
deletedCount: number;
|
|
856
|
+
}>>;
|
|
697
857
|
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
858
|
+
declare function BulkMixin<TBase extends Constructor$3<BaseCrudController>>(Base: TBase): TBase & Constructor$3<BulkExt>;
|
|
859
|
+
//#endregion
|
|
860
|
+
//#region src/core/mixins/slug.d.ts
|
|
861
|
+
type Constructor$2<T> = new (...args: any[]) => T;
|
|
862
|
+
/** Public surface contributed by SlugMixin. */
|
|
863
|
+
interface SlugExt {
|
|
864
|
+
getBySlug(req: IRequestContext): Promise<IControllerResponse<AnyRecord>>;
|
|
705
865
|
}
|
|
706
|
-
|
|
866
|
+
declare function SlugMixin<TBase extends Constructor$2<BaseCrudController>>(Base: TBase): TBase & Constructor$2<SlugExt>;
|
|
707
867
|
//#endregion
|
|
708
|
-
//#region src/
|
|
868
|
+
//#region src/core/mixins/tree.d.ts
|
|
869
|
+
type Constructor$1<T> = new (...args: any[]) => T;
|
|
870
|
+
/** Public surface contributed by TreeMixin. */
|
|
871
|
+
interface TreeExt {
|
|
872
|
+
getTree(req: IRequestContext): Promise<IControllerResponse<AnyRecord[]>>;
|
|
873
|
+
getChildren(req: IRequestContext): Promise<IControllerResponse<AnyRecord[]>>;
|
|
874
|
+
}
|
|
875
|
+
declare function TreeMixin<TBase extends Constructor$1<BaseCrudController>>(Base: TBase): TBase & Constructor$1<TreeExt>;
|
|
876
|
+
//#endregion
|
|
877
|
+
//#region src/core/mixins/softDelete.d.ts
|
|
878
|
+
type Constructor<T> = new (...args: any[]) => T;
|
|
879
|
+
/** Public surface contributed by SoftDeleteMixin. */
|
|
880
|
+
interface SoftDeleteExt {
|
|
881
|
+
getDeleted(req: IRequestContext): Promise<IControllerResponse<PaginationResult<AnyRecord>>>;
|
|
882
|
+
restore(req: IRequestContext): Promise<IControllerResponse<AnyRecord>>;
|
|
883
|
+
}
|
|
884
|
+
declare function SoftDeleteMixin<TBase extends Constructor<BaseCrudController>>(Base: TBase): TBase & Constructor<SoftDeleteExt>;
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/core/BaseController.d.ts
|
|
709
887
|
/**
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
888
|
+
* Fully-composed controller shape: all CRUD methods + every preset method
|
|
889
|
+
* (SoftDelete / Tree / Slug / Bulk) typed over the caller-supplied `TDoc`.
|
|
890
|
+
*
|
|
891
|
+
* **Inheritance summary** (what hover-docs should show when you reach for
|
|
892
|
+
* a method):
|
|
893
|
+
*
|
|
894
|
+
* `BaseController<TDoc>`
|
|
895
|
+
* └─ composes: `BaseCrudController` → `BulkMixin` → `SlugMixin` → `TreeMixin` → `SoftDeleteMixin`
|
|
896
|
+
*
|
|
897
|
+
* - From `BaseCrudController`: `list`, `get`, `create`, `update`, `delete`
|
|
898
|
+
* - From `BulkMixin`: `bulkCreate`, `bulkUpdate`, `bulkDelete`
|
|
899
|
+
* - From `SlugMixin`: `getBySlug`
|
|
900
|
+
* - From `TreeMixin`: `getTree`, `getChildren`
|
|
901
|
+
* - From `SoftDeleteMixin`: `getDeleted`, `restore`
|
|
902
|
+
*
|
|
903
|
+
* Hosts that only need CRUD extend `BaseCrudController` directly for a
|
|
904
|
+
* smaller surface. Hosts that want specific mixins compose them by hand
|
|
905
|
+
* (e.g. `class X extends SoftDeleteMixin(BaseCrudController<Doc>)`).
|
|
906
|
+
*
|
|
907
|
+
* ──────────────────────────────────────────────────────────────────────────
|
|
908
|
+
* ADR — why declaration merging + why `TDoc` carries `extends AnyRecord`
|
|
909
|
+
* ──────────────────────────────────────────────────────────────────────────
|
|
910
|
+
* The natural reflex is:
|
|
911
|
+
*
|
|
912
|
+
* class BaseController<TDoc> extends SoftDelete(Tree(Slug(Bulk(BaseCrud<TDoc>)))) {}
|
|
913
|
+
*
|
|
914
|
+
* TypeScript rejects this. Mixin factories can't receive a generic type
|
|
915
|
+
* parameter from the derived class (TS4.0 added limited support, but
|
|
916
|
+
* chained mixins over 4 levels deep still break because each factory
|
|
917
|
+
* would need its own TDoc binding — TS can't infer a shared `TDoc` across
|
|
918
|
+
* the chain without losing the base `extends Constructor<Base>` constraint).
|
|
919
|
+
*
|
|
920
|
+
* The composed runtime pins `BaseCrudController` to `AnyRecord` at the
|
|
921
|
+
* mixin-chain base; the TYPE surface hosts interact with threads `TDoc` so
|
|
922
|
+
* `new BaseController<Product>().list(req)` returns
|
|
923
|
+
* `Promise<IControllerResponse<ListResult<Product>>>` and not
|
|
924
|
+
* `ListResult<AnyRecord>`. Declaration merging bridges the two.
|
|
925
|
+
*
|
|
926
|
+
* **Why `TDoc extends AnyRecord` IS load-bearing:** the derived class
|
|
927
|
+
* inherits the mixin-composed base which is pinned to `AnyRecord`.
|
|
928
|
+
* Inherited methods return `ListResult<AnyRecord>` and the derived
|
|
929
|
+
* interface returns `ListResult<TDoc>`. For TS's "derived is assignable
|
|
930
|
+
* to base" check to pass, `TDoc` must be assignable to `AnyRecord` —
|
|
931
|
+
* which requires the `extends AnyRecord` bound. Dropping the bound fails
|
|
932
|
+
* with `Type 'TDoc[]' is not assignable to type 'AnyRecord[]'` at the
|
|
933
|
+
* class declaration. Hosts therefore need one of:
|
|
934
|
+
* (a) `class X extends BaseController { ... }` — drop the generic,
|
|
935
|
+
* lose return-type narrowing for `list` / `get` / etc.
|
|
936
|
+
* (b) `interface IUser extends AnyRecord { ... }` — add an index
|
|
937
|
+
* signature to the domain interface (preferred when you own it).
|
|
938
|
+
* (c) Use the utility types (`ArcListResult<typeof this>`,
|
|
939
|
+
* `ArcCreateResult<typeof this>`) when overriding methods — they
|
|
940
|
+
* read the return type from the class, sidestepping the bound
|
|
941
|
+
* for method bodies even when `TDoc` is unbound elsewhere.
|
|
942
|
+
*
|
|
943
|
+
* **Why `TRepository` defaults to `RepositoryLike<TDoc>`:** keeps
|
|
944
|
+
* diagnostics symmetric. With the older `RepositoryLike = RepositoryLike<unknown>`
|
|
945
|
+
* default, error messages mixed `AnyRecord` and `unknown` in the same
|
|
946
|
+
* signature, which confused the reader about where the mismatch was.
|
|
947
|
+
*
|
|
948
|
+
* **Cost this pays:** every method that participates in the `TDoc`
|
|
949
|
+
* narrowing is redeclared on the interface. Adding a new method to a
|
|
950
|
+
* mixin means updating this interface too. The alternative (losing
|
|
951
|
+
* host-side generics OR rewriting mixins as a non-generic union) is
|
|
952
|
+
* strictly worse.
|
|
953
|
+
*
|
|
954
|
+
* Spec reference: https://www.typescriptlang.org/docs/handbook/declaration-merging.html#merging-classes-with-other-types
|
|
713
955
|
*/
|
|
714
|
-
interface
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
gcTime?: number;
|
|
742
|
-
tags?: string[];
|
|
743
|
-
}) => Promise<void>;
|
|
744
|
-
getResourceVersion: (resource: string) => Promise<number>;
|
|
745
|
-
bumpResourceVersion: (resource: string) => Promise<void>;
|
|
746
|
-
};
|
|
956
|
+
interface BaseController<TDoc extends AnyRecord = AnyRecord, TRepository extends RepositoryLike = RepositoryLike<TDoc>> {
|
|
957
|
+
readonly accessControl: AccessControl;
|
|
958
|
+
readonly bodySanitizer: BodySanitizer;
|
|
959
|
+
queryResolver: QueryResolver;
|
|
960
|
+
setQueryParser(queryParser: QueryParserInterface): void;
|
|
961
|
+
list(req: IRequestContext): Promise<IControllerResponse<ListResult<TDoc>>>;
|
|
962
|
+
get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
963
|
+
create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
964
|
+
update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
965
|
+
delete(req: IRequestContext): Promise<IControllerResponse<{
|
|
966
|
+
message: string;
|
|
967
|
+
id?: string;
|
|
968
|
+
soft?: boolean;
|
|
969
|
+
}>>;
|
|
970
|
+
getDeleted(req: IRequestContext): Promise<IControllerResponse<PaginationResult<TDoc>>>;
|
|
971
|
+
restore(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
972
|
+
getTree(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
973
|
+
getChildren(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
974
|
+
getBySlug(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
975
|
+
bulkCreate(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
|
|
976
|
+
bulkUpdate(req: IRequestContext): Promise<IControllerResponse<{
|
|
977
|
+
matchedCount: number;
|
|
978
|
+
modifiedCount: number;
|
|
979
|
+
}>>;
|
|
980
|
+
bulkDelete(req: IRequestContext): Promise<IControllerResponse<{
|
|
981
|
+
deletedCount: number;
|
|
982
|
+
}>>;
|
|
747
983
|
}
|
|
984
|
+
declare const BaseController_base: (new (repository: any, options?: BaseControllerOptions) => BaseCrudController<AnyRecord, any>) & (new (...args: any[]) => BulkExt) & (new (...args: any[]) => SlugExt) & (new (...args: any[]) => TreeExt) & (new (...args: any[]) => SoftDeleteExt);
|
|
748
985
|
/**
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
*
|
|
752
|
-
*
|
|
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
|
-
* ```
|
|
986
|
+
* Fully-composed controller: `BaseCrudController` + SoftDelete + Tree +
|
|
987
|
+
* Slug + Bulk. Drop-in replacement for the pre-2.11 god class. The
|
|
988
|
+
* companion interface above gives every method full generic precision
|
|
989
|
+
* on `TDoc` via declaration merging.
|
|
787
990
|
*/
|
|
788
|
-
|
|
789
|
-
|
|
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;
|
|
803
|
-
/**
|
|
804
|
-
* Organization/auth context from middleware.
|
|
805
|
-
* Contains orgRoles, orgScope, organizationId, and any custom fields
|
|
806
|
-
* set by the auth adapter or org-scope plugin.
|
|
807
|
-
*
|
|
808
|
-
* @example
|
|
809
|
-
* ```typescript
|
|
810
|
-
* async create(req: IRequestContext) {
|
|
811
|
-
* const roles = req.context?.orgRoles ?? [];
|
|
812
|
-
* if (roles.includes('manager')) { ... }
|
|
813
|
-
* }
|
|
814
|
-
* ```
|
|
815
|
-
*/
|
|
816
|
-
context?: RequestContext;
|
|
817
|
-
/**
|
|
818
|
-
* Internal metadata (includes context + Arc internals like `_policyFilters`,
|
|
819
|
-
* `_scope`, `log`). Type as `ArcInternalMetadata` for typed access to Arc's
|
|
820
|
-
* built-in fields, or supply your own interface to layer custom fields.
|
|
821
|
-
*/
|
|
822
|
-
metadata?: TMetadata;
|
|
823
|
-
/**
|
|
824
|
-
* Fastify server accessor — publish events, log, and audit
|
|
825
|
-
* from any handler without switching to `raw: true`.
|
|
826
|
-
*
|
|
827
|
-
* @example
|
|
828
|
-
* ```typescript
|
|
829
|
-
* async reschedule(req: IRequestContext) {
|
|
830
|
-
* const result = await repo.reschedule(req.params.id, req.body);
|
|
831
|
-
* await req.server?.events?.publish('interview.rescheduled', { data: result });
|
|
832
|
-
* return { success: true, data: result };
|
|
833
|
-
* }
|
|
834
|
-
* ```
|
|
835
|
-
*/
|
|
836
|
-
server?: ServerAccessor;
|
|
991
|
+
declare class BaseController<TDoc extends AnyRecord = AnyRecord, TRepository extends RepositoryLike = RepositoryLike<TDoc>> extends BaseController_base {
|
|
992
|
+
readonly _phantom?: [TDoc, TRepository];
|
|
837
993
|
}
|
|
994
|
+
//#endregion
|
|
995
|
+
//#region src/types/base.d.ts
|
|
996
|
+
declare module "fastify" {
|
|
997
|
+
interface FastifyRequest {
|
|
998
|
+
/** Request scope — set by auth adapter, read by permissions/presets/guards */
|
|
999
|
+
scope: RequestScope;
|
|
1000
|
+
/**
|
|
1001
|
+
* Current user — set by auth adapter (Better Auth, JWT, custom).
|
|
1002
|
+
* `undefined` on public routes (`auth: false`) or unauthenticated requests.
|
|
1003
|
+
* Guard with `if (request.user)` on routes that allow anonymous access.
|
|
1004
|
+
*
|
|
1005
|
+
* Kept as required (not `user?`) because `@fastify/jwt` declares it
|
|
1006
|
+
* as required — declaration merges must have identical modifiers.
|
|
1007
|
+
* The `| undefined` in the type achieves the same DX.
|
|
1008
|
+
*/
|
|
1009
|
+
user: Record<string, unknown> | undefined;
|
|
1010
|
+
/** Policy-injected query filters (e.g. ownership, org-scoping) */
|
|
1011
|
+
_policyFilters?: Record<string, unknown>;
|
|
1012
|
+
/** Field mask — fields to include/exclude in responses */
|
|
1013
|
+
fieldMask?: {
|
|
1014
|
+
include?: string[];
|
|
1015
|
+
exclude?: string[];
|
|
1016
|
+
};
|
|
1017
|
+
/** Arbitrary policy metadata for downstream consumers */
|
|
1018
|
+
policyMetadata?: Record<string, unknown>;
|
|
1019
|
+
/** Document loaded by policy middleware for ownership checks */
|
|
1020
|
+
document?: unknown;
|
|
1021
|
+
/** Ownership check context (field name + user field) */
|
|
1022
|
+
_ownershipCheck?: Record<string, unknown>;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
type AnyRecord = Record<string, unknown>;
|
|
1026
|
+
/** MongoDB ObjectId — accepts string or any object with a `toString()` (e.g. mongoose ObjectId). */
|
|
1027
|
+
type ObjectId = string | {
|
|
1028
|
+
toString(): string;
|
|
1029
|
+
};
|
|
838
1030
|
/**
|
|
839
|
-
*
|
|
1031
|
+
* Flexible user type that accepts any object with id/_id properties.
|
|
1032
|
+
* The actual user structure is defined by your app's auth system.
|
|
840
1033
|
*/
|
|
841
|
-
|
|
842
|
-
/**
|
|
1034
|
+
type UserLike = UserBase & {
|
|
1035
|
+
/** User email (optional) */email?: string;
|
|
1036
|
+
};
|
|
1037
|
+
interface UserOrganization {
|
|
1038
|
+
userId: string;
|
|
1039
|
+
organizationId: string;
|
|
1040
|
+
[key: string]: unknown;
|
|
1041
|
+
}
|
|
1042
|
+
interface JWTPayload {
|
|
1043
|
+
sub: string;
|
|
1044
|
+
[key: string]: unknown;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Standard API response envelope — `{ success, data?, error?, message?, meta? }`.
|
|
1048
|
+
* Used by Arc's default response shape.
|
|
1049
|
+
*/
|
|
1050
|
+
interface ApiResponse<T = unknown> {
|
|
843
1051
|
success: boolean;
|
|
844
|
-
/** Response data */
|
|
845
1052
|
data?: T;
|
|
846
|
-
/** Error message (when success is false) */
|
|
847
1053
|
error?: string;
|
|
848
|
-
|
|
849
|
-
status?: number;
|
|
850
|
-
/** Additional metadata */
|
|
1054
|
+
message?: string;
|
|
851
1055
|
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
1056
|
}
|
|
857
1057
|
/**
|
|
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.
|
|
1058
|
+
* Typed Fastify request with Arc decorations. Use in `raw: true` handlers
|
|
1059
|
+
* instead of `(req as any).user`.
|
|
872
1060
|
*
|
|
873
1061
|
* @example
|
|
874
1062
|
* ```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
|
-
* };
|
|
880
|
-
*
|
|
881
|
-
* // Fully typed — body, params, query, and response all inferred
|
|
882
|
-
* const updateProduct: ControllerHandler<
|
|
883
|
-
* Product,
|
|
884
|
-
* Partial<Product>,
|
|
885
|
-
* { id: string },
|
|
886
|
-
* { upsert?: string }
|
|
887
|
-
* > = async (req) => {
|
|
888
|
-
* const upsert = req.query.upsert === "true";
|
|
889
|
-
* const product = await productRepo.update(req.params.id, req.body, { upsert });
|
|
890
|
-
* return { success: true, data: product };
|
|
891
|
-
* };
|
|
1063
|
+
* import type { ArcRequest } from '@classytic/arc';
|
|
892
1064
|
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
896
|
-
*
|
|
897
|
-
*
|
|
898
|
-
* raw: false, // Arc wraps this into Fastify handler
|
|
899
|
-
* }]
|
|
1065
|
+
* handler: async (req: ArcRequest, reply) => {
|
|
1066
|
+
* req.user?.id; // typed
|
|
1067
|
+
* req.scope.organizationId; // typed (when member)
|
|
1068
|
+
* req.signal; // AbortSignal (Fastify 5)
|
|
1069
|
+
* }
|
|
900
1070
|
* ```
|
|
901
1071
|
*/
|
|
902
|
-
type
|
|
1072
|
+
type ArcRequest = FastifyRequest & {
|
|
1073
|
+
scope: RequestScope;
|
|
1074
|
+
user: Record<string, unknown> | undefined;
|
|
1075
|
+
signal: AbortSignal;
|
|
1076
|
+
};
|
|
1077
|
+
//#endregion
|
|
1078
|
+
//#region src/types/auth.d.ts
|
|
903
1079
|
/**
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
* Standard Fastify request/reply pattern.
|
|
907
|
-
* Use with `raw: true` in routes.
|
|
908
|
-
*
|
|
909
|
-
* @example
|
|
910
|
-
* ```typescript
|
|
911
|
-
* const downloadFile: FastifyHandler = async (request, reply) => {
|
|
912
|
-
* const file = await getFile(request.params.id);
|
|
913
|
-
* reply.header('Content-Type', file.mimeType);
|
|
914
|
-
* return reply.send(file.buffer);
|
|
915
|
-
* };
|
|
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
|
-
* }]
|
|
924
|
-
* ```
|
|
925
|
-
*/
|
|
926
|
-
type FastifyHandler<RouteGeneric extends Record<string, unknown> = Record<string, unknown>> = (request: FastifyRequest<RouteGeneric>, reply: FastifyReply) => Promise<unknown> | unknown;
|
|
927
|
-
/**
|
|
928
|
-
* Union type for route handlers
|
|
1080
|
+
* JWT utilities provided to authenticator. Arc provides the helpers;
|
|
1081
|
+
* apps use them as needed.
|
|
929
1082
|
*/
|
|
930
|
-
|
|
1083
|
+
interface JwtContext {
|
|
1084
|
+
/** Verify a JWT token and return decoded payload */
|
|
1085
|
+
verify: <T = Record<string, unknown>>(token: string) => T;
|
|
1086
|
+
/** Sign a payload and return JWT token */
|
|
1087
|
+
sign: (payload: Record<string, unknown>, options?: {
|
|
1088
|
+
expiresIn?: string;
|
|
1089
|
+
}) => string;
|
|
1090
|
+
/** Decode without verification (for inspection) */
|
|
1091
|
+
decode: <T = Record<string, unknown>>(token: string) => T | null;
|
|
1092
|
+
}
|
|
1093
|
+
/** Context passed to app's authenticator function. */
|
|
1094
|
+
interface AuthenticatorContext {
|
|
1095
|
+
/** JWT utilities (available if `jwt.secret` provided) */
|
|
1096
|
+
jwt: JwtContext | null;
|
|
1097
|
+
/** Fastify instance for advanced use cases */
|
|
1098
|
+
fastify: FastifyInstance;
|
|
1099
|
+
}
|
|
931
1100
|
/**
|
|
932
|
-
*
|
|
1101
|
+
* App-provided authenticator function. Arc calls this for every
|
|
1102
|
+
* non-public route. The app has full control over authentication logic.
|
|
1103
|
+
*
|
|
1104
|
+
* Return a user object to authenticate, `null`/`undefined` to reject.
|
|
1105
|
+
*
|
|
1106
|
+
* @example
|
|
1107
|
+
* ```typescript
|
|
1108
|
+
* authenticate: async (request, { jwt }) => {
|
|
1109
|
+
* const token = request.headers.authorization?.split(' ')[1];
|
|
1110
|
+
* if (!token || !jwt) return null;
|
|
1111
|
+
* const decoded = jwt.verify(token);
|
|
1112
|
+
* return userRepo.findById(decoded.id);
|
|
1113
|
+
* }
|
|
1114
|
+
* ```
|
|
933
1115
|
*/
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1116
|
+
type Authenticator = (request: FastifyRequest, context: AuthenticatorContext) => Promise<unknown | null> | unknown | null;
|
|
1117
|
+
/** Token pair returned by `issueTokens` helper. */
|
|
1118
|
+
interface TokenPair {
|
|
1119
|
+
/** Access token (JWT) */
|
|
1120
|
+
accessToken: string;
|
|
1121
|
+
/** Refresh token (JWT with longer expiry) */
|
|
1122
|
+
refreshToken?: string;
|
|
1123
|
+
/** Access token expiry in seconds */
|
|
1124
|
+
expiresIn: number;
|
|
1125
|
+
/** Refresh token expiry in seconds */
|
|
1126
|
+
refreshExpiresIn?: number;
|
|
1127
|
+
/** Token type (always 'Bearer') */
|
|
1128
|
+
tokenType: "Bearer";
|
|
945
1129
|
}
|
|
946
1130
|
/**
|
|
947
|
-
*
|
|
948
|
-
*
|
|
1131
|
+
* Auth helpers exposed on `fastify.auth`.
|
|
1132
|
+
*
|
|
1133
|
+
* @example
|
|
1134
|
+
* ```typescript
|
|
1135
|
+
* const tokens = fastify.auth.issueTokens({
|
|
1136
|
+
* id: user._id,
|
|
1137
|
+
* email: user.email,
|
|
1138
|
+
* role: user.role,
|
|
1139
|
+
* });
|
|
1140
|
+
* return { success: true, ...tokens, user };
|
|
1141
|
+
* ```
|
|
949
1142
|
*/
|
|
950
|
-
interface
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1143
|
+
interface AuthHelpers {
|
|
1144
|
+
/** JWT utilities (if configured) */
|
|
1145
|
+
jwt: JwtContext | null;
|
|
1146
|
+
/**
|
|
1147
|
+
* Issue access + refresh tokens for a user. App calls this after
|
|
1148
|
+
* validating credentials.
|
|
1149
|
+
*/
|
|
1150
|
+
issueTokens: (payload: Record<string, unknown>, options?: {
|
|
1151
|
+
expiresIn?: string;
|
|
1152
|
+
refreshExpiresIn?: string;
|
|
1153
|
+
}) => TokenPair;
|
|
1154
|
+
/** Verify a refresh token and return decoded payload. */
|
|
1155
|
+
verifyRefreshToken: <T = Record<string, unknown>>(token: string) => T;
|
|
957
1156
|
}
|
|
958
|
-
//#endregion
|
|
959
|
-
//#region src/types/resource.d.ts
|
|
960
|
-
/** Standard controller type alias for CRUD operations. */
|
|
961
|
-
type CrudController<TDoc> = IController<TDoc>;
|
|
962
1157
|
/**
|
|
963
|
-
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
1158
|
+
* Auth plugin options — clean, minimal configuration.
|
|
1159
|
+
*
|
|
1160
|
+
* Arc provides JWT infrastructure and calls your authenticator. You
|
|
1161
|
+
* control all authentication logic.
|
|
1162
|
+
*
|
|
1163
|
+
* @example
|
|
1164
|
+
* ```typescript
|
|
1165
|
+
* auth: {
|
|
1166
|
+
* jwt: { secret: process.env.JWT_SECRET },
|
|
1167
|
+
* authenticate: async (request, { jwt }) => {
|
|
1168
|
+
* const token = request.headers.authorization?.split(' ')[1];
|
|
1169
|
+
* if (!token) return null;
|
|
1170
|
+
* const decoded = jwt.verify(token);
|
|
1171
|
+
* return userRepo.findById(decoded.id);
|
|
1172
|
+
* },
|
|
1173
|
+
* }
|
|
1174
|
+
* ```
|
|
966
1175
|
*/
|
|
967
|
-
interface
|
|
968
|
-
/**
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
gcTime?: number;
|
|
1176
|
+
interface AuthPluginOptions {
|
|
1177
|
+
/**
|
|
1178
|
+
* JWT configuration (optional but recommended). If provided, JWT
|
|
1179
|
+
* utilities are available in the authenticator context.
|
|
1180
|
+
*/
|
|
1181
|
+
jwt?: {
|
|
1182
|
+
/** JWT secret (required for JWT features) */secret: string; /** Access token expiry (default: '15m') */
|
|
1183
|
+
expiresIn?: string; /** Refresh token secret (defaults to main secret) */
|
|
1184
|
+
refreshSecret?: string; /** Refresh token expiry (default: '7d') */
|
|
1185
|
+
refreshExpiresIn?: string; /** Additional `@fastify/jwt` sign options */
|
|
1186
|
+
sign?: Record<string, unknown>; /** Additional `@fastify/jwt` verify options */
|
|
1187
|
+
verify?: Record<string, unknown>;
|
|
980
1188
|
};
|
|
981
|
-
/** Tags for cross-resource invalidation grouping */
|
|
982
|
-
tags?: string[];
|
|
983
1189
|
/**
|
|
984
|
-
*
|
|
985
|
-
*
|
|
1190
|
+
* Custom authenticator function. Arc calls this for non-public routes.
|
|
1191
|
+
* If not provided and `jwt.secret` is set, uses default `jwtVerify`.
|
|
986
1192
|
*/
|
|
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[];
|
|
1193
|
+
authenticate?: Authenticator;
|
|
1003
1194
|
/**
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1195
|
+
* Custom auth failure handler. Customize the 401 response when
|
|
1196
|
+
* authentication fails.
|
|
1006
1197
|
*/
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1198
|
+
onFailure?: (request: FastifyRequest, reply: FastifyReply, error?: Error) => void | Promise<void>;
|
|
1199
|
+
/**
|
|
1200
|
+
* Expose detailed auth error messages in 401 responses. When `false`
|
|
1201
|
+
* (default), returns generic "Authentication required". Decoupled from
|
|
1202
|
+
* log level — set explicitly per environment.
|
|
1203
|
+
*/
|
|
1204
|
+
exposeAuthErrors?: boolean;
|
|
1205
|
+
/** Property name to store user on request (default: 'user') */
|
|
1206
|
+
userProperty?: string;
|
|
1207
|
+
/**
|
|
1208
|
+
* Custom token extractor for the built-in JWT auth path. Defaults to
|
|
1209
|
+
* extracting Bearer token from Authorization header. Use when tokens
|
|
1210
|
+
* are in HttpOnly cookies, custom headers, or query params.
|
|
1211
|
+
*
|
|
1212
|
+
* @example
|
|
1213
|
+
* ```typescript
|
|
1214
|
+
* tokenExtractor: (request) => request.cookies?.['auth-token'] ?? null,
|
|
1215
|
+
* ```
|
|
1216
|
+
*/
|
|
1217
|
+
tokenExtractor?: (request: FastifyRequest) => string | null;
|
|
1218
|
+
/**
|
|
1219
|
+
* Token revocation check — called after JWT verification succeeds.
|
|
1220
|
+
* Return `true` to reject the token (revoked), `false` to allow.
|
|
1221
|
+
*
|
|
1222
|
+
* **Fail-closed**: if the check throws, the token is treated as revoked.
|
|
1223
|
+
*
|
|
1224
|
+
* @example
|
|
1225
|
+
* ```typescript
|
|
1226
|
+
* isRevoked: async (decoded) => {
|
|
1227
|
+
* return await redis.sismember('revoked-tokens', decoded.jti ?? decoded.id);
|
|
1228
|
+
* },
|
|
1229
|
+
* ```
|
|
1230
|
+
*/
|
|
1231
|
+
isRevoked?: (decoded: Record<string, unknown>) => boolean | Promise<boolean>;
|
|
1025
1232
|
/**
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
* `
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1233
|
+
* Enforce strict JWT `type` claim validation (default: `true`).
|
|
1234
|
+
*
|
|
1235
|
+
* When enabled, `authenticate` requires `decoded.type === "access"`.
|
|
1236
|
+
* Tokens with a missing or unexpected `type` claim are rejected —
|
|
1237
|
+
* defence in depth for apps that reuse the JWT secret to sign other
|
|
1238
|
+
* token kinds (invite links, one-time verification codes).
|
|
1239
|
+
*
|
|
1240
|
+
* Arc's own `issueTokens` always sets `type: "access"` or
|
|
1241
|
+
* `type: "refresh"`, so this default is safe for Arc-generated tokens.
|
|
1242
|
+
*
|
|
1243
|
+
* Set to `false` ONLY when you must accept tokens signed without a
|
|
1244
|
+
* `type` claim (e.g. a legacy issuer you don't control). In that mode
|
|
1245
|
+
* Arc still rejects tokens explicitly marked `type: "refresh"`.
|
|
1032
1246
|
*/
|
|
1033
|
-
|
|
1247
|
+
strictTokenType?: boolean;
|
|
1034
1248
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1249
|
+
//#endregion
|
|
1250
|
+
//#region src/pipeline/types.d.ts
|
|
1251
|
+
/**
|
|
1252
|
+
* Pipeline context passed to guards, transforms, and interceptors.
|
|
1253
|
+
* Extends IRequestContext with pipeline-specific metadata.
|
|
1254
|
+
*/
|
|
1255
|
+
interface PipelineContext extends IRequestContext {
|
|
1256
|
+
/** Resource name being accessed */
|
|
1257
|
+
resource: string;
|
|
1258
|
+
/** CRUD operation being performed */
|
|
1259
|
+
operation: "list" | "get" | "create" | "update" | "delete" | string;
|
|
1040
1260
|
}
|
|
1041
1261
|
/**
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
* feature-detects at runtime. Slot values are typed `unknown` so
|
|
1045
|
-
* class-based Zod schemas assign without casts.
|
|
1262
|
+
* Which operations a pipeline step applies to.
|
|
1263
|
+
* If omitted, applies to ALL operations.
|
|
1046
1264
|
*/
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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;
|
|
1265
|
+
type OperationFilter = Array<"list" | "get" | "create" | "update" | "delete" | string>;
|
|
1266
|
+
/**
|
|
1267
|
+
* Guard — boolean check that short-circuits on failure.
|
|
1268
|
+
* Return true to proceed, throw to deny.
|
|
1269
|
+
*/
|
|
1270
|
+
interface Guard {
|
|
1271
|
+
readonly _type: "guard";
|
|
1272
|
+
readonly name: string;
|
|
1273
|
+
readonly operations?: OperationFilter;
|
|
1274
|
+
handler(ctx: PipelineContext): boolean | Promise<boolean>;
|
|
1080
1275
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
*/
|
|
1091
|
-
response?: unknown;
|
|
1092
|
-
[key: string]: unknown;
|
|
1276
|
+
/**
|
|
1277
|
+
* Transform — modifies request data before the handler.
|
|
1278
|
+
* Returns modified context (or mutates in place).
|
|
1279
|
+
*/
|
|
1280
|
+
interface Transform {
|
|
1281
|
+
readonly _type: "transform";
|
|
1282
|
+
readonly name: string;
|
|
1283
|
+
readonly operations?: OperationFilter;
|
|
1284
|
+
handler(ctx: PipelineContext): PipelineContext | undefined | Promise<PipelineContext | undefined>;
|
|
1093
1285
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1286
|
+
/**
|
|
1287
|
+
* Next function passed to interceptors — calls the handler (or next interceptor).
|
|
1288
|
+
*/
|
|
1289
|
+
type NextFunction = () => Promise<IControllerResponse<unknown>>;
|
|
1290
|
+
/**
|
|
1291
|
+
* Intercept — wraps handler execution (before + after pattern).
|
|
1292
|
+
*/
|
|
1293
|
+
interface Interceptor {
|
|
1294
|
+
readonly _type: "interceptor";
|
|
1295
|
+
readonly name: string;
|
|
1296
|
+
readonly operations?: OperationFilter;
|
|
1297
|
+
handler(ctx: PipelineContext, next: NextFunction): Promise<IControllerResponse<unknown>>;
|
|
1102
1298
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1299
|
+
type PipelineStep = Guard | Transform | Interceptor;
|
|
1300
|
+
/**
|
|
1301
|
+
* Pipeline configuration — can be a flat array or per-operation map.
|
|
1302
|
+
*/
|
|
1303
|
+
type PipelineConfig = PipelineStep[] | {
|
|
1304
|
+
list?: PipelineStep[];
|
|
1305
|
+
get?: PipelineStep[];
|
|
1306
|
+
create?: PipelineStep[];
|
|
1307
|
+
update?: PipelineStep[];
|
|
1308
|
+
delete?: PipelineStep[];
|
|
1309
|
+
[operation: string]: PipelineStep[] | undefined;
|
|
1310
|
+
};
|
|
1311
|
+
//#endregion
|
|
1312
|
+
//#region src/types/handlers.d.ts
|
|
1313
|
+
/**
|
|
1314
|
+
* Minimal server accessor — exposes safe, read-only server decorators.
|
|
1315
|
+
* Allows controller handlers to publish events, log, and audit
|
|
1316
|
+
* without switching to `raw: true`.
|
|
1317
|
+
*/
|
|
1318
|
+
interface ServerAccessor {
|
|
1319
|
+
/** Event bus — publish domain events from any handler */
|
|
1320
|
+
events?: {
|
|
1321
|
+
publish: <T>(type: string, payload: T, meta?: Partial<Record<string, unknown>>) => Promise<void>;
|
|
1322
|
+
};
|
|
1323
|
+
/** Audit logger — log custom audit entries */
|
|
1324
|
+
audit?: {
|
|
1325
|
+
create: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
1326
|
+
update: (resource: string, documentId: string, before: Record<string, unknown>, after: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
1327
|
+
delete: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
1328
|
+
custom: (resource: string, documentId: string, action: string, data?: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
|
|
1329
|
+
};
|
|
1330
|
+
/** Logger — structured logging */
|
|
1331
|
+
log?: {
|
|
1332
|
+
info: (...args: unknown[]) => void;
|
|
1333
|
+
warn: (...args: unknown[]) => void;
|
|
1334
|
+
error: (...args: unknown[]) => void;
|
|
1335
|
+
debug: (...args: unknown[]) => void;
|
|
1336
|
+
};
|
|
1337
|
+
/** QueryCache — stale-while-revalidate data cache */
|
|
1338
|
+
queryCache?: {
|
|
1339
|
+
get: <T>(key: string) => Promise<{
|
|
1340
|
+
data: T;
|
|
1341
|
+
status: "fresh" | "stale" | "miss";
|
|
1342
|
+
}>;
|
|
1343
|
+
set: <T>(key: string, data: T, config: {
|
|
1344
|
+
staleTime?: number;
|
|
1345
|
+
gcTime?: number;
|
|
1346
|
+
tags?: string[];
|
|
1347
|
+
}) => Promise<void>;
|
|
1348
|
+
getResourceVersion: (resource: string) => Promise<number>;
|
|
1349
|
+
bumpResourceVersion: (resource: string) => Promise<void>;
|
|
1115
1350
|
};
|
|
1116
1351
|
}
|
|
1117
1352
|
/**
|
|
1118
|
-
*
|
|
1353
|
+
* Request context passed to controller handlers.
|
|
1119
1354
|
*
|
|
1120
|
-
*
|
|
1121
|
-
* - `
|
|
1122
|
-
* - `
|
|
1355
|
+
* **Generic parameters** (all default to safe permissive types so existing code keeps working):
|
|
1356
|
+
* - `TBody` — request body shape (default: `unknown`)
|
|
1357
|
+
* - `TParams` — route param shape (default: `Record<string, string>`)
|
|
1358
|
+
* - `TQuery` — query string shape (default: `Record<string, unknown>`)
|
|
1359
|
+
* - `TUser` — authenticated user shape (default: `UserBase`)
|
|
1360
|
+
* - `TMetadata` — internal metadata shape (default: `Record<string, unknown>`;
|
|
1361
|
+
* override with `ArcInternalMetadata` or your own augmentation when you
|
|
1362
|
+
* need typed access to `_scope`, `_policyFilters`, custom hook context, etc.)
|
|
1363
|
+
*
|
|
1364
|
+
* @example
|
|
1365
|
+
* ```typescript
|
|
1366
|
+
* // Untyped (default) — req.body is `unknown`, must be narrowed
|
|
1367
|
+
* async create(req: IRequestContext) {
|
|
1368
|
+
* const data = req.body as Partial<Product>;
|
|
1369
|
+
* return { success: true, data: await productRepo.create(data) };
|
|
1370
|
+
* }
|
|
1371
|
+
*
|
|
1372
|
+
* // Typed body — req.body is `CreateProductInput`, narrowing not needed
|
|
1373
|
+
* async create(req: IRequestContext<CreateProductInput>) {
|
|
1374
|
+
* return { success: true, data: await productRepo.create(req.body) };
|
|
1375
|
+
* }
|
|
1376
|
+
*
|
|
1377
|
+
* // Fully typed — body, route params, query, and metadata
|
|
1378
|
+
* async update(
|
|
1379
|
+
* req: IRequestContext<
|
|
1380
|
+
* Partial<Product>,
|
|
1381
|
+
* { id: string },
|
|
1382
|
+
* { fields?: string },
|
|
1383
|
+
* ArcInternalMetadata
|
|
1384
|
+
* >,
|
|
1385
|
+
* ) {
|
|
1386
|
+
* const fields = req.query.fields?.split(',');
|
|
1387
|
+
* const orgId = req.metadata?._scope ? getOrgId(req.metadata._scope) : undefined;
|
|
1388
|
+
* return { success: true, data: await productRepo.update(req.params.id, req.body) };
|
|
1389
|
+
* }
|
|
1390
|
+
* ```
|
|
1123
1391
|
*/
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1392
|
+
/**
|
|
1393
|
+
* First-class projection of `request._scope` for controller handlers.
|
|
1394
|
+
*
|
|
1395
|
+
* **v2.10.6:** previously, pulling tenant/user/role info from inside a
|
|
1396
|
+
* controller override meant digging through `req.metadata._scope` and
|
|
1397
|
+
* calling `getOrgId(scope)` / `getUserId(user)` manually. This projection
|
|
1398
|
+
* lifts the two fields most hosts need directly onto `req` so cross-kit
|
|
1399
|
+
* controller code reads:
|
|
1400
|
+
*
|
|
1401
|
+
* ```ts
|
|
1402
|
+
* async create(req: IRequestContext) {
|
|
1403
|
+
* const flowCtx = { organizationId: req.scope?.organizationId, actorId: req.scope?.userId };
|
|
1404
|
+
* }
|
|
1405
|
+
* ```
|
|
1406
|
+
*
|
|
1407
|
+
* Full scope shape (discriminated union of `member` / `service` / `elevated` / `public`)
|
|
1408
|
+
* still lives on `req.metadata._scope` for code that needs to branch on
|
|
1409
|
+
* `scope.kind` — this projection just surfaces the two keys every
|
|
1410
|
+
* tenant-scoped resource reaches for.
|
|
1411
|
+
*/
|
|
1412
|
+
interface RequestScopeProjection {
|
|
1413
|
+
/** Tenant the caller is scoped to (org member, service key bound to an org, or elevated admin's target org). */
|
|
1414
|
+
organizationId?: string;
|
|
1415
|
+
/** Caller's user id when authenticated — undefined for public / service-only scopes. */
|
|
1416
|
+
userId?: string;
|
|
1417
|
+
/** Org-level roles (e.g. `['admin', 'warehouse-manager']`) — separate from global `user.roles`. */
|
|
1418
|
+
orgRoles?: string[];
|
|
1419
|
+
}
|
|
1420
|
+
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>> {
|
|
1421
|
+
/** Route parameters (e.g., { id: '123' }) */
|
|
1422
|
+
params: TParams;
|
|
1423
|
+
/** Query string parameters */
|
|
1424
|
+
query: TQuery;
|
|
1425
|
+
/** Request body */
|
|
1426
|
+
body: TBody;
|
|
1427
|
+
/** Authenticated user or null */
|
|
1428
|
+
user: TUser | null;
|
|
1429
|
+
/** Request headers */
|
|
1430
|
+
headers: Record<string, string | undefined>;
|
|
1431
|
+
/** Organization ID (for multi-tenant apps) */
|
|
1432
|
+
organizationId?: string;
|
|
1433
|
+
/** Team ID (for team-scoped resources) */
|
|
1434
|
+
teamId?: string;
|
|
1128
1435
|
/**
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
*
|
|
1436
|
+
* First-class tenant/user scope projection — lifted from `metadata._scope`
|
|
1437
|
+
* so controller overrides don't have to dig through Arc internals.
|
|
1438
|
+
* See {@link RequestScopeProjection}. `undefined` for routes that run
|
|
1439
|
+
* without auth / scope resolution.
|
|
1133
1440
|
*/
|
|
1134
|
-
|
|
1135
|
-
/** Permission check — REQUIRED */
|
|
1136
|
-
readonly permissions: PermissionCheck;
|
|
1441
|
+
scope?: RequestScopeProjection;
|
|
1137
1442
|
/**
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1443
|
+
* Organization/auth context from middleware.
|
|
1444
|
+
* Contains orgRoles, orgScope, organizationId, and any custom fields
|
|
1445
|
+
* set by the auth adapter or org-scope plugin.
|
|
1446
|
+
*
|
|
1447
|
+
* @example
|
|
1448
|
+
* ```typescript
|
|
1449
|
+
* async create(req: IRequestContext) {
|
|
1450
|
+
* const roles = req.context?.orgRoles ?? [];
|
|
1451
|
+
* if (roles.includes('manager')) { ... }
|
|
1452
|
+
* }
|
|
1453
|
+
* ```
|
|
1140
1454
|
*/
|
|
1141
|
-
|
|
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;
|
|
1455
|
+
context?: RequestContext;
|
|
1156
1456
|
/**
|
|
1157
|
-
*
|
|
1158
|
-
* `
|
|
1159
|
-
*
|
|
1160
|
-
*/
|
|
1161
|
-
readonly schema?: {
|
|
1162
|
-
body?: unknown;
|
|
1163
|
-
querystring?: unknown;
|
|
1164
|
-
params?: unknown;
|
|
1165
|
-
headers?: unknown;
|
|
1166
|
-
response?: Record<number | string, unknown>;
|
|
1167
|
-
[key: string]: unknown;
|
|
1168
|
-
};
|
|
1169
|
-
/**
|
|
1170
|
-
* MCP tool generation:
|
|
1171
|
-
* - omitted/true: auto-generate (non-raw routes only)
|
|
1172
|
-
* - false: skip MCP
|
|
1173
|
-
* - object: explicit config
|
|
1457
|
+
* Internal metadata (includes context + Arc internals like `_policyFilters`,
|
|
1458
|
+
* `_scope`, `log`). Type as `ArcInternalMetadata` for typed access to Arc's
|
|
1459
|
+
* built-in fields, or supply your own interface to layer custom fields.
|
|
1174
1460
|
*/
|
|
1175
|
-
|
|
1461
|
+
metadata?: TMetadata;
|
|
1176
1462
|
/**
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
1463
|
+
* Fastify server accessor — publish events, log, and audit
|
|
1464
|
+
* from any handler without switching to `raw: true`.
|
|
1465
|
+
*
|
|
1466
|
+
* @example
|
|
1467
|
+
* ```typescript
|
|
1468
|
+
* async reschedule(req: IRequestContext) {
|
|
1469
|
+
* const result = await repo.reschedule(req.params.id, req.body);
|
|
1470
|
+
* await req.server?.events?.publish('interview.rescheduled', { data: result });
|
|
1471
|
+
* return { success: true, data: result };
|
|
1472
|
+
* }
|
|
1473
|
+
* ```
|
|
1179
1474
|
*/
|
|
1180
|
-
|
|
1181
|
-
content: Array<{
|
|
1182
|
-
type: string;
|
|
1183
|
-
text: string;
|
|
1184
|
-
}>;
|
|
1185
|
-
isError?: boolean;
|
|
1186
|
-
}>;
|
|
1475
|
+
server?: ServerAccessor;
|
|
1187
1476
|
}
|
|
1188
1477
|
/**
|
|
1189
|
-
*
|
|
1190
|
-
* ID, action-specific data, and the request.
|
|
1478
|
+
* Standard response from controller handlers
|
|
1191
1479
|
*/
|
|
1192
|
-
|
|
1193
|
-
/**
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
* MCP tool generation:
|
|
1208
|
-
* - omitted/true: auto-generate
|
|
1209
|
-
* - false: skip
|
|
1210
|
-
* - object: explicit config
|
|
1211
|
-
*/
|
|
1212
|
-
readonly mcp?: boolean | RouteMcpConfig;
|
|
1480
|
+
interface IControllerResponse<T = unknown> {
|
|
1481
|
+
/** Operation success status */
|
|
1482
|
+
success: boolean;
|
|
1483
|
+
/** Response data */
|
|
1484
|
+
data?: T;
|
|
1485
|
+
/** Error message (when success is false) */
|
|
1486
|
+
error?: string;
|
|
1487
|
+
/** HTTP status code (default: 200 for success, 400 for error) */
|
|
1488
|
+
status?: number;
|
|
1489
|
+
/** Additional metadata */
|
|
1490
|
+
meta?: Record<string, unknown>;
|
|
1491
|
+
/** Error details (for debugging) */
|
|
1492
|
+
details?: Record<string, unknown>;
|
|
1493
|
+
/** Custom response headers (e.g., X-Total-Count, Link, ETag) */
|
|
1494
|
+
headers?: Record<string, string>;
|
|
1213
1495
|
}
|
|
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
1496
|
/**
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1497
|
+
* Controller handler — Arc's standard pattern.
|
|
1498
|
+
*
|
|
1499
|
+
* Receives a request context object, returns IControllerResponse.
|
|
1500
|
+
* Use with `raw: false` in routes.
|
|
1501
|
+
*
|
|
1502
|
+
* **Generic parameters:**
|
|
1503
|
+
* - `TResponse` — shape of `IControllerResponse.data` (default: `unknown`)
|
|
1504
|
+
* - `TBody` — shape of `req.body` (default: `unknown`)
|
|
1505
|
+
* - `TParams` — shape of `req.params` (default: `Record<string, string>`)
|
|
1506
|
+
* - `TQuery` — shape of `req.query` (default: `Record<string, unknown>`)
|
|
1507
|
+
*
|
|
1508
|
+
* Backward-compatible: `ControllerHandler<Product>` still works (only the
|
|
1509
|
+
* response data is typed); add more generics as needed when you want
|
|
1510
|
+
* type-safe access to the request body, params, or query string.
|
|
1511
|
+
*
|
|
1512
|
+
* @example
|
|
1513
|
+
* ```typescript
|
|
1514
|
+
* // Untyped req — body is unknown, must be narrowed
|
|
1515
|
+
* const createProduct: ControllerHandler<Product> = async (req) => {
|
|
1516
|
+
* const product = await productRepo.create(req.body as Partial<Product>);
|
|
1517
|
+
* return { success: true, data: product, status: 201 };
|
|
1518
|
+
* };
|
|
1519
|
+
*
|
|
1520
|
+
* // Fully typed — body, params, query, and response all inferred
|
|
1521
|
+
* const updateProduct: ControllerHandler<
|
|
1522
|
+
* Product,
|
|
1523
|
+
* Partial<Product>,
|
|
1524
|
+
* { id: string },
|
|
1525
|
+
* { upsert?: string }
|
|
1526
|
+
* > = async (req) => {
|
|
1527
|
+
* const upsert = req.query.upsert === "true";
|
|
1528
|
+
* const product = await productRepo.update(req.params.id, req.body, { upsert });
|
|
1529
|
+
* return { success: true, data: product };
|
|
1530
|
+
* };
|
|
1531
|
+
*
|
|
1532
|
+
* routes: [{
|
|
1533
|
+
* method: 'POST',
|
|
1534
|
+
* path: '/products',
|
|
1535
|
+
* handler: createProduct,
|
|
1536
|
+
* permissions: requireAuth(),
|
|
1537
|
+
* raw: false, // Arc wraps this into Fastify handler
|
|
1538
|
+
* }]
|
|
1539
|
+
* ```
|
|
1221
1540
|
*/
|
|
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
|
-
}
|
|
1541
|
+
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>>;
|
|
1230
1542
|
/**
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
1543
|
+
* Fastify native handler
|
|
1544
|
+
*
|
|
1545
|
+
* Standard Fastify request/reply pattern.
|
|
1546
|
+
* Use with `raw: true` in routes.
|
|
1233
1547
|
*
|
|
1234
1548
|
* @example
|
|
1235
1549
|
* ```typescript
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1244
|
-
*
|
|
1550
|
+
* const downloadFile: FastifyHandler = async (request, reply) => {
|
|
1551
|
+
* const file = await getFile(request.params.id);
|
|
1552
|
+
* reply.header('Content-Type', file.mimeType);
|
|
1553
|
+
* return reply.send(file.buffer);
|
|
1554
|
+
* };
|
|
1555
|
+
*
|
|
1556
|
+
* routes: [{
|
|
1557
|
+
* method: 'GET',
|
|
1558
|
+
* path: '/files/:id/download',
|
|
1559
|
+
* handler: downloadFile,
|
|
1560
|
+
* permissions: requireAuth(),
|
|
1561
|
+
* raw: true, // Use as-is, no wrapping
|
|
1562
|
+
* }]
|
|
1245
1563
|
* ```
|
|
1246
1564
|
*/
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1565
|
+
type FastifyHandler<RouteGeneric extends Record<string, unknown> = Record<string, unknown>> = (request: FastifyRequest<RouteGeneric>, reply: FastifyReply) => Promise<unknown> | unknown;
|
|
1566
|
+
/**
|
|
1567
|
+
* Union type for route handlers
|
|
1568
|
+
*/
|
|
1569
|
+
type RouteHandler = ControllerHandler | FastifyHandler;
|
|
1570
|
+
/**
|
|
1571
|
+
* Controller interface for CRUD operations (strict).
|
|
1572
|
+
*
|
|
1573
|
+
* `list`'s return type aligns with repo-core's `MinimalRepo.getAll()`
|
|
1574
|
+
* contract — the kit MAY return an offset envelope, a keyset envelope,
|
|
1575
|
+
* or a raw array. Arc's `BaseController` forwards the kit's response
|
|
1576
|
+
* verbatim; consumers narrow on shape
|
|
1577
|
+
* (`Array.isArray(data)` → bare array, presence of `total` → offset,
|
|
1578
|
+
* presence of `nextCursor` → keyset).
|
|
1579
|
+
*/
|
|
1580
|
+
interface IController<TDoc = unknown> {
|
|
1581
|
+
list(req: IRequestContext): Promise<IControllerResponse<unknown>>;
|
|
1582
|
+
get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1583
|
+
create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1584
|
+
update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
|
|
1585
|
+
delete(req: IRequestContext): Promise<IControllerResponse<{
|
|
1586
|
+
message: string;
|
|
1587
|
+
}>>;
|
|
1254
1588
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1589
|
+
/**
|
|
1590
|
+
* Flexible controller interface — accepts controllers with any handler style,
|
|
1591
|
+
* including class instances with extra methods / private fields.
|
|
1592
|
+
*
|
|
1593
|
+
* **v2.10.6:** the previous `[key: string]: unknown` index signature made
|
|
1594
|
+
* real class instances fail structural assignment (`new ScrapController()`
|
|
1595
|
+
* needed a `as unknown as ControllerLike` cast, because class instances
|
|
1596
|
+
* don't have an index signature). Dropped — arc only invokes the CRUD
|
|
1597
|
+
* methods at runtime, so the rest of the shape is the caller's concern.
|
|
1598
|
+
*
|
|
1599
|
+
* The five CRUD slots stay optional so partial controllers (e.g. read-only)
|
|
1600
|
+
* assign too. Additional domain methods on the controller are allowed by
|
|
1601
|
+
* construction (they're just not part of this contract).
|
|
1602
|
+
*/
|
|
1603
|
+
interface ControllerLike {
|
|
1604
|
+
list?: unknown;
|
|
1605
|
+
get?: unknown;
|
|
1606
|
+
create?: unknown;
|
|
1607
|
+
update?: unknown;
|
|
1608
|
+
delete?: unknown;
|
|
1260
1609
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1610
|
+
//#endregion
|
|
1611
|
+
//#region src/types/resource.d.ts
|
|
1612
|
+
/** Standard controller type alias for CRUD operations. */
|
|
1613
|
+
type CrudController<TDoc> = IController<TDoc>;
|
|
1614
|
+
/**
|
|
1615
|
+
* Per-resource cache configuration for QueryCache. Enables
|
|
1616
|
+
* stale-while-revalidate, auto-invalidation on mutations, and
|
|
1617
|
+
* cross-resource tag-based invalidation.
|
|
1618
|
+
*/
|
|
1619
|
+
interface ResourceCacheConfig {
|
|
1620
|
+
/** Seconds data is "fresh" (no revalidation). Default: 0 */
|
|
1621
|
+
staleTime?: number;
|
|
1622
|
+
/** Seconds stale data stays cached (SWR window). Default: 60 */
|
|
1623
|
+
gcTime?: number;
|
|
1624
|
+
/** Per-operation overrides */
|
|
1625
|
+
list?: {
|
|
1626
|
+
staleTime?: number;
|
|
1627
|
+
gcTime?: number;
|
|
1628
|
+
};
|
|
1629
|
+
byId?: {
|
|
1630
|
+
staleTime?: number;
|
|
1631
|
+
gcTime?: number;
|
|
1632
|
+
};
|
|
1633
|
+
/** Tags for cross-resource invalidation grouping */
|
|
1634
|
+
tags?: string[];
|
|
1635
|
+
/**
|
|
1636
|
+
* Cross-resource invalidation: event pattern → tag targets.
|
|
1637
|
+
* @example { 'category.*': ['catalog'] }
|
|
1638
|
+
*/
|
|
1639
|
+
invalidateOn?: Record<string, string[]>;
|
|
1640
|
+
/** Disable caching for this resource */
|
|
1641
|
+
disabled?: boolean;
|
|
1269
1642
|
}
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
/**
|
|
1274
|
-
|
|
1275
|
-
/** JSON schema for event payload */
|
|
1276
|
-
schema?: Record<string, unknown>;
|
|
1277
|
-
description?: string;
|
|
1643
|
+
interface RateLimitConfig {
|
|
1644
|
+
/** Maximum number of requests allowed within the time window */
|
|
1645
|
+
max: number;
|
|
1646
|
+
/** Time window for rate limiting (e.g., '1 minute', '15 seconds') */
|
|
1647
|
+
timeWindow: string;
|
|
1278
1648
|
}
|
|
1279
|
-
/**
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
tag?: string;
|
|
1291
|
-
/** Defaults to `/${name}s` if not provided. */
|
|
1292
|
-
prefix?: string;
|
|
1649
|
+
/**
|
|
1650
|
+
* Per-field rule — arc's extension of repo-core's 4-field `FieldRule` floor
|
|
1651
|
+
* (`immutable`, `immutableAfterCreate`, `systemManaged`, `optional`) with
|
|
1652
|
+
* the constraint / UI / security bits arc layers on top.
|
|
1653
|
+
*
|
|
1654
|
+
* Kept structurally compatible with `@classytic/repo-core/schema`'s
|
|
1655
|
+
* `FieldRule` so arc's `fieldRules: Record<string, ArcFieldRule>` flows
|
|
1656
|
+
* into mongokit's / sqlitekit's `buildCrudSchemasFromModel(..., options)`
|
|
1657
|
+
* without a cast. See `RouteSchemaOptions` JSDoc for the full rationale.
|
|
1658
|
+
*/
|
|
1659
|
+
interface ArcFieldRule extends FieldRule {
|
|
1293
1660
|
/**
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1661
|
+
* When `true`, bypass the `systemManaged` / `readonly` / `immutable`
|
|
1662
|
+
* strip in `BodySanitizer` for callers whose request scope is
|
|
1663
|
+
* `elevated`. Lets platform admins stamp the value from the request
|
|
1664
|
+
* body — needed for cross-tenant admin writes where the tenant field
|
|
1665
|
+
* is the only way to pick a target org.
|
|
1298
1666
|
*
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
1667
|
+
* Auto-set by `defineResource` on the configured `tenantField`. Hosts
|
|
1668
|
+
* can set it manually on other fields (e.g. `createdBy`) if they want
|
|
1669
|
+
* elevation-only override semantics for those too.
|
|
1670
|
+
*
|
|
1671
|
+
* Has no effect when `isElevated(scope)` is false — member and
|
|
1672
|
+
* service callers continue to have the field stripped.
|
|
1303
1673
|
*/
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1674
|
+
preserveForElevated?: boolean;
|
|
1675
|
+
hidden?: boolean;
|
|
1676
|
+
/** String minimum length — auto-maps to OpenAPI `minLength` and MCP tool schema */
|
|
1677
|
+
minLength?: number;
|
|
1678
|
+
/** String maximum length — auto-maps to OpenAPI `maxLength` and MCP tool schema */
|
|
1679
|
+
maxLength?: number;
|
|
1680
|
+
/** Number minimum — auto-maps to OpenAPI `minimum` and MCP tool schema */
|
|
1681
|
+
min?: number;
|
|
1682
|
+
/** Number maximum — auto-maps to OpenAPI `maximum` and MCP tool schema */
|
|
1683
|
+
max?: number;
|
|
1684
|
+
/** Regex pattern — auto-maps to OpenAPI `pattern` and MCP tool schema */
|
|
1685
|
+
pattern?: string;
|
|
1686
|
+
/** Allowed values — auto-maps to OpenAPI `enum` and MCP tool schema */
|
|
1687
|
+
enum?: ReadonlyArray<string | number>;
|
|
1688
|
+
/**
|
|
1689
|
+
* When `true`, widen the JSON Schema `type` of this field to also
|
|
1690
|
+
* accept `null`. Mirrors Zod's `.nullable()` at the arc config layer
|
|
1691
|
+
* for kit-generated schemas that don't carry the flag end-to-end
|
|
1692
|
+
* (e.g. Zod → Mongoose → mongokit drops `.nullable()` because
|
|
1693
|
+
* Mongoose has no first-class nullable marker unless `default: null`
|
|
1694
|
+
* is also set).
|
|
1324
1695
|
*
|
|
1325
|
-
*
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1696
|
+
* Applied post-kit by `mergeFieldRuleConstraints`: if the adapter
|
|
1697
|
+
* emitted `{ type: 'string', enum: [...] }` for a field arc should
|
|
1698
|
+
* accept null for, the merge widens it to
|
|
1699
|
+
* `{ type: ['string', 'null'], enum: [...] }` — draft-7 tuple form
|
|
1700
|
+
* AJV 8 validates natively.
|
|
1701
|
+
*
|
|
1702
|
+
* No-op when the property already declares `type: [...,'null']` or
|
|
1703
|
+
* an `anyOf: [..., { type: 'null' }]` branch — arc never fights the
|
|
1704
|
+
* kit's own output.
|
|
1330
1705
|
*/
|
|
1331
|
-
|
|
1706
|
+
nullable?: boolean;
|
|
1707
|
+
/** Human-readable description — auto-maps to OpenAPI `description` */
|
|
1708
|
+
description?: string;
|
|
1709
|
+
[key: string]: unknown;
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* Schema-shaping options for a resource.
|
|
1713
|
+
*
|
|
1714
|
+
* Extends `@classytic/repo-core/schema`'s `SchemaBuilderOptions` so every
|
|
1715
|
+
* kit-generator callback typed against the repo-core contract
|
|
1716
|
+
* (mongokit's `buildCrudSchemasFromModel`, sqlitekit's
|
|
1717
|
+
* `buildCrudSchemasFromTable`, prismakit's equivalent) accepts arc's
|
|
1718
|
+
* options bag directly — no `as SchemaBuilderOptions` / `Parameters<...>[1]`
|
|
1719
|
+
* cast at the host wiring site.
|
|
1720
|
+
*
|
|
1721
|
+
* Inherited from `SchemaBuilderOptions`:
|
|
1722
|
+
* - `strictAdditionalProperties` — emit `additionalProperties: false`
|
|
1723
|
+
* - `dateAs` — `'date'` vs `'datetime'` ISO rendering
|
|
1724
|
+
* - `softRequiredFields` — stay in `properties`, drop from `required[]`
|
|
1725
|
+
* - `create: { omitFields, requiredOverrides, optionalOverrides, schemaOverrides }`
|
|
1726
|
+
* - `update: { omitFields, requireAtLeastOne }`
|
|
1727
|
+
* - `query: { filterableFields }` (kit-native filter declaration)
|
|
1728
|
+
* - `openApiExtensions` — emit `x-*` vendor keywords for docgen
|
|
1729
|
+
*
|
|
1730
|
+
* Arc adds:
|
|
1731
|
+
* - `fieldRules` with the richer `ArcFieldRule` per-entry shape
|
|
1732
|
+
* (preserveForElevated, minLength/maxLength/min/max/pattern, enum,
|
|
1733
|
+
* nullable, description) — arc's extensions are applied post-kit by
|
|
1734
|
+
* `mergeFieldRuleConstraints`; the kit only sees the repo-core floor.
|
|
1735
|
+
* - `hiddenFields` / `readonlyFields` / `requiredFields` / `optionalFields`
|
|
1736
|
+
* / `excludeFields` — arc-only convenience lists that predate fieldRules.
|
|
1737
|
+
* Keep using them if they're already in place; new code should prefer
|
|
1738
|
+
* `fieldRules` for per-field control.
|
|
1739
|
+
* - `filterableFields: string[]` — top-level list arc's MCP layer auto-
|
|
1740
|
+
* derives from `QueryParser.allowedFilterFields`. Distinct from the
|
|
1741
|
+
* inherited `query.filterableFields: Record<...>` which feeds the kit's
|
|
1742
|
+
* list-query schema; nothing stops a resource from using both.
|
|
1743
|
+
*
|
|
1744
|
+
* **Why extend rather than duplicate**: mongokit's
|
|
1745
|
+
* `buildCrudSchemasFromModel(model, options: SchemaBuilderOptions)` is the
|
|
1746
|
+
* canonical callback shape. Before the extension, hosts wrote
|
|
1747
|
+
* `Parameters<typeof buildCrudSchemasFromModel>[1]` or
|
|
1748
|
+
* `as SchemaBuilderOptions` at every wiring site — a defensive cast with
|
|
1749
|
+
* no runtime effect. Extension locks the structural relationship at the
|
|
1750
|
+
* type layer so the cast is compile-verified gone.
|
|
1751
|
+
*/
|
|
1752
|
+
interface RouteSchemaOptions extends SchemaBuilderOptions {
|
|
1753
|
+
hiddenFields?: string[];
|
|
1754
|
+
readonlyFields?: string[];
|
|
1755
|
+
requiredFields?: string[];
|
|
1756
|
+
optionalFields?: string[];
|
|
1757
|
+
excludeFields?: string[];
|
|
1332
1758
|
/**
|
|
1333
|
-
*
|
|
1759
|
+
* Fields allowed for filtering in list operations. MCP auto-derives
|
|
1760
|
+
* from `QueryParser.allowedFilterFields` when not set explicitly.
|
|
1334
1761
|
*
|
|
1335
|
-
*
|
|
1336
|
-
*
|
|
1337
|
-
*
|
|
1338
|
-
* salary: fields.visibleTo(['admin', 'hr']),
|
|
1339
|
-
* password: fields.hidden(),
|
|
1340
|
-
* }
|
|
1341
|
-
* ```
|
|
1762
|
+
* Distinct from the inherited `query.filterableFields: Record<...>`
|
|
1763
|
+
* from `SchemaBuilderOptions` — that entry feeds the kit's list-query
|
|
1764
|
+
* JSON Schema; this one is arc's MCP-auto-derivation list.
|
|
1342
1765
|
*/
|
|
1343
|
-
|
|
1766
|
+
filterableFields?: string[];
|
|
1344
1767
|
/**
|
|
1345
|
-
*
|
|
1768
|
+
* Per-field rules. Richer than repo-core's `FieldRules` — arc adds
|
|
1769
|
+
* `preserveForElevated`, constraint hints (`minLength`, `enum`,
|
|
1770
|
+
* `nullable`, etc.), and `description` on top of the four-flag floor
|
|
1771
|
+
* (`immutable`, `immutableAfterCreate`, `systemManaged`, `optional`).
|
|
1346
1772
|
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1350
|
-
* - `
|
|
1351
|
-
* pre-2.9 code that relied on the permissive default.
|
|
1773
|
+
* Structurally compatible: `Record<string, ArcFieldRule>` is assignable
|
|
1774
|
+
* to repo-core's `Record<string, FieldRule>` since `ArcFieldRule extends
|
|
1775
|
+
* FieldRule`. Kits see only the floor; arc's extensions are applied
|
|
1776
|
+
* post-kit by `mergeFieldRuleConstraints`.
|
|
1352
1777
|
*/
|
|
1353
|
-
|
|
1354
|
-
|
|
1778
|
+
fieldRules?: Record<string, ArcFieldRule>;
|
|
1779
|
+
}
|
|
1780
|
+
interface FieldRule$1 {
|
|
1781
|
+
field: string;
|
|
1782
|
+
required?: boolean;
|
|
1783
|
+
readonly?: boolean;
|
|
1784
|
+
hidden?: boolean;
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* CRUD route schemas (Fastify native format). Each slot accepts a plain
|
|
1788
|
+
* JSON Schema object **or** a Zod v4 schema — Arc's `convertRouteSchema`
|
|
1789
|
+
* feature-detects at runtime. Slot values are typed `unknown` so
|
|
1790
|
+
* class-based Zod schemas assign without casts.
|
|
1791
|
+
*/
|
|
1792
|
+
interface CrudSchemas {
|
|
1793
|
+
/** GET / — list */
|
|
1794
|
+
list?: {
|
|
1795
|
+
querystring?: unknown;
|
|
1796
|
+
response?: Record<number, unknown>;
|
|
1797
|
+
[key: string]: unknown;
|
|
1798
|
+
};
|
|
1799
|
+
/** GET /:id — get one */
|
|
1800
|
+
get?: {
|
|
1801
|
+
params?: unknown;
|
|
1802
|
+
response?: Record<number, unknown>;
|
|
1803
|
+
[key: string]: unknown;
|
|
1804
|
+
};
|
|
1805
|
+
/** POST / — create */
|
|
1806
|
+
create?: {
|
|
1807
|
+
body?: unknown;
|
|
1808
|
+
response?: Record<number, unknown>;
|
|
1809
|
+
[key: string]: unknown;
|
|
1810
|
+
};
|
|
1811
|
+
/** PATCH /:id — update */
|
|
1812
|
+
update?: {
|
|
1813
|
+
params?: unknown;
|
|
1814
|
+
body?: unknown;
|
|
1815
|
+
response?: Record<number, unknown>;
|
|
1816
|
+
[key: string]: unknown;
|
|
1817
|
+
};
|
|
1818
|
+
/** DELETE /:id — delete */
|
|
1819
|
+
delete?: {
|
|
1820
|
+
params?: unknown;
|
|
1821
|
+
response?: Record<number, unknown>;
|
|
1822
|
+
[key: string]: unknown;
|
|
1823
|
+
};
|
|
1824
|
+
[key: string]: unknown;
|
|
1825
|
+
}
|
|
1826
|
+
interface OpenApiSchemas {
|
|
1827
|
+
entity?: unknown;
|
|
1828
|
+
createBody?: unknown;
|
|
1829
|
+
updateBody?: unknown;
|
|
1830
|
+
params?: unknown;
|
|
1831
|
+
listQuery?: unknown;
|
|
1355
1832
|
/**
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
* per-route `preHandler`. Use for mode gates, tenant checks, feature
|
|
1359
|
-
* flags — anything that applies to every endpoint.
|
|
1833
|
+
* Explicit response schema for OpenAPI documentation. Auto-generated
|
|
1834
|
+
* from `createBody` if omitted. Does NOT affect Fastify serialization.
|
|
1360
1835
|
*/
|
|
1361
|
-
|
|
1836
|
+
response?: unknown;
|
|
1837
|
+
[key: string]: unknown;
|
|
1838
|
+
}
|
|
1839
|
+
type CrudRouteKey = "list" | "get" | "create" | "update" | "delete";
|
|
1840
|
+
interface MiddlewareConfig {
|
|
1841
|
+
list?: MiddlewareHandler[];
|
|
1842
|
+
get?: MiddlewareHandler[];
|
|
1843
|
+
create?: MiddlewareHandler[];
|
|
1844
|
+
update?: MiddlewareHandler[];
|
|
1845
|
+
delete?: MiddlewareHandler[];
|
|
1846
|
+
[key: string]: MiddlewareHandler[] | undefined;
|
|
1847
|
+
}
|
|
1848
|
+
/** HTTP methods for custom routes. */
|
|
1849
|
+
type RouteMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
1850
|
+
/** MCP tool configuration for a route or action. */
|
|
1851
|
+
interface RouteMcpConfig {
|
|
1852
|
+
/** Override auto-generated tool description */
|
|
1853
|
+
readonly description?: string;
|
|
1854
|
+
/** MCP tool annotations */
|
|
1855
|
+
readonly annotations?: {
|
|
1856
|
+
readonly readOnlyHint?: boolean;
|
|
1857
|
+
readonly destructiveHint?: boolean;
|
|
1858
|
+
readonly idempotentHint?: boolean;
|
|
1859
|
+
readonly openWorldHint?: boolean;
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
/**
|
|
1863
|
+
* Route definition — single custom-route shape (user-facing + internal).
|
|
1864
|
+
*
|
|
1865
|
+
* - `handler: 'string'` → controller method → full Arc pipeline + MCP tool
|
|
1866
|
+
* - `handler: function` → inline handler → full Arc pipeline + MCP tool
|
|
1867
|
+
* - `raw: true` → raw Fastify handler → no pipeline, no MCP by default
|
|
1868
|
+
*/
|
|
1869
|
+
interface RouteDefinition {
|
|
1870
|
+
readonly method: RouteMethod;
|
|
1871
|
+
/** Path relative to resource prefix */
|
|
1872
|
+
readonly path: string;
|
|
1362
1873
|
/**
|
|
1363
|
-
*
|
|
1364
|
-
*
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1367
|
-
* routes: [
|
|
1368
|
-
* { method: 'GET', path: '/stats', handler: 'getStats', permissions: auth() },
|
|
1369
|
-
* { method: 'POST', path: '/webhook', handler: webhookFn, raw: true, permissions: auth() },
|
|
1370
|
-
* ]
|
|
1371
|
-
* ```
|
|
1874
|
+
* Route handler.
|
|
1875
|
+
* - String: controller method name (Arc pipeline)
|
|
1876
|
+
* - Function without `raw: true`: receives IRequestContext, returns IControllerResponse (Arc pipeline)
|
|
1877
|
+
* - Function with `raw: true`: raw Fastify handler `(request, reply)`
|
|
1372
1878
|
*/
|
|
1373
|
-
|
|
1879
|
+
readonly handler: string | ControllerHandler | RouteHandlerMethod | ((request: FastifyRequest<Record<string, unknown>>, reply: FastifyReply) => unknown);
|
|
1880
|
+
/** Permission check — REQUIRED */
|
|
1881
|
+
readonly permissions: PermissionCheck;
|
|
1374
1882
|
/**
|
|
1375
|
-
*
|
|
1376
|
-
*
|
|
1377
|
-
* + schema.
|
|
1378
|
-
*
|
|
1379
|
-
* @example
|
|
1380
|
-
* ```typescript
|
|
1381
|
-
* actions: {
|
|
1382
|
-
* approve: async (id, data, req) => service.approve(id, req.user._id),
|
|
1383
|
-
* cancel: {
|
|
1384
|
-
* handler: async (id, data, req) => service.cancel(id, data.reason, req.user._id),
|
|
1385
|
-
* permissions: roles('admin'),
|
|
1386
|
-
* schema: { reason: { type: 'string' } },
|
|
1387
|
-
* },
|
|
1388
|
-
* },
|
|
1389
|
-
* actionPermissions: auth(),
|
|
1390
|
-
* ```
|
|
1883
|
+
* Raw mode — bypasses Arc pipeline. Handler receives raw Fastify
|
|
1884
|
+
* request/reply. Default: false.
|
|
1391
1885
|
*/
|
|
1392
|
-
|
|
1886
|
+
readonly raw?: boolean;
|
|
1887
|
+
/** Logical operation name (pipeline keys, MCP tool naming). */
|
|
1888
|
+
readonly operation?: string;
|
|
1889
|
+
/** OpenAPI summary */
|
|
1890
|
+
readonly summary?: string;
|
|
1891
|
+
/** OpenAPI description */
|
|
1892
|
+
readonly description?: string;
|
|
1893
|
+
/** OpenAPI tags */
|
|
1894
|
+
readonly tags?: string[];
|
|
1895
|
+
/** Route-level middleware */
|
|
1896
|
+
readonly preHandler?: RouteHandlerMethod[] | ((fastify: FastifyInstance) => RouteHandlerMethod[]);
|
|
1897
|
+
/** Pre-auth handlers (run before authentication) */
|
|
1898
|
+
readonly preAuth?: RouteHandlerMethod[];
|
|
1899
|
+
/** SSE streaming mode */
|
|
1900
|
+
readonly streamResponse?: boolean;
|
|
1393
1901
|
/**
|
|
1394
|
-
*
|
|
1395
|
-
*
|
|
1902
|
+
* Fastify route schema. Each slot (`body`, `querystring`, `params`,
|
|
1903
|
+
* `headers`, `response[status]`) accepts a plain JSON Schema object
|
|
1904
|
+
* **or** a Zod v4 schema — Arc auto-converts via `convertRouteSchema`.
|
|
1396
1905
|
*/
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1906
|
+
readonly schema?: {
|
|
1907
|
+
body?: unknown;
|
|
1908
|
+
querystring?: unknown;
|
|
1909
|
+
params?: unknown;
|
|
1910
|
+
headers?: unknown;
|
|
1911
|
+
response?: Record<number | string, unknown>;
|
|
1912
|
+
[key: string]: unknown;
|
|
1913
|
+
};
|
|
1402
1914
|
/**
|
|
1403
|
-
*
|
|
1404
|
-
*
|
|
1915
|
+
* MCP tool generation:
|
|
1916
|
+
* - omitted/true: auto-generate (non-raw routes only)
|
|
1917
|
+
* - false: skip MCP
|
|
1918
|
+
* - object: explicit config
|
|
1919
|
+
*/
|
|
1920
|
+
readonly mcp?: boolean | RouteMcpConfig;
|
|
1921
|
+
/**
|
|
1922
|
+
* MCP handler for raw routes — parallel entry point for MCP without
|
|
1923
|
+
* changing the HTTP handler.
|
|
1924
|
+
*/
|
|
1925
|
+
readonly mcpHandler?: (input: Record<string, unknown>) => Promise<{
|
|
1926
|
+
content: Array<{
|
|
1927
|
+
type: string;
|
|
1928
|
+
text: string;
|
|
1929
|
+
}>;
|
|
1930
|
+
isError?: boolean;
|
|
1931
|
+
}>;
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* Action handler function for state transitions. Receives the resource
|
|
1935
|
+
* ID, action-specific data, and the request.
|
|
1936
|
+
*/
|
|
1937
|
+
type ActionHandlerFn = (id: string, data: Record<string, unknown>, req: RequestWithExtras) => Promise<unknown>;
|
|
1938
|
+
/** Full action configuration with handler, permissions, and schema. */
|
|
1939
|
+
interface ActionDefinition {
|
|
1940
|
+
readonly handler: ActionHandlerFn;
|
|
1941
|
+
/** Per-action permission (overrides resource-level `actionPermissions`) */
|
|
1942
|
+
readonly permissions?: PermissionCheck;
|
|
1943
|
+
/**
|
|
1944
|
+
* JSON Schema or Zod v4 schema for action-specific body fields.
|
|
1945
|
+
* Per-field values are typed `unknown` so Zod class instances assign
|
|
1946
|
+
* without casts.
|
|
1947
|
+
*/
|
|
1948
|
+
readonly schema?: Record<string, unknown>;
|
|
1949
|
+
/** Description for OpenAPI docs and MCP tool */
|
|
1950
|
+
readonly description?: string;
|
|
1951
|
+
/**
|
|
1952
|
+
* MCP tool generation:
|
|
1953
|
+
* - omitted/true: auto-generate
|
|
1954
|
+
* - false: skip
|
|
1955
|
+
* - object: explicit config
|
|
1956
|
+
*/
|
|
1957
|
+
readonly mcp?: boolean | RouteMcpConfig;
|
|
1958
|
+
}
|
|
1959
|
+
/** Action config: bare handler function OR full ActionDefinition. */
|
|
1960
|
+
type ActionEntry = ActionHandlerFn | ActionDefinition;
|
|
1961
|
+
/** Actions configuration map. */
|
|
1962
|
+
type ActionsMap = Record<string, ActionEntry>;
|
|
1963
|
+
/**
|
|
1964
|
+
* Hook context passed to resource-level hook handlers. Mirrors
|
|
1965
|
+
* HookSystem's HookContext but with a simpler API for inline use.
|
|
1966
|
+
*
|
|
1967
|
+
* **v2.10.8:** `context` and a first-class `scope` projection are now
|
|
1968
|
+
* forwarded from the internal `HookContext`. Before this release, inline
|
|
1969
|
+
* `config.hooks` handlers had no way to reach the caller's tenant or
|
|
1970
|
+
* user info — they had to bypass the documented API and push directly
|
|
1971
|
+
* into `resource._pendingHooks` to get the raw internal shape. Now the
|
|
1972
|
+
* documented DX is complete:
|
|
1973
|
+
*
|
|
1974
|
+
* ```ts
|
|
1975
|
+
* hooks: {
|
|
1976
|
+
* afterCreate: (ctx) => {
|
|
1977
|
+
* auditLog.write({
|
|
1978
|
+
* org: ctx.scope?.organizationId,
|
|
1979
|
+
* actor: ctx.scope?.userId,
|
|
1980
|
+
* id: ctx.data._id,
|
|
1981
|
+
* });
|
|
1982
|
+
* },
|
|
1983
|
+
* }
|
|
1984
|
+
* ```
|
|
1985
|
+
*
|
|
1986
|
+
* The `scope` projection matches `IRequestContext.scope` (2.10.6) so
|
|
1987
|
+
* hosts read tenant/user the same way across controllers and hooks.
|
|
1988
|
+
* Use `context._scope` directly for advanced cases that need to
|
|
1989
|
+
* discriminate on `scope.kind` or reach auth-adapter-specific fields.
|
|
1990
|
+
*/
|
|
1991
|
+
interface ResourceHookContext {
|
|
1992
|
+
/** The document data (create/update body, or existing doc for delete / after-result) */
|
|
1993
|
+
data: AnyRecord;
|
|
1994
|
+
/** Authenticated user or null */
|
|
1995
|
+
user?: UserBase;
|
|
1996
|
+
/**
|
|
1997
|
+
* Full typed request context — includes `_scope`, `_policyFilters`,
|
|
1998
|
+
* `arc` metadata. Use `ctx.scope` for the common tenant/user projection;
|
|
1999
|
+
* reach for `ctx.context` when you need `_scope.kind` branching or
|
|
2000
|
+
* custom fields set by your auth adapter.
|
|
2001
|
+
*/
|
|
2002
|
+
context?: AnyRecord;
|
|
2003
|
+
/**
|
|
2004
|
+
* First-class projection of request scope — `{ organizationId?, userId?, orgRoles? }`.
|
|
2005
|
+
* Populated for every scoped request so multi-tenant hooks don't have to
|
|
2006
|
+
* drill into `context._scope.organizationId` themselves. Matches the
|
|
2007
|
+
* identically-named field on `IRequestContext` (v2.10.6) so the same
|
|
2008
|
+
* read pattern works in controllers and hooks.
|
|
2009
|
+
*/
|
|
2010
|
+
scope?: {
|
|
2011
|
+
organizationId?: string;
|
|
2012
|
+
userId?: string;
|
|
2013
|
+
orgRoles?: string[];
|
|
2014
|
+
};
|
|
2015
|
+
/** Additional metadata (e.g. `{ id, existing }` for update/delete) */
|
|
2016
|
+
meta?: AnyRecord;
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Inline lifecycle hooks on a resource definition. Wired into the
|
|
2020
|
+
* HookSystem automatically — same pipeline as presets and app-level hooks.
|
|
2021
|
+
*
|
|
2022
|
+
* @example
|
|
2023
|
+
* ```typescript
|
|
2024
|
+
* defineResource({
|
|
2025
|
+
* name: 'chat',
|
|
2026
|
+
* hooks: {
|
|
2027
|
+
* afterCreate: async (ctx) => { analytics.track('chat.created', { id: ctx.data._id }); },
|
|
2028
|
+
* beforeDelete: async (ctx) => {
|
|
2029
|
+
* if (ctx.data.isProtected) throw new Error('Cannot delete protected chat');
|
|
2030
|
+
* },
|
|
2031
|
+
* },
|
|
2032
|
+
* });
|
|
2033
|
+
* ```
|
|
2034
|
+
*/
|
|
2035
|
+
interface ResourceHooks {
|
|
2036
|
+
beforeCreate?: (ctx: ResourceHookContext) => Promise<AnyRecord | void> | AnyRecord | void;
|
|
2037
|
+
afterCreate?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
2038
|
+
beforeUpdate?: (ctx: ResourceHookContext) => Promise<AnyRecord | void> | AnyRecord | void;
|
|
2039
|
+
afterUpdate?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
2040
|
+
beforeDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
2041
|
+
afterDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
|
|
2042
|
+
}
|
|
2043
|
+
interface PresetHook {
|
|
2044
|
+
operation: "create" | "update" | "delete" | "read" | "list";
|
|
2045
|
+
phase: "before" | "after";
|
|
2046
|
+
handler: (ctx: AnyRecord) => void | Promise<void> | AnyRecord | Promise<AnyRecord>;
|
|
2047
|
+
priority?: number;
|
|
2048
|
+
}
|
|
2049
|
+
interface PresetResult {
|
|
2050
|
+
name: string;
|
|
2051
|
+
/** Preset routes — merged into the resource's `routes` array. */
|
|
2052
|
+
routes?: RouteDefinition[] | ((permissions: ResourcePermissions) => RouteDefinition[]);
|
|
2053
|
+
middlewares?: MiddlewareConfig;
|
|
2054
|
+
schemaOptions?: RouteSchemaOptions;
|
|
2055
|
+
controllerOptions?: Record<string, unknown>;
|
|
2056
|
+
hooks?: PresetHook[];
|
|
2057
|
+
}
|
|
2058
|
+
type PresetFunction = (config: ResourceConfig) => PresetResult;
|
|
2059
|
+
interface EventDefinition {
|
|
2060
|
+
name: string;
|
|
2061
|
+
/** Optional handler — events are published via `fastify.events.publish()`. */
|
|
2062
|
+
handler?: (data: unknown) => Promise<void> | void;
|
|
2063
|
+
/** JSON schema for event payload */
|
|
2064
|
+
schema?: Record<string, unknown>;
|
|
2065
|
+
description?: string;
|
|
2066
|
+
}
|
|
2067
|
+
/** Resource-level permissions — only `PermissionCheck` functions allowed. */
|
|
2068
|
+
interface ResourcePermissions {
|
|
2069
|
+
list?: PermissionCheck;
|
|
2070
|
+
get?: PermissionCheck;
|
|
2071
|
+
create?: PermissionCheck;
|
|
2072
|
+
update?: PermissionCheck;
|
|
2073
|
+
delete?: PermissionCheck;
|
|
2074
|
+
}
|
|
2075
|
+
interface ResourceConfig<TDoc = AnyRecord> {
|
|
2076
|
+
name: string;
|
|
2077
|
+
displayName?: string;
|
|
2078
|
+
tag?: string;
|
|
2079
|
+
/** Defaults to `/${name}s` if not provided. */
|
|
2080
|
+
prefix?: string;
|
|
2081
|
+
/**
|
|
2082
|
+
* Skip the global `resourcePrefix` from `createApp()`. The resource
|
|
2083
|
+
* registers at its own `prefix` (or `/${name}s`) directly on root.
|
|
2084
|
+
* Useful for webhooks, health, admin routes that shouldn't be under
|
|
2085
|
+
* `/api/v1`.
|
|
2086
|
+
*
|
|
2087
|
+
* @example
|
|
2088
|
+
* ```typescript
|
|
2089
|
+
* defineResource({ name: 'webhook', prefix: '/webhooks', skipGlobalPrefix: true })
|
|
2090
|
+
* ```
|
|
2091
|
+
*/
|
|
2092
|
+
skipGlobalPrefix?: boolean;
|
|
2093
|
+
/** Optional for service-pattern resources */
|
|
2094
|
+
adapter?: DataAdapter<TDoc>;
|
|
2095
|
+
/** Controller instance — accepts any object with CRUD methods. */
|
|
2096
|
+
controller?: IController<TDoc> | ControllerLike;
|
|
2097
|
+
queryParser?: unknown;
|
|
2098
|
+
permissions?: ResourcePermissions;
|
|
2099
|
+
schemaOptions?: RouteSchemaOptions;
|
|
2100
|
+
openApiSchemas?: OpenApiSchemas;
|
|
2101
|
+
/** Custom JSON schemas (override Arc-generated). */
|
|
2102
|
+
customSchemas?: Partial<CrudSchemas>;
|
|
2103
|
+
/** Preset names, objects, or PresetResult values. */
|
|
2104
|
+
presets?: Array<string | PresetResult | {
|
|
2105
|
+
name: string;
|
|
2106
|
+
[key: string]: unknown;
|
|
2107
|
+
}>;
|
|
2108
|
+
hooks?: ResourceHooks;
|
|
2109
|
+
/**
|
|
2110
|
+
* Functional pipeline — guards, transforms, interceptors. Flat array
|
|
2111
|
+
* (all operations) or per-operation map.
|
|
2112
|
+
*
|
|
2113
|
+
* @example
|
|
2114
|
+
* ```typescript
|
|
2115
|
+
* pipe: pipe(isActive, slugify, timing),
|
|
2116
|
+
* pipe: { create: pipe(isActive, slugify), list: pipe(timing) },
|
|
2117
|
+
* ```
|
|
2118
|
+
*/
|
|
2119
|
+
pipe?: PipelineConfig;
|
|
2120
|
+
/**
|
|
2121
|
+
* Field-level permissions — control visibility and writability per role.
|
|
2122
|
+
*
|
|
2123
|
+
* @example
|
|
2124
|
+
* ```typescript
|
|
2125
|
+
* fields: {
|
|
2126
|
+
* salary: fields.visibleTo(['admin', 'hr']),
|
|
2127
|
+
* password: fields.hidden(),
|
|
2128
|
+
* }
|
|
2129
|
+
* ```
|
|
2130
|
+
*/
|
|
2131
|
+
fields?: FieldPermissionMap;
|
|
2132
|
+
/**
|
|
2133
|
+
* Policy for requests that include fields the caller can't write.
|
|
2134
|
+
*
|
|
2135
|
+
* - `'reject'` (default, secure): 403 with the denied field names.
|
|
2136
|
+
* Surfaces misconfigurations and write-side permission violations
|
|
2137
|
+
* instead of silently dropping them.
|
|
2138
|
+
* - `'strip'`: legacy silent-drop behaviour — only opt in when migrating
|
|
2139
|
+
* pre-2.9 code that relied on the permissive default.
|
|
2140
|
+
*/
|
|
2141
|
+
onFieldWriteDenied?: "reject" | "strip";
|
|
2142
|
+
middlewares?: MiddlewareConfig;
|
|
2143
|
+
/**
|
|
2144
|
+
* PreHandler guards auto-applied to **every** route on this resource
|
|
2145
|
+
* (CRUD + custom + preset). Runs after auth/permissions, before
|
|
2146
|
+
* per-route `preHandler`. Use for mode gates, tenant checks, feature
|
|
2147
|
+
* flags — anything that applies to every endpoint.
|
|
2148
|
+
*/
|
|
2149
|
+
routeGuards?: RouteHandlerMethod[];
|
|
2150
|
+
/**
|
|
2151
|
+
* Custom routes beyond CRUD. Presets also merge their routes here.
|
|
2152
|
+
*
|
|
2153
|
+
* @example
|
|
2154
|
+
* ```typescript
|
|
2155
|
+
* routes: [
|
|
2156
|
+
* { method: 'GET', path: '/stats', handler: 'getStats', permissions: auth() },
|
|
2157
|
+
* { method: 'POST', path: '/webhook', handler: webhookFn, raw: true, permissions: auth() },
|
|
2158
|
+
* ]
|
|
2159
|
+
* ```
|
|
2160
|
+
*/
|
|
2161
|
+
routes?: RouteDefinition[];
|
|
2162
|
+
/**
|
|
2163
|
+
* State-transition actions → unified `POST /:id/action` endpoint.
|
|
2164
|
+
* Each action can be a bare handler or full config with permissions
|
|
2165
|
+
* + schema.
|
|
2166
|
+
*
|
|
2167
|
+
* @example
|
|
2168
|
+
* ```typescript
|
|
2169
|
+
* actions: {
|
|
2170
|
+
* approve: async (id, data, req) => service.approve(id, req.user._id),
|
|
2171
|
+
* cancel: {
|
|
2172
|
+
* handler: async (id, data, req) => service.cancel(id, data.reason, req.user._id),
|
|
2173
|
+
* permissions: roles('admin'),
|
|
2174
|
+
* schema: { reason: { type: 'string' } },
|
|
2175
|
+
* },
|
|
2176
|
+
* },
|
|
2177
|
+
* actionPermissions: auth(),
|
|
2178
|
+
* ```
|
|
2179
|
+
*/
|
|
2180
|
+
actions?: ActionsMap;
|
|
2181
|
+
/**
|
|
2182
|
+
* Fallback permission for actions without per-action permissions.
|
|
2183
|
+
* Only applies when `actions` is defined.
|
|
2184
|
+
*/
|
|
2185
|
+
actionPermissions?: PermissionCheck;
|
|
2186
|
+
disableCrud?: boolean;
|
|
2187
|
+
disableDefaultRoutes?: boolean;
|
|
2188
|
+
/** Specific routes to disable */
|
|
2189
|
+
disabledRoutes?: CrudRouteKey[];
|
|
2190
|
+
/**
|
|
2191
|
+
* Field name used for multi-tenant scoping (default: 'organizationId').
|
|
2192
|
+
* Override to match your schema: 'workspaceId', 'tenantId', etc.
|
|
1405
2193
|
*/
|
|
1406
2194
|
tenantField?: string | false;
|
|
2195
|
+
/**
|
|
2196
|
+
* Default sort applied to `list` responses when the request doesn't
|
|
2197
|
+
* specify one. Arc's built-in default is `-createdAt` (Mongo convention).
|
|
2198
|
+
*
|
|
2199
|
+
* - `string` — override (e.g. `'-created_at'`, `'-id'`).
|
|
2200
|
+
* - `false` — disable the default entirely. The adapter returns rows
|
|
2201
|
+
* in its native order (primary-key order on most kits). **Use this
|
|
2202
|
+
* for SQL/Drizzle resources that don't declare a `createdAt`
|
|
2203
|
+
* column** — without it, the framework default would compile to
|
|
2204
|
+
* `ORDER BY "createdAt" DESC` against a missing column.
|
|
2205
|
+
*
|
|
2206
|
+
* @example
|
|
2207
|
+
* ```ts
|
|
2208
|
+
* defineResource({ name: 'metric', defaultSort: '-recordedAt' });
|
|
2209
|
+
* defineResource({ name: 'tag', defaultSort: false }); // no default sort
|
|
2210
|
+
* ```
|
|
2211
|
+
*/
|
|
2212
|
+
defaultSort?: string | false;
|
|
1407
2213
|
/**
|
|
1408
2214
|
* Primary key field name (default: '_id').
|
|
1409
2215
|
*
|
|
@@ -1469,10 +2275,27 @@ interface ResourceConfig<TDoc = AnyRecord> {
|
|
|
1469
2275
|
//#endregion
|
|
1470
2276
|
//#region src/core/defineResource.d.ts
|
|
1471
2277
|
/**
|
|
1472
|
-
* Define a resource with database adapter
|
|
2278
|
+
* Define a resource with database adapter.
|
|
2279
|
+
*
|
|
2280
|
+
* This is the MAIN entry point for creating Arc resources — the adapter
|
|
2281
|
+
* provides both repository and schema metadata.
|
|
2282
|
+
*
|
|
2283
|
+
* Staged into seven named phases so future refactors touch one phase at a
|
|
2284
|
+
* time instead of threading changes through a 450-line function:
|
|
2285
|
+
*
|
|
2286
|
+
* 1. validate — fail-fast structural checks
|
|
2287
|
+
* 2. resolveIdField — auto-derive `idField` from repository
|
|
2288
|
+
* 3. applyPresetsAndAutoInject — clone + apply presets + tenant-field rules
|
|
2289
|
+
* 4. resolveController — reuse user controller or auto-create BaseController
|
|
2290
|
+
* 5. buildResource — construct ResourceDefinition + validate methods
|
|
2291
|
+
* 6. wireHooks — push preset + inline `config.hooks` onto _pendingHooks
|
|
2292
|
+
* 7. resolveOpenApiSchemas — adapter schemas → parser listQuery → user override
|
|
1473
2293
|
*
|
|
1474
|
-
*
|
|
1475
|
-
*
|
|
2294
|
+
* Each phase has a single responsibility; `resolvedConfig` is the canonical
|
|
2295
|
+
* post-preset, post-auto-inject config that every later phase reads. Raw
|
|
2296
|
+
* `config` is only consulted for things presets don't touch (adapter,
|
|
2297
|
+
* skipRegistry, skipValidation, hooks — which are wired separately from
|
|
2298
|
+
* preset hooks).
|
|
1476
2299
|
*/
|
|
1477
2300
|
declare function defineResource<TDoc = AnyRecord>(config: ResourceConfig<TDoc>): ResourceDefinition<TDoc>;
|
|
1478
2301
|
interface ResolvedResourceConfig<TDoc = AnyRecord> extends ResourceConfig<TDoc> {
|
|
@@ -1529,7 +2352,7 @@ declare class ResourceDefinition<TDoc = AnyRecord> {
|
|
|
1529
2352
|
_registryMeta?: RegisterOptions;
|
|
1530
2353
|
constructor(config: ResolvedResourceConfig<TDoc>);
|
|
1531
2354
|
/** Get repository from adapter (if available) */
|
|
1532
|
-
get repository():
|
|
2355
|
+
get repository(): RepositoryLike<TDoc> | undefined;
|
|
1533
2356
|
_validateControllerMethods(): void;
|
|
1534
2357
|
toPlugin(): FastifyPluginAsync;
|
|
1535
2358
|
/**
|
|
@@ -1682,178 +2505,6 @@ type FastifyWithDecorators = FastifyInstance & {
|
|
|
1682
2505
|
/** Handler signature for middleware functions. */
|
|
1683
2506
|
type MiddlewareHandler = (request: RequestWithExtras, reply: FastifyReply) => Promise<unknown>;
|
|
1684
2507
|
//#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;
|
|
1753
|
-
/**
|
|
1754
|
-
* Issue access + refresh tokens for a user. App calls this after
|
|
1755
|
-
* validating credentials.
|
|
1756
|
-
*/
|
|
1757
|
-
issueTokens: (payload: Record<string, unknown>, options?: {
|
|
1758
|
-
expiresIn?: string;
|
|
1759
|
-
refreshExpiresIn?: string;
|
|
1760
|
-
}) => TokenPair;
|
|
1761
|
-
/** Verify a refresh token and return decoded payload. */
|
|
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 {
|
|
1784
|
-
/**
|
|
1785
|
-
* JWT configuration (optional but recommended). If provided, JWT
|
|
1786
|
-
* utilities are available in the authenticator context.
|
|
1787
|
-
*/
|
|
1788
|
-
jwt?: {
|
|
1789
|
-
/** JWT secret (required for JWT features) */secret: string; /** Access token expiry (default: '15m') */
|
|
1790
|
-
expiresIn?: string; /** Refresh token secret (defaults to main secret) */
|
|
1791
|
-
refreshSecret?: string; /** Refresh token expiry (default: '7d') */
|
|
1792
|
-
refreshExpiresIn?: string; /** Additional `@fastify/jwt` sign options */
|
|
1793
|
-
sign?: Record<string, unknown>; /** Additional `@fastify/jwt` verify options */
|
|
1794
|
-
verify?: Record<string, unknown>;
|
|
1795
|
-
};
|
|
1796
|
-
/**
|
|
1797
|
-
* Custom authenticator function. Arc calls this for non-public routes.
|
|
1798
|
-
* If not provided and `jwt.secret` is set, uses default `jwtVerify`.
|
|
1799
|
-
*/
|
|
1800
|
-
authenticate?: Authenticator;
|
|
1801
|
-
/**
|
|
1802
|
-
* Custom auth failure handler. Customize the 401 response when
|
|
1803
|
-
* authentication fails.
|
|
1804
|
-
*/
|
|
1805
|
-
onFailure?: (request: FastifyRequest, reply: FastifyReply, error?: Error) => void | Promise<void>;
|
|
1806
|
-
/**
|
|
1807
|
-
* Expose detailed auth error messages in 401 responses. When `false`
|
|
1808
|
-
* (default), returns generic "Authentication required". Decoupled from
|
|
1809
|
-
* log level — set explicitly per environment.
|
|
1810
|
-
*/
|
|
1811
|
-
exposeAuthErrors?: boolean;
|
|
1812
|
-
/** Property name to store user on request (default: 'user') */
|
|
1813
|
-
userProperty?: string;
|
|
1814
|
-
/**
|
|
1815
|
-
* Custom token extractor for the built-in JWT auth path. Defaults to
|
|
1816
|
-
* extracting Bearer token from Authorization header. Use when tokens
|
|
1817
|
-
* are in HttpOnly cookies, custom headers, or query params.
|
|
1818
|
-
*
|
|
1819
|
-
* @example
|
|
1820
|
-
* ```typescript
|
|
1821
|
-
* tokenExtractor: (request) => request.cookies?.['auth-token'] ?? null,
|
|
1822
|
-
* ```
|
|
1823
|
-
*/
|
|
1824
|
-
tokenExtractor?: (request: FastifyRequest) => string | null;
|
|
1825
|
-
/**
|
|
1826
|
-
* Token revocation check — called after JWT verification succeeds.
|
|
1827
|
-
* Return `true` to reject the token (revoked), `false` to allow.
|
|
1828
|
-
*
|
|
1829
|
-
* **Fail-closed**: if the check throws, the token is treated as revoked.
|
|
1830
|
-
*
|
|
1831
|
-
* @example
|
|
1832
|
-
* ```typescript
|
|
1833
|
-
* isRevoked: async (decoded) => {
|
|
1834
|
-
* return await redis.sismember('revoked-tokens', decoded.jti ?? decoded.id);
|
|
1835
|
-
* },
|
|
1836
|
-
* ```
|
|
1837
|
-
*/
|
|
1838
|
-
isRevoked?: (decoded: Record<string, unknown>) => boolean | Promise<boolean>;
|
|
1839
|
-
/**
|
|
1840
|
-
* Enforce strict JWT `type` claim validation (default: `true`).
|
|
1841
|
-
*
|
|
1842
|
-
* When enabled, `authenticate` requires `decoded.type === "access"`.
|
|
1843
|
-
* Tokens with a missing or unexpected `type` claim are rejected —
|
|
1844
|
-
* defence in depth for apps that reuse the JWT secret to sign other
|
|
1845
|
-
* token kinds (invite links, one-time verification codes).
|
|
1846
|
-
*
|
|
1847
|
-
* Arc's own `issueTokens` always sets `type: "access"` or
|
|
1848
|
-
* `type: "refresh"`, so this default is safe for Arc-generated tokens.
|
|
1849
|
-
*
|
|
1850
|
-
* Set to `false` ONLY when you must accept tokens signed without a
|
|
1851
|
-
* `type` claim (e.g. a legacy issuer you don't control). In that mode
|
|
1852
|
-
* Arc still rejects tokens explicitly marked `type: "refresh"`.
|
|
1853
|
-
*/
|
|
1854
|
-
strictTokenType?: boolean;
|
|
1855
|
-
}
|
|
1856
|
-
//#endregion
|
|
1857
2508
|
//#region src/types/plugins.d.ts
|
|
1858
2509
|
interface GracefulShutdownOptions {
|
|
1859
2510
|
timeout?: number;
|
|
@@ -1916,68 +2567,245 @@ interface CrudRouterOptions {
|
|
|
1916
2567
|
routeGuards?: RouteHandlerMethod[];
|
|
1917
2568
|
}
|
|
1918
2569
|
//#endregion
|
|
1919
|
-
//#region src/types/
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
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[];
|
|
2570
|
+
//#region src/types/query.d.ts
|
|
2571
|
+
/**
|
|
2572
|
+
* Request-shaped context object passed to controller methods. Apps and
|
|
2573
|
+
* adapters extend it freely via the index signature.
|
|
2574
|
+
*/
|
|
2575
|
+
interface RequestContext {
|
|
2576
|
+
operation?: string;
|
|
2577
|
+
user?: unknown;
|
|
2578
|
+
filters?: Record<string, unknown>;
|
|
2579
|
+
[key: string]: unknown;
|
|
1947
2580
|
}
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
/**
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
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")[];
|
|
2581
|
+
/**
|
|
2582
|
+
* Internal metadata shape injected by Arc's Fastify adapter. Extends
|
|
2583
|
+
* RequestContext with known internal fields so controllers can access
|
|
2584
|
+
* them without `as AnyRecord` casts.
|
|
2585
|
+
*/
|
|
2586
|
+
interface ArcInternalMetadata extends RequestContext {
|
|
2587
|
+
/** Policy filters from permission middleware */
|
|
2588
|
+
_policyFilters?: Record<string, unknown>;
|
|
2589
|
+
/** Request scope from scope resolution */
|
|
2590
|
+
_scope?: RequestScope;
|
|
2591
|
+
/** Ownership check config from ownedByUser preset */
|
|
2592
|
+
_ownershipCheck?: {
|
|
2593
|
+
field: string;
|
|
2594
|
+
userId: string;
|
|
1979
2595
|
};
|
|
1980
|
-
/**
|
|
2596
|
+
/** Arc instance references (hooks, field permissions, etc.) */
|
|
2597
|
+
arc?: {
|
|
2598
|
+
hooks?: HookSystem;
|
|
2599
|
+
fields?: FieldPermissionMap;
|
|
2600
|
+
[key: string]: unknown;
|
|
2601
|
+
};
|
|
2602
|
+
}
|
|
2603
|
+
/**
|
|
2604
|
+
* Controller-level query options — parsed from request query string.
|
|
2605
|
+
* Includes pagination, filtering, populate/lookup, and context data.
|
|
2606
|
+
*/
|
|
2607
|
+
interface ControllerQueryOptions {
|
|
2608
|
+
page?: number;
|
|
2609
|
+
limit?: number;
|
|
2610
|
+
sort?: string | Record<string, 1 | -1>;
|
|
2611
|
+
/** Simple populate (comma-separated string or array) */
|
|
2612
|
+
populate?: string | string[] | Record<string, unknown>;
|
|
2613
|
+
/**
|
|
2614
|
+
* Advanced populate options (Mongoose-compatible). When set, takes
|
|
2615
|
+
* precedence over simple `populate`.
|
|
2616
|
+
*/
|
|
2617
|
+
populateOptions?: PopulateOption[];
|
|
2618
|
+
/**
|
|
2619
|
+
* Lookup/join options (database-agnostic). MongoKit maps these to
|
|
2620
|
+
* `$lookup`; future SQL adapters would map to JOINs.
|
|
2621
|
+
*
|
|
2622
|
+
* @example
|
|
2623
|
+
* URL: ?lookup[category][from]=categories&lookup[category][localField]=categorySlug&lookup[category][foreignField]=slug
|
|
2624
|
+
*/
|
|
2625
|
+
lookups?: LookupOption[];
|
|
2626
|
+
select?: string | string[] | Record<string, 0 | 1>;
|
|
2627
|
+
filters?: Record<string, unknown>;
|
|
2628
|
+
search?: string;
|
|
2629
|
+
lean?: boolean;
|
|
2630
|
+
after?: string;
|
|
2631
|
+
user?: unknown;
|
|
2632
|
+
context?: Record<string, unknown>;
|
|
2633
|
+
[key: string]: unknown;
|
|
2634
|
+
}
|
|
2635
|
+
/**
|
|
2636
|
+
* Database-agnostic lookup/join option. Parsed from URL:
|
|
2637
|
+
* `?lookup[alias][from]=...&lookup[alias][localField]=...&lookup[alias][foreignField]=...`
|
|
2638
|
+
*/
|
|
2639
|
+
interface LookupOption {
|
|
2640
|
+
/** Source collection/table to join from */
|
|
2641
|
+
from: string;
|
|
2642
|
+
/** Local field to match on */
|
|
2643
|
+
localField: string;
|
|
2644
|
+
/** Foreign field to match on */
|
|
2645
|
+
foreignField: string;
|
|
2646
|
+
/** Alias for the joined data (defaults to the lookup key) */
|
|
2647
|
+
as?: string;
|
|
2648
|
+
/** Return a single object instead of array (default: false) */
|
|
2649
|
+
single?: boolean;
|
|
2650
|
+
/** Field selection on the joined collection */
|
|
2651
|
+
select?: string | Record<string, 0 | 1>;
|
|
2652
|
+
}
|
|
2653
|
+
/**
|
|
2654
|
+
* Mongoose-compatible populate option for advanced field selection.
|
|
2655
|
+
*
|
|
2656
|
+
* @example
|
|
2657
|
+
* ```typescript
|
|
2658
|
+
* // URL: ?populate[author][select]=name,email
|
|
2659
|
+
* // Generates: { path: 'author', select: 'name email' }
|
|
2660
|
+
* ```
|
|
2661
|
+
*/
|
|
2662
|
+
interface PopulateOption {
|
|
2663
|
+
/** Field path to populate */
|
|
2664
|
+
path: string;
|
|
2665
|
+
/** Fields to select (space-separated) */
|
|
2666
|
+
select?: string;
|
|
2667
|
+
/** Filter conditions for populated documents */
|
|
2668
|
+
match?: Record<string, unknown>;
|
|
2669
|
+
/** Query options (limit, sort, skip) */
|
|
2670
|
+
options?: {
|
|
2671
|
+
limit?: number;
|
|
2672
|
+
sort?: Record<string, 1 | -1>;
|
|
2673
|
+
skip?: number;
|
|
2674
|
+
};
|
|
2675
|
+
/** Nested populate configuration */
|
|
2676
|
+
populate?: PopulateOption;
|
|
2677
|
+
}
|
|
2678
|
+
/**
|
|
2679
|
+
* Parsed query result from QueryParser. The index signature lets custom
|
|
2680
|
+
* parsers (MongoKit, PrismaKit) add fields without breaking Arc's types.
|
|
2681
|
+
*/
|
|
2682
|
+
interface ParsedQuery {
|
|
2683
|
+
filters?: Record<string, unknown>;
|
|
2684
|
+
limit?: number;
|
|
2685
|
+
sort?: string | Record<string, 1 | -1>;
|
|
2686
|
+
populate?: string | string[] | Record<string, unknown>;
|
|
2687
|
+
populateOptions?: PopulateOption[];
|
|
2688
|
+
lookups?: LookupOption[];
|
|
2689
|
+
search?: string;
|
|
2690
|
+
page?: number;
|
|
2691
|
+
after?: string;
|
|
2692
|
+
select?: string | string[] | Record<string, 0 | 1>;
|
|
2693
|
+
[key: string]: unknown;
|
|
2694
|
+
}
|
|
2695
|
+
/**
|
|
2696
|
+
* Query Parser interface. Implement to create custom query parsers.
|
|
2697
|
+
*
|
|
2698
|
+
* @example MongoKit
|
|
2699
|
+
* ```typescript
|
|
2700
|
+
* import { QueryParser } from '@classytic/mongokit';
|
|
2701
|
+
* const queryParser = new QueryParser();
|
|
2702
|
+
* ```
|
|
2703
|
+
*/
|
|
2704
|
+
interface QueryParserInterface {
|
|
2705
|
+
parse(query: Record<string, unknown> | null | undefined): ParsedQuery;
|
|
2706
|
+
/** Optional: Export OpenAPI schema for query parameters. */
|
|
2707
|
+
getQuerySchema?(): {
|
|
2708
|
+
type: "object";
|
|
2709
|
+
properties: Record<string, unknown>;
|
|
2710
|
+
required?: string[];
|
|
2711
|
+
};
|
|
2712
|
+
/**
|
|
2713
|
+
* Optional: Allowed filter fields whitelist. MCP auto-derives
|
|
2714
|
+
* `filterableFields` from this if `schemaOptions.filterableFields`
|
|
2715
|
+
* is not explicitly configured.
|
|
2716
|
+
*/
|
|
2717
|
+
allowedFilterFields?: readonly string[];
|
|
2718
|
+
/**
|
|
2719
|
+
* Optional: Allowed filter operators whitelist. Used by MCP to enrich
|
|
2720
|
+
* list-tool descriptions. Values are human-readable keys: 'eq', 'ne',
|
|
2721
|
+
* 'gt', 'gte', 'lt', 'lte', 'in', 'nin', etc.
|
|
2722
|
+
*/
|
|
2723
|
+
allowedOperators?: readonly string[];
|
|
2724
|
+
/**
|
|
2725
|
+
* Optional: Allowed sort fields whitelist. Used by MCP to describe
|
|
2726
|
+
* available sort options in list-tool descriptions.
|
|
2727
|
+
*/
|
|
2728
|
+
allowedSortFields?: readonly string[];
|
|
2729
|
+
}
|
|
2730
|
+
/** Ownership-check config used by `ownedByUser` preset / middleware. */
|
|
2731
|
+
interface OwnershipCheck {
|
|
2732
|
+
field: string;
|
|
2733
|
+
userField?: string;
|
|
2734
|
+
}
|
|
2735
|
+
/** Service-layer context — passed to repository / service calls. */
|
|
2736
|
+
interface ServiceContext {
|
|
2737
|
+
user?: unknown;
|
|
2738
|
+
requestId?: string;
|
|
2739
|
+
/** Field projection for responses */
|
|
2740
|
+
select?: string[] | Record<string, 0 | 1>;
|
|
2741
|
+
/** Relations to populate */
|
|
2742
|
+
populate?: string | string[];
|
|
2743
|
+
/** Return plain objects */
|
|
2744
|
+
lean?: boolean;
|
|
2745
|
+
}
|
|
2746
|
+
//#endregion
|
|
2747
|
+
//#region src/types/registry.d.ts
|
|
2748
|
+
interface ResourceMetadata {
|
|
2749
|
+
name: string;
|
|
2750
|
+
displayName?: string;
|
|
2751
|
+
tag?: string;
|
|
2752
|
+
prefix: string;
|
|
2753
|
+
module?: string;
|
|
2754
|
+
permissions?: ResourcePermissions;
|
|
2755
|
+
presets: string[];
|
|
2756
|
+
customRoutes?: Array<{
|
|
2757
|
+
method: string;
|
|
2758
|
+
path: string;
|
|
2759
|
+
handler: string;
|
|
2760
|
+
operation?: string;
|
|
2761
|
+
summary?: string;
|
|
2762
|
+
description?: string;
|
|
2763
|
+
permissions?: PermissionCheck;
|
|
2764
|
+
raw?: boolean;
|
|
2765
|
+
schema?: Record<string, unknown>;
|
|
2766
|
+
}>;
|
|
2767
|
+
routes: Array<{
|
|
2768
|
+
method: string;
|
|
2769
|
+
path: string;
|
|
2770
|
+
handler?: string;
|
|
2771
|
+
operation?: string;
|
|
2772
|
+
summary?: string;
|
|
2773
|
+
}>;
|
|
2774
|
+
events?: string[];
|
|
2775
|
+
}
|
|
2776
|
+
interface RegistryEntry extends ResourceMetadata {
|
|
2777
|
+
plugin: unknown;
|
|
2778
|
+
adapter?: {
|
|
2779
|
+
type: string;
|
|
2780
|
+
name: string;
|
|
2781
|
+
} | null;
|
|
2782
|
+
events?: string[];
|
|
2783
|
+
disableDefaultRoutes?: boolean;
|
|
2784
|
+
openApiSchemas?: OpenApiSchemas;
|
|
2785
|
+
registeredAt?: string;
|
|
2786
|
+
/** Field-level permissions metadata (for OpenAPI docs) */
|
|
2787
|
+
fieldPermissions?: Record<string, {
|
|
2788
|
+
type: string;
|
|
2789
|
+
roles?: readonly string[];
|
|
2790
|
+
redactValue?: unknown;
|
|
2791
|
+
}>;
|
|
2792
|
+
/** Pipeline step names (for OpenAPI docs) */
|
|
2793
|
+
pipelineSteps?: Array<{
|
|
2794
|
+
type: string;
|
|
2795
|
+
name: string;
|
|
2796
|
+
operations?: string[];
|
|
2797
|
+
}>;
|
|
2798
|
+
/** Update HTTP method(s) used for this resource */
|
|
2799
|
+
updateMethod?: "PUT" | "PATCH" | "both";
|
|
2800
|
+
/** Routes disabled for this resource */
|
|
2801
|
+
disabledRoutes?: string[];
|
|
2802
|
+
/** Rate limit config */
|
|
2803
|
+
rateLimit?: RateLimitConfig | false;
|
|
2804
|
+
/** Per-resource audit opt-in flag (read by `auditPlugin` perResource mode) */
|
|
2805
|
+
audit?: boolean | {
|
|
2806
|
+
operations?: ("create" | "update" | "delete")[];
|
|
2807
|
+
};
|
|
2808
|
+
/**
|
|
1981
2809
|
* v2.8 declarative actions metadata — populated from
|
|
1982
2810
|
* `ResourceConfig.actions`. Consumed by OpenAPI generation (renders
|
|
1983
2811
|
* `POST /:id/action` with a discriminated body schema) and MCP tool
|
|
@@ -2015,23 +2843,26 @@ interface IntrospectionData {
|
|
|
2015
2843
|
generatedAt?: string;
|
|
2016
2844
|
}
|
|
2017
2845
|
//#endregion
|
|
2018
|
-
//#region src/types/
|
|
2846
|
+
//#region src/types/repository.d.ts
|
|
2019
2847
|
/**
|
|
2020
|
-
*
|
|
2021
|
-
*
|
|
2848
|
+
* Discriminated union of pagination result shapes. Narrow on `method`.
|
|
2849
|
+
*
|
|
2850
|
+
* repo-core ships the individual shapes (`OffsetPaginationResult` /
|
|
2851
|
+
* `KeysetPaginationResult`) but no combined union — arc needs one for the
|
|
2852
|
+
* BaseController's `list` / `getDeleted` return signatures, where either
|
|
2853
|
+
* shape is valid depending on the caller's pagination params.
|
|
2854
|
+
*
|
|
2855
|
+
* @example
|
|
2856
|
+
* ```ts
|
|
2857
|
+
* const result = await repo.getAll(params);
|
|
2858
|
+
* if (result.method === 'keyset') {
|
|
2859
|
+
* result.next; // keyset cursor
|
|
2860
|
+
* } else {
|
|
2861
|
+
* result.page; // offset number
|
|
2862
|
+
* }
|
|
2863
|
+
* ```
|
|
2022
2864
|
*/
|
|
2023
|
-
|
|
2024
|
-
field: string;
|
|
2025
|
-
message: string;
|
|
2026
|
-
code?: string;
|
|
2027
|
-
}
|
|
2028
|
-
interface ValidationResult {
|
|
2029
|
-
valid: boolean;
|
|
2030
|
-
errors: ConfigError[];
|
|
2031
|
-
}
|
|
2032
|
-
interface ValidateOptions {
|
|
2033
|
-
strict?: boolean;
|
|
2034
|
-
}
|
|
2865
|
+
type PaginationResult<TDoc, TExtra extends Record<string, unknown> = Record<string, never>> = OffsetPaginationResult<TDoc, TExtra> | KeysetPaginationResult<TDoc, TExtra>;
|
|
2035
2866
|
//#endregion
|
|
2036
2867
|
//#region src/types/utility.d.ts
|
|
2037
2868
|
/**
|
|
@@ -2061,441 +2892,22 @@ type TypedResourceConfig<TDoc> = ResourceConfig<TDoc>;
|
|
|
2061
2892
|
type TypedController<TDoc> = IController<TDoc>;
|
|
2062
2893
|
type TypedRepository<TDoc> = StandardRepo<TDoc>;
|
|
2063
2894
|
//#endregion
|
|
2064
|
-
//#region src/types/
|
|
2895
|
+
//#region src/types/validation.d.ts
|
|
2065
2896
|
/**
|
|
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.
|
|
2072
|
-
*
|
|
2073
|
-
* @example
|
|
2074
|
-
* ```ts
|
|
2075
|
-
* const result = await repo.getAll(params);
|
|
2076
|
-
* if (result.method === 'keyset') {
|
|
2077
|
-
* result.next; // keyset cursor
|
|
2078
|
-
* } else {
|
|
2079
|
-
* result.page; // offset number
|
|
2080
|
-
* }
|
|
2081
|
-
* ```
|
|
2082
|
-
*/
|
|
2083
|
-
type PaginationResult<TDoc, TExtra extends Record<string, unknown> = Record<string, never>> = OffsetPaginationResult<TDoc, TExtra> | KeysetPaginationResult<TDoc, TExtra>;
|
|
2084
|
-
//#endregion
|
|
2085
|
-
//#region src/core/AccessControl.d.ts
|
|
2086
|
-
/** Denial reason codes returned by `fetchDetailed()`. */
|
|
2087
|
-
type FetchDenialReason = "NOT_FOUND" | "POLICY_FILTERED" | "ORG_SCOPE_DENIED";
|
|
2088
|
-
/** Result of a detailed fetch with access control. */
|
|
2089
|
-
interface FetchResult<TDoc> {
|
|
2090
|
-
/** The document, or null if denied. */
|
|
2091
|
-
doc: TDoc | null;
|
|
2092
|
-
/** Null when the doc was found. A string code when denied. */
|
|
2093
|
-
reason: FetchDenialReason | null;
|
|
2094
|
-
}
|
|
2095
|
-
interface AccessControlConfig {
|
|
2096
|
-
/** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable org filtering. */
|
|
2097
|
-
tenantField: string | false;
|
|
2098
|
-
/** Primary key field name (default: '_id') */
|
|
2099
|
-
idField: string;
|
|
2100
|
-
/**
|
|
2101
|
-
* Custom filter matching for policy enforcement.
|
|
2102
|
-
* Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
|
|
2103
|
-
* Falls back to built-in MongoDB-style matching if not provided.
|
|
2104
|
-
*/
|
|
2105
|
-
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
2106
|
-
}
|
|
2107
|
-
/** Minimal repository interface for access-controlled fetch operations */
|
|
2108
|
-
interface AccessControlRepository {
|
|
2109
|
-
getById(id: string, options?: QueryOptions): Promise<unknown>;
|
|
2110
|
-
getOne?: (filter: AnyRecord, options?: QueryOptions) => Promise<unknown>;
|
|
2111
|
-
}
|
|
2112
|
-
declare class AccessControl {
|
|
2113
|
-
private readonly tenantField;
|
|
2114
|
-
private readonly idField;
|
|
2115
|
-
private readonly _adapterMatchesFilter?;
|
|
2116
|
-
/** Patterns that indicate dangerous regex (nested quantifiers, excessive backtracking).
|
|
2117
|
-
* Uses [^...] character classes instead of .+ to avoid backtracking in the detector itself. */
|
|
2118
|
-
private static readonly DANGEROUS_REGEX;
|
|
2119
|
-
/** Forbidden paths that could lead to prototype pollution */
|
|
2120
|
-
private static readonly FORBIDDEN_PATHS;
|
|
2121
|
-
constructor(config: AccessControlConfig);
|
|
2122
|
-
/**
|
|
2123
|
-
* Build filter for single-item operations (get/update/delete)
|
|
2124
|
-
* Combines ID filter with policy/org filters for proper security enforcement
|
|
2125
|
-
*/
|
|
2126
|
-
buildIdFilter(id: string, req: IRequestContext): AnyRecord;
|
|
2127
|
-
/**
|
|
2128
|
-
* Check if item matches policy filters (for get/update/delete operations)
|
|
2129
|
-
* Validates that fetched item satisfies all policy constraints
|
|
2130
|
-
*
|
|
2131
|
-
* Delegates to adapter-provided matchesFilter if available (for SQL, etc.),
|
|
2132
|
-
* otherwise falls back to built-in MongoDB-style matching.
|
|
2133
|
-
*/
|
|
2134
|
-
checkPolicyFilters(item: AnyRecord, req: IRequestContext): boolean;
|
|
2135
|
-
/**
|
|
2136
|
-
* Check org/tenant scope for a document — uses configurable tenantField.
|
|
2137
|
-
*
|
|
2138
|
-
* SECURITY: When org scope is active (orgId present), documents that are
|
|
2139
|
-
* missing the tenant field are DENIED by default. This prevents legacy or
|
|
2140
|
-
* unscoped records from leaking across tenants.
|
|
2141
|
-
*/
|
|
2142
|
-
checkOrgScope(item: AnyRecord | null, arcContext: ArcInternalMetadata | RequestContext | undefined): boolean;
|
|
2143
|
-
/** Check ownership for update/delete (ownedByUser preset) */
|
|
2144
|
-
checkOwnership(item: AnyRecord | null, req: IRequestContext): boolean;
|
|
2145
|
-
/**
|
|
2146
|
-
* Fetch a single document with full access control enforcement.
|
|
2147
|
-
* Combines compound DB filter (ID + org + policy) with post-hoc fallback.
|
|
2148
|
-
*
|
|
2149
|
-
* Takes repository as a parameter to avoid coupling.
|
|
2150
|
-
*
|
|
2151
|
-
* Replaces the duplicated pattern in get/update/delete:
|
|
2152
|
-
* buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
|
|
2153
|
-
*/
|
|
2154
|
-
fetchWithAccessControl<TDoc>(id: string, req: IRequestContext, repository: AccessControlRepository, queryOptions?: QueryOptions): Promise<TDoc | null>;
|
|
2155
|
-
/**
|
|
2156
|
-
* Same as `fetchWithAccessControl` but returns a structured result with
|
|
2157
|
-
* a denial reason so callers can distinguish "doc doesn't exist" from
|
|
2158
|
-
* "doc exists but was filtered by policy/org scope" from "repo threw".
|
|
2159
|
-
*
|
|
2160
|
-
* Codes:
|
|
2161
|
-
* - `null` — doc was found, no denial
|
|
2162
|
-
* - `'NOT_FOUND'` — doc genuinely doesn't exist in the DB
|
|
2163
|
-
* - `'POLICY_FILTERED'` — doc exists but the request's policy filters exclude it
|
|
2164
|
-
* - `'ORG_SCOPE_DENIED'` — doc exists but the caller's org context doesn't match
|
|
2165
|
-
* - `'REPO_ERROR'` — the repository threw a "not found" error (mongokit style)
|
|
2166
|
-
*/
|
|
2167
|
-
fetchDetailed<TDoc>(id: string, req: IRequestContext, repository: AccessControlRepository, queryOptions?: QueryOptions): Promise<FetchResult<TDoc>>;
|
|
2168
|
-
/**
|
|
2169
|
-
* Post-fetch access control validation for items fetched by non-ID queries
|
|
2170
|
-
* (e.g., getBySlug, restore). Applies org scope, policy filters, and
|
|
2171
|
-
* ownership checks — the same guarantees as fetchWithAccessControl.
|
|
2172
|
-
*/
|
|
2173
|
-
validateItemAccess(item: AnyRecord | null, req: IRequestContext): boolean;
|
|
2174
|
-
/** Extract typed Arc internal metadata from request */
|
|
2175
|
-
private _meta;
|
|
2176
|
-
/**
|
|
2177
|
-
* Check if a value matches a MongoDB query operator
|
|
2178
|
-
*/
|
|
2179
|
-
private matchesOperator;
|
|
2180
|
-
/**
|
|
2181
|
-
* Check if item matches a single filter condition
|
|
2182
|
-
* Supports nested paths (e.g., "owner.id", "metadata.status")
|
|
2183
|
-
*/
|
|
2184
|
-
private matchesFilter;
|
|
2185
|
-
/**
|
|
2186
|
-
* Built-in MongoDB-style policy filter matching.
|
|
2187
|
-
* Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
|
|
2188
|
-
*/
|
|
2189
|
-
private defaultMatchesPolicyFilters;
|
|
2190
|
-
/**
|
|
2191
|
-
* Get nested value from object using dot notation (e.g., "owner.id")
|
|
2192
|
-
* Security: Validates path against forbidden patterns to prevent prototype pollution
|
|
2193
|
-
*/
|
|
2194
|
-
private getNestedValue;
|
|
2195
|
-
/**
|
|
2196
|
-
* Create a safe RegExp from a string, guarding against ReDoS.
|
|
2197
|
-
* Returns null if the pattern is invalid or dangerous.
|
|
2198
|
-
*/
|
|
2199
|
-
private static safeRegex;
|
|
2200
|
-
}
|
|
2201
|
-
//#endregion
|
|
2202
|
-
//#region src/core/BodySanitizer.d.ts
|
|
2203
|
-
/**
|
|
2204
|
-
* Policy for handling fields the caller lacks write permission for.
|
|
2205
|
-
*
|
|
2206
|
-
* - `'reject'` (default, secure): throw 403 listing the denied fields so
|
|
2207
|
-
* misconfigurations and attacks surface instead of silently disappearing.
|
|
2208
|
-
* - `'strip'` (legacy): silently drop the field and continue. Preserved for
|
|
2209
|
-
* apps that relied on the pre-2.9 behaviour — new code should not use it.
|
|
2897
|
+
* Validation result types — produced by `validateResourceConfig` and
|
|
2898
|
+
* consumed by `assertValidConfig` / `formatValidationErrors`.
|
|
2210
2899
|
*/
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
/**
|
|
2216
|
-
* What to do when a request contains fields the caller can't write.
|
|
2217
|
-
* Default: `'reject'` — surface the misconfiguration as a 403.
|
|
2218
|
-
*/
|
|
2219
|
-
onFieldWriteDenied?: FieldWriteDenialPolicy;
|
|
2220
|
-
}
|
|
2221
|
-
declare class BodySanitizer {
|
|
2222
|
-
private schemaOptions;
|
|
2223
|
-
private onFieldWriteDenied;
|
|
2224
|
-
constructor(config: BodySanitizerConfig);
|
|
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;
|
|
2233
|
-
}
|
|
2234
|
-
//#endregion
|
|
2235
|
-
//#region src/core/QueryResolver.d.ts
|
|
2236
|
-
interface QueryResolverConfig {
|
|
2237
|
-
/** Query parser instance (default: Arc built-in parser) */
|
|
2238
|
-
queryParser?: QueryParserInterface;
|
|
2239
|
-
/** Maximum limit for pagination (default: 100) */
|
|
2240
|
-
maxLimit?: number;
|
|
2241
|
-
/** Default limit for pagination (default: 20) */
|
|
2242
|
-
defaultLimit?: number;
|
|
2243
|
-
/** Default sort field (default: '-createdAt') */
|
|
2244
|
-
defaultSort?: string;
|
|
2245
|
-
/** Schema options for field sanitization */
|
|
2246
|
-
schemaOptions?: RouteSchemaOptions;
|
|
2247
|
-
/** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable. */
|
|
2248
|
-
tenantField?: string | false;
|
|
2249
|
-
}
|
|
2250
|
-
declare class QueryResolver {
|
|
2251
|
-
private queryParser;
|
|
2252
|
-
private maxLimit;
|
|
2253
|
-
private defaultLimit;
|
|
2254
|
-
private defaultSort;
|
|
2255
|
-
private schemaOptions;
|
|
2256
|
-
private tenantField;
|
|
2257
|
-
constructor(config?: QueryResolverConfig);
|
|
2258
|
-
/**
|
|
2259
|
-
* Resolve a request into parsed query options -- ONE parse per request.
|
|
2260
|
-
* Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
|
|
2261
|
-
*/
|
|
2262
|
-
resolve(req: IRequestContext, meta?: ArcInternalMetadata): ControllerQueryOptions;
|
|
2263
|
-
/**
|
|
2264
|
-
* Sanitize select — preserves the input format (string, array, or object).
|
|
2265
|
-
* This is critical for db-agnostic support: MongoKit returns object projections,
|
|
2266
|
-
* Mongoose uses space-separated strings, SQL adapters may use arrays.
|
|
2267
|
-
*/
|
|
2268
|
-
private sanitizeSelectAny;
|
|
2269
|
-
/** Sanitize populate fields */
|
|
2270
|
-
private sanitizePopulate;
|
|
2271
|
-
/** Sanitize advanced populate options against allowedPopulate */
|
|
2272
|
-
private sanitizePopulateOptions;
|
|
2273
|
-
/**
|
|
2274
|
-
* Sanitize lookup/join options.
|
|
2275
|
-
* If schemaOptions.query.allowedLookups is set, only those collections are allowed.
|
|
2276
|
-
* Validates lookup structure to prevent injection.
|
|
2277
|
-
*/
|
|
2278
|
-
private sanitizeLookups;
|
|
2279
|
-
/** Get blocked fields from schema options */
|
|
2280
|
-
private getBlockedFields;
|
|
2900
|
+
interface ConfigError {
|
|
2901
|
+
field: string;
|
|
2902
|
+
message: string;
|
|
2903
|
+
code?: string;
|
|
2281
2904
|
}
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
/** Schema options for field sanitization */
|
|
2286
|
-
schemaOptions?: RouteSchemaOptions;
|
|
2287
|
-
/**
|
|
2288
|
-
* Query parser instance.
|
|
2289
|
-
* Default: Arc built-in query parser (adapter-agnostic).
|
|
2290
|
-
* Swap in MongoKit QueryParser, pgkit parser, etc.
|
|
2291
|
-
*/
|
|
2292
|
-
queryParser?: QueryParserInterface;
|
|
2293
|
-
/** Maximum limit for pagination (default: 100) */
|
|
2294
|
-
maxLimit?: number;
|
|
2295
|
-
/** Default limit for pagination (default: 20) */
|
|
2296
|
-
defaultLimit?: number;
|
|
2297
|
-
/** Default sort field (default: '-createdAt') */
|
|
2298
|
-
defaultSort?: string;
|
|
2299
|
-
/** Resource name for hook execution (e.g., 'product' -> 'product.created') */
|
|
2300
|
-
resourceName?: string;
|
|
2301
|
-
/**
|
|
2302
|
-
* Field name used for multi-tenant scoping (default: 'organizationId').
|
|
2303
|
-
* Override to match your schema: 'workspaceId', 'tenantId', 'teamId', etc.
|
|
2304
|
-
* Set to `false` to disable org filtering for platform-universal resources.
|
|
2305
|
-
*/
|
|
2306
|
-
tenantField?: string | false;
|
|
2307
|
-
/**
|
|
2308
|
-
* Primary key field name (default: '_id').
|
|
2309
|
-
*
|
|
2310
|
-
* If not set, the controller auto-derives it from the repository's own
|
|
2311
|
-
* `idField` property (e.g. MongoKit's `Repository({ idField: 'id' })`),
|
|
2312
|
-
* so you only need to configure it in one place.
|
|
2313
|
-
*
|
|
2314
|
-
* Set explicitly to override the repo's setting (e.g. `'_id'` to opt out
|
|
2315
|
-
* of native pass-through and force the slug-translation path).
|
|
2316
|
-
*
|
|
2317
|
-
* Override for non-MongoDB adapters (e.g., 'id' for SQL databases).
|
|
2318
|
-
*/
|
|
2319
|
-
idField?: string;
|
|
2320
|
-
/**
|
|
2321
|
-
* Custom filter matching for policy enforcement.
|
|
2322
|
-
* Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
|
|
2323
|
-
* Falls back to built-in MongoDB-style matching if not provided.
|
|
2324
|
-
*/
|
|
2325
|
-
matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
|
|
2326
|
-
/** Cache configuration for the resource */
|
|
2327
|
-
cache?: ResourceCacheConfig;
|
|
2328
|
-
/** Internal preset fields map (slug, tree, etc.) */
|
|
2329
|
-
presetFields?: {
|
|
2330
|
-
slugField?: string;
|
|
2331
|
-
parentField?: string;
|
|
2332
|
-
};
|
|
2333
|
-
/**
|
|
2334
|
-
* Policy for requests that include fields the caller can't write.
|
|
2335
|
-
*
|
|
2336
|
-
* - `'reject'` (default): 403 with the denied field names. Surfaces
|
|
2337
|
-
* misconfigurations and attempts to set protected fields instead of
|
|
2338
|
-
* silently dropping them.
|
|
2339
|
-
* - `'strip'`: legacy silent-drop behaviour. Only opt in when migrating
|
|
2340
|
-
* code that relied on the pre-2.9 permissive default.
|
|
2341
|
-
*/
|
|
2342
|
-
onFieldWriteDenied?: FieldWriteDenialPolicy;
|
|
2905
|
+
interface ValidationResult {
|
|
2906
|
+
valid: boolean;
|
|
2907
|
+
errors: ConfigError[];
|
|
2343
2908
|
}
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
*
|
|
2347
|
-
* Composes AccessControl, BodySanitizer, and QueryResolver for clean
|
|
2348
|
-
* separation of concerns. CRUD methods delegate directly to these
|
|
2349
|
-
* composed classes — no intermediate wrapper methods.
|
|
2350
|
-
*
|
|
2351
|
-
* @template TDoc - The document type
|
|
2352
|
-
* @template TRepository - The repository type (defaults to RepositoryLike)
|
|
2353
|
-
*/
|
|
2354
|
-
declare class BaseController<TDoc = AnyRecord, TRepository extends RepositoryLike = RepositoryLike> implements IController<TDoc> {
|
|
2355
|
-
protected repository: TRepository;
|
|
2356
|
-
protected schemaOptions: RouteSchemaOptions;
|
|
2357
|
-
protected queryParser: QueryParserInterface;
|
|
2358
|
-
protected maxLimit: number;
|
|
2359
|
-
protected defaultLimit: number;
|
|
2360
|
-
protected defaultSort: string;
|
|
2361
|
-
protected resourceName?: string;
|
|
2362
|
-
protected tenantField: string | false;
|
|
2363
|
-
protected idField: string;
|
|
2364
|
-
/** Composable access control (ID filtering, policy checks, org scope, ownership) */
|
|
2365
|
-
readonly accessControl: AccessControl;
|
|
2366
|
-
/** Composable body sanitization (field permissions, system fields) */
|
|
2367
|
-
readonly bodySanitizer: BodySanitizer;
|
|
2368
|
-
/** Composable query resolution (parsing, pagination, sort, select/populate) */
|
|
2369
|
-
readonly queryResolver: QueryResolver;
|
|
2370
|
-
private _matchesFilter?;
|
|
2371
|
-
private _presetFields;
|
|
2372
|
-
private _cacheConfig?;
|
|
2373
|
-
constructor(repository: TRepository, options?: BaseControllerOptions);
|
|
2374
|
-
/**
|
|
2375
|
-
* Get the tenant field name if multi-tenant scoping is enabled.
|
|
2376
|
-
* Returns `undefined` when `tenantField` is `false` (platform-universal mode).
|
|
2377
|
-
*
|
|
2378
|
-
* Use this in subclass overrides instead of accessing `this.tenantField` directly
|
|
2379
|
-
* to avoid TypeScript indexing errors with `string | false`.
|
|
2380
|
-
*/
|
|
2381
|
-
protected getTenantField(): string | undefined;
|
|
2382
|
-
/** Extract typed Arc internal metadata from request */
|
|
2383
|
-
private meta;
|
|
2384
|
-
/** Get hook system from request context (instance-scoped) */
|
|
2385
|
-
private getHooks;
|
|
2386
|
-
/**
|
|
2387
|
-
* Resolve the repository primary key for mutation calls (update/delete/restore).
|
|
2388
|
-
*
|
|
2389
|
-
* When the resource declares a custom `idField` (e.g. `slug`, `jobId`, UUID),
|
|
2390
|
-
* the default behavior is to translate the route id → the fetched doc's `_id`
|
|
2391
|
-
* because most Mongo repositories key their mutation methods off `_id`.
|
|
2392
|
-
*
|
|
2393
|
-
* Exception: if the repository itself exposes a matching `idField` property
|
|
2394
|
-
* (e.g. MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
|
|
2395
|
-
* repository already knows how to look up by that field — so we pass the
|
|
2396
|
-
* route id through unchanged and skip the translation.
|
|
2397
|
-
*
|
|
2398
|
-
* This makes `defineResource({ idField: 'id' })` work end-to-end with repos
|
|
2399
|
-
* that natively support custom primary keys, without breaking the slug-style
|
|
2400
|
-
* aliasing that Arc 2.6.3 introduced for repos keyed on `_id`.
|
|
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
|
-
}>>;
|
|
2909
|
+
interface ValidateOptions {
|
|
2910
|
+
strict?: boolean;
|
|
2499
2911
|
}
|
|
2500
2912
|
//#endregion
|
|
2501
|
-
export {
|
|
2913
|
+
export { OpenApiSchemas as $, ArcListResult as $t, RequestIdOptions as A, RelationMetadata as An, Authenticator as At, ResourceDefinition as B, UserOrganization as Bt, RequestContext as C, beforeUpdate as Cn, OperationFilter as Ct, HealthCheck as D, AdapterSchemaContext as Dn, Transform as Dt, GracefulShutdownOptions as E, AdapterFactory as En, PipelineStep as Et, FastifyWithDecorators as F, ApiResponse as Ft, ActionsMap as G, TreeMixin as Gt, ActionDefinition as H, SoftDeleteExt as Ht, MiddlewareHandler as I, ArcRequest as It, CrudRouteKey as J, BulkExt as Jt, ArcFieldRule as K, SlugExt as Kt, RequestWithExtras as L, JWTPayload as Lt, EventsDecorator as M, SchemaMetadata as Mn, JwtContext as Mt, FastifyRequestExtras as N, ValidationResult$1 as Nn, TokenPair as Nt, HealthOptions as O, DataAdapter as On, AuthHelpers as Ot, FastifyWithAuth as P, AnyRecord as Pt, MiddlewareConfig as Q, ArcGetResult as Qt, RegisterOptions as R, ObjectId as Rt, QueryParserInterface as S, beforeDelete as Sn, NextFunction as St, CrudRouterOptions as T, defineHook as Tn, PipelineContext as Tt, ActionEntry as U, SoftDeleteMixin as Ut, defineResource as V, BaseController as Vt, ActionHandlerFn as W, TreeExt as Wt, EventDefinition as X, ArcCreateResult as Xt, CrudSchemas as Y, BulkMixin as Yt, FieldRule$1 as Z, ArcDeleteResult as Zt, ControllerQueryOptions as _, HookSystemOptions as _n, IControllerResponse as _t, InferAdapterDoc as a, QueryResolverConfig as an, ResourceConfig as at, ParsedQuery as b, afterUpdate as bn, Guard as bt, TypedController as c, AccessControl as cn, ResourcePermissions as ct, PaginationResult as d, HookContext as dn, RouteMethod as dt, ArcUpdateResult as en, PresetFunction as et, IntrospectionData as f, HookHandler as fn, RouteSchemaOptions as ft, ArcInternalMetadata as g, HookSystem as gn, IController as gt, ResourceMetadata as h, HookRegistration as hn, FastifyHandler as ht, ValidationResult as i, QueryResolver as in, ResourceCacheConfig as it, ArcDecorator as j, RepositoryLike as jn, AuthenticatorContext as jt, IntrospectionPluginOptions as k, FieldMetadata as kn, AuthPluginOptions as kt, TypedRepository as l, AccessControlConfig as ln, RouteDefinition as lt, RegistryStats as m, HookPhase as mn, ControllerLike as mt, ConfigError as n, BaseCrudController as nn, PresetResult as nt, InferDocType as o, BodySanitizer as on, ResourceHookContext as ot, RegistryEntry as p, HookOperation as pn, ControllerHandler as pt, CrudController as q, SlugMixin as qt, ValidateOptions as r, ListResult as rn, RateLimitConfig as rt, InferResourceDoc as s, BodySanitizerConfig as sn, ResourceHooks as st, RouteHandlerMethod$1 as t, BaseControllerOptions as tn, PresetHook as tt, TypedResourceConfig as u, DefineHookOptions as un, RouteMcpConfig as ut, LookupOption as v, afterCreate as vn, IRequestContext as vt, ServiceContext as w, createHookSystem as wn, PipelineConfig as wt, PopulateOption as x, beforeCreate as xn, Interceptor as xt, OwnershipCheck as y, afterDelete as yn, RouteHandler as yt, ResourceRegistry as z, UserLike as zt };
|