@classytic/arc 1.1.0 → 2.1.2
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 +247 -794
- package/bin/arc.js +91 -52
- package/dist/EventTransport-BD2U0BTc.d.mts +100 -0
- package/dist/EventTransport-BD2U0BTc.d.mts.map +1 -0
- package/dist/HookSystem-BsGV-j2l.mjs +405 -0
- package/dist/HookSystem-BsGV-j2l.mjs.map +1 -0
- package/dist/ResourceRegistry-DsN4KJjV.mjs +250 -0
- package/dist/ResourceRegistry-DsN4KJjV.mjs.map +1 -0
- package/dist/adapters/index.d.mts +5 -0
- package/dist/adapters/index.mjs +3 -0
- package/dist/audit/index.d.mts +82 -0
- package/dist/audit/index.d.mts.map +1 -0
- package/dist/audit/index.mjs +276 -0
- package/dist/audit/index.mjs.map +1 -0
- package/dist/audit/mongodb.d.mts +5 -0
- package/dist/audit/mongodb.mjs +3 -0
- package/dist/audited-C3T5DTUx.mjs +141 -0
- package/dist/audited-C3T5DTUx.mjs.map +1 -0
- package/dist/auth/index.d.mts +189 -0
- package/dist/auth/index.d.mts.map +1 -0
- package/dist/auth/index.mjs +1102 -0
- package/dist/auth/index.mjs.map +1 -0
- package/dist/auth/redis-session.d.mts +44 -0
- package/dist/auth/redis-session.d.mts.map +1 -0
- package/dist/auth/redis-session.mjs +76 -0
- package/dist/auth/redis-session.mjs.map +1 -0
- package/dist/betterAuthOpenApi-BrHKeSAx.mjs +250 -0
- package/dist/betterAuthOpenApi-BrHKeSAx.mjs.map +1 -0
- package/dist/cache/index.d.mts +146 -0
- package/dist/cache/index.d.mts.map +1 -0
- package/dist/cache/index.mjs +92 -0
- package/dist/cache/index.mjs.map +1 -0
- package/dist/caching-Bl28lYsR.mjs +94 -0
- package/dist/caching-Bl28lYsR.mjs.map +1 -0
- package/dist/chunk-C7Uep-_p.mjs +20 -0
- package/dist/circuitBreaker-DeY4FCjs.mjs +1097 -0
- package/dist/circuitBreaker-DeY4FCjs.mjs.map +1 -0
- package/dist/cli/commands/describe.d.mts +19 -0
- package/dist/cli/commands/describe.d.mts.map +1 -0
- package/dist/cli/commands/describe.mjs +239 -0
- package/dist/cli/commands/describe.mjs.map +1 -0
- package/dist/cli/commands/docs.d.mts +14 -0
- package/dist/cli/commands/docs.d.mts.map +1 -0
- package/dist/cli/commands/docs.mjs +53 -0
- package/dist/cli/commands/docs.mjs.map +1 -0
- package/dist/cli/commands/{generate.d.ts → generate.d.mts} +3 -1
- package/dist/cli/commands/generate.d.mts.map +1 -0
- package/dist/cli/commands/generate.mjs +358 -0
- package/dist/cli/commands/generate.mjs.map +1 -0
- package/dist/cli/commands/{init.d.ts → init.d.mts} +12 -8
- package/dist/cli/commands/init.d.mts.map +1 -0
- package/dist/cli/commands/{init.js → init.mjs} +807 -616
- package/dist/cli/commands/init.mjs.map +1 -0
- package/dist/cli/commands/introspect.d.mts +11 -0
- package/dist/cli/commands/introspect.d.mts.map +1 -0
- package/dist/cli/commands/introspect.mjs +76 -0
- package/dist/cli/commands/introspect.mjs.map +1 -0
- package/dist/cli/index.d.mts +17 -0
- package/dist/cli/index.d.mts.map +1 -0
- package/dist/cli/index.mjs +157 -0
- package/dist/cli/index.mjs.map +1 -0
- package/dist/constants-DdXFXQtN.mjs +85 -0
- package/dist/constants-DdXFXQtN.mjs.map +1 -0
- package/dist/core/index.d.mts +5 -0
- package/dist/core/index.mjs +4 -0
- package/dist/createApp-CUgNqegw.mjs +560 -0
- package/dist/createApp-CUgNqegw.mjs.map +1 -0
- package/dist/defineResource-k0_BDn8v.mjs +2197 -0
- package/dist/defineResource-k0_BDn8v.mjs.map +1 -0
- package/dist/discovery/index.d.mts +47 -0
- package/dist/discovery/index.d.mts.map +1 -0
- package/dist/discovery/index.mjs +110 -0
- package/dist/discovery/index.mjs.map +1 -0
- package/dist/docs/index.d.mts +163 -0
- package/dist/docs/index.d.mts.map +1 -0
- package/dist/docs/index.mjs +73 -0
- package/dist/docs/index.mjs.map +1 -0
- package/dist/elevation-BRy3yFWT.mjs +113 -0
- package/dist/elevation-BRy3yFWT.mjs.map +1 -0
- package/dist/elevation-B_2dRLVP.d.mts +88 -0
- package/dist/elevation-B_2dRLVP.d.mts.map +1 -0
- package/dist/errorHandler-BbcgBmIH.d.mts +73 -0
- package/dist/errorHandler-BbcgBmIH.d.mts.map +1 -0
- package/dist/errorHandler-C1okiriz.mjs +109 -0
- package/dist/errorHandler-C1okiriz.mjs.map +1 -0
- package/dist/errors-B9bZok84.mjs +212 -0
- package/dist/errors-B9bZok84.mjs.map +1 -0
- package/dist/errors-ChKiFz62.d.mts +125 -0
- package/dist/errors-ChKiFz62.d.mts.map +1 -0
- package/dist/eventPlugin-CTrLH3mt.d.mts +125 -0
- package/dist/eventPlugin-CTrLH3mt.d.mts.map +1 -0
- package/dist/eventPlugin-DGR_B2on.mjs +230 -0
- package/dist/eventPlugin-DGR_B2on.mjs.map +1 -0
- package/dist/events/index.d.mts +54 -0
- package/dist/events/index.d.mts.map +1 -0
- package/dist/events/index.mjs +52 -0
- package/dist/events/index.mjs.map +1 -0
- package/dist/events/transports/redis-stream-entry.d.mts +2 -0
- package/dist/events/transports/redis-stream-entry.mjs +178 -0
- package/dist/events/transports/redis-stream-entry.mjs.map +1 -0
- package/dist/events/transports/redis.d.mts +77 -0
- package/dist/events/transports/redis.d.mts.map +1 -0
- package/dist/events/transports/redis.mjs +125 -0
- package/dist/events/transports/redis.mjs.map +1 -0
- package/dist/externalPaths-DlINfKbP.d.mts +51 -0
- package/dist/externalPaths-DlINfKbP.d.mts.map +1 -0
- package/dist/factory/index.d.mts +64 -0
- package/dist/factory/index.d.mts.map +1 -0
- package/dist/factory/index.mjs +3 -0
- package/dist/fastifyAdapter-BkrGrlFi.d.mts +217 -0
- package/dist/fastifyAdapter-BkrGrlFi.d.mts.map +1 -0
- package/dist/fields-DyaDVX4J.d.mts +110 -0
- package/dist/fields-DyaDVX4J.d.mts.map +1 -0
- package/dist/fields-iagOozy0.mjs +115 -0
- package/dist/fields-iagOozy0.mjs.map +1 -0
- package/dist/hooks/index.d.mts +4 -0
- package/dist/hooks/index.mjs +3 -0
- package/dist/idempotency/index.d.mts +97 -0
- package/dist/idempotency/index.d.mts.map +1 -0
- package/dist/idempotency/index.mjs +320 -0
- package/dist/idempotency/index.mjs.map +1 -0
- package/dist/idempotency/mongodb.d.mts +2 -0
- package/dist/idempotency/mongodb.mjs +115 -0
- package/dist/idempotency/mongodb.mjs.map +1 -0
- package/dist/idempotency/redis.d.mts +2 -0
- package/dist/idempotency/redis.mjs +104 -0
- package/dist/idempotency/redis.mjs.map +1 -0
- package/dist/index.d.mts +261 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +105 -0
- package/dist/index.mjs.map +1 -0
- package/dist/integrations/event-gateway.d.mts +47 -0
- package/dist/integrations/event-gateway.d.mts.map +1 -0
- package/dist/integrations/event-gateway.mjs +44 -0
- package/dist/integrations/event-gateway.mjs.map +1 -0
- package/dist/integrations/index.d.mts +5 -0
- package/dist/integrations/index.mjs +1 -0
- package/dist/integrations/jobs.d.mts +104 -0
- package/dist/integrations/jobs.d.mts.map +1 -0
- package/dist/integrations/jobs.mjs +124 -0
- package/dist/integrations/jobs.mjs.map +1 -0
- package/dist/integrations/streamline.d.mts +61 -0
- package/dist/integrations/streamline.d.mts.map +1 -0
- package/dist/integrations/streamline.mjs +126 -0
- package/dist/integrations/streamline.mjs.map +1 -0
- package/dist/integrations/websocket.d.mts +83 -0
- package/dist/integrations/websocket.d.mts.map +1 -0
- package/dist/integrations/websocket.mjs +289 -0
- package/dist/integrations/websocket.mjs.map +1 -0
- package/dist/interface-B01JvPVc.d.mts +78 -0
- package/dist/interface-B01JvPVc.d.mts.map +1 -0
- package/dist/interface-CZe8IkMf.d.mts +55 -0
- package/dist/interface-CZe8IkMf.d.mts.map +1 -0
- package/dist/interface-Ch8HU9uM.d.mts +1098 -0
- package/dist/interface-Ch8HU9uM.d.mts.map +1 -0
- package/dist/introspectionPlugin-rFdO8ZUa.mjs +54 -0
- package/dist/introspectionPlugin-rFdO8ZUa.mjs.map +1 -0
- package/dist/keys-BqNejWup.mjs +43 -0
- package/dist/keys-BqNejWup.mjs.map +1 -0
- package/dist/logger-Df2O2WsW.mjs +79 -0
- package/dist/logger-Df2O2WsW.mjs.map +1 -0
- package/dist/memory-cQgelFOj.mjs +144 -0
- package/dist/memory-cQgelFOj.mjs.map +1 -0
- package/dist/migrations/index.d.mts +157 -0
- package/dist/migrations/index.d.mts.map +1 -0
- package/dist/migrations/index.mjs +261 -0
- package/dist/migrations/index.mjs.map +1 -0
- package/dist/mongodb-BfJVlUJH.mjs +94 -0
- package/dist/mongodb-BfJVlUJH.mjs.map +1 -0
- package/dist/mongodb-CGzRbfAK.d.mts +119 -0
- package/dist/mongodb-CGzRbfAK.d.mts.map +1 -0
- package/dist/mongodb-JN-9JA7K.d.mts +72 -0
- package/dist/mongodb-JN-9JA7K.d.mts.map +1 -0
- package/dist/openapi-G3Cw7XuM.mjs +524 -0
- package/dist/openapi-G3Cw7XuM.mjs.map +1 -0
- package/dist/org/index.d.mts +69 -0
- package/dist/org/index.d.mts.map +1 -0
- package/dist/org/index.mjs +514 -0
- package/dist/org/index.mjs.map +1 -0
- package/dist/org/types.d.mts +83 -0
- package/dist/org/types.d.mts.map +1 -0
- package/dist/org/types.mjs +1 -0
- package/dist/permissions/index.d.mts +279 -0
- package/dist/permissions/index.d.mts.map +1 -0
- package/dist/permissions/index.mjs +579 -0
- package/dist/permissions/index.mjs.map +1 -0
- package/dist/plugins/index.d.mts +173 -0
- package/dist/plugins/index.d.mts.map +1 -0
- package/dist/plugins/index.mjs +523 -0
- package/dist/plugins/index.mjs.map +1 -0
- package/dist/plugins/response-cache.d.mts +88 -0
- package/dist/plugins/response-cache.d.mts.map +1 -0
- package/dist/plugins/response-cache.mjs +284 -0
- package/dist/plugins/response-cache.mjs.map +1 -0
- package/dist/plugins/tracing-entry.d.mts +2 -0
- package/dist/plugins/tracing-entry.mjs +186 -0
- package/dist/plugins/tracing-entry.mjs.map +1 -0
- package/dist/pluralize-CEweyOEm.mjs +87 -0
- package/dist/pluralize-CEweyOEm.mjs.map +1 -0
- package/dist/policies/{index.d.ts → index.d.mts} +204 -169
- package/dist/policies/index.d.mts.map +1 -0
- package/dist/policies/index.mjs +322 -0
- package/dist/policies/index.mjs.map +1 -0
- package/dist/presets/{index.d.ts → index.d.mts} +63 -131
- package/dist/presets/index.d.mts.map +1 -0
- package/dist/presets/index.mjs +144 -0
- package/dist/presets/index.mjs.map +1 -0
- package/dist/presets/multiTenant.d.mts +25 -0
- package/dist/presets/multiTenant.d.mts.map +1 -0
- package/dist/presets/multiTenant.mjs +114 -0
- package/dist/presets/multiTenant.mjs.map +1 -0
- package/dist/presets-BITljm96.mjs +120 -0
- package/dist/presets-BITljm96.mjs.map +1 -0
- package/dist/presets-DzSMwlKj.d.mts +58 -0
- package/dist/presets-DzSMwlKj.d.mts.map +1 -0
- package/dist/prisma-DJbMt3yf.mjs +628 -0
- package/dist/prisma-DJbMt3yf.mjs.map +1 -0
- package/dist/prisma-Dg9GoVdj.d.mts +275 -0
- package/dist/prisma-Dg9GoVdj.d.mts.map +1 -0
- package/dist/queryCachePlugin-7THaI5mt.d.mts +72 -0
- package/dist/queryCachePlugin-7THaI5mt.d.mts.map +1 -0
- package/dist/queryCachePlugin-DMBnp2Q0.mjs +139 -0
- package/dist/queryCachePlugin-DMBnp2Q0.mjs.map +1 -0
- package/dist/redis-D-JAeLtm.d.mts +50 -0
- package/dist/redis-D-JAeLtm.d.mts.map +1 -0
- package/dist/redis-stream-Bdh_vUU8.d.mts +104 -0
- package/dist/redis-stream-Bdh_vUU8.d.mts.map +1 -0
- package/dist/registry/index.d.mts +12 -0
- package/dist/registry/index.d.mts.map +1 -0
- package/dist/registry/index.mjs +4 -0
- package/dist/requestContext-QQD6ROJc.mjs +56 -0
- package/dist/requestContext-QQD6ROJc.mjs.map +1 -0
- package/dist/schemaConverter-BwrmWroW.mjs +99 -0
- package/dist/schemaConverter-BwrmWroW.mjs.map +1 -0
- package/dist/schemas/index.d.mts +64 -0
- package/dist/schemas/index.d.mts.map +1 -0
- package/dist/schemas/index.mjs +83 -0
- package/dist/schemas/index.mjs.map +1 -0
- package/dist/scope/index.d.mts +22 -0
- package/dist/scope/index.d.mts.map +1 -0
- package/dist/scope/index.mjs +66 -0
- package/dist/scope/index.mjs.map +1 -0
- package/dist/sessionManager-jPKLbHE0.d.mts +187 -0
- package/dist/sessionManager-jPKLbHE0.d.mts.map +1 -0
- package/dist/sse-B3c3_yZp.mjs +124 -0
- package/dist/sse-B3c3_yZp.mjs.map +1 -0
- package/dist/testing/index.d.mts +908 -0
- package/dist/testing/index.d.mts.map +1 -0
- package/dist/testing/index.mjs +1977 -0
- package/dist/testing/index.mjs.map +1 -0
- package/dist/tracing-Cc7vVQPp.d.mts +71 -0
- package/dist/tracing-Cc7vVQPp.d.mts.map +1 -0
- package/dist/typeGuards-DhMNLuvU.mjs +10 -0
- package/dist/typeGuards-DhMNLuvU.mjs.map +1 -0
- package/dist/types/index.d.mts +947 -0
- package/dist/types/index.d.mts.map +1 -0
- package/dist/types/index.mjs +15 -0
- package/dist/types/index.mjs.map +1 -0
- package/dist/types-Beqn1Un7.mjs +39 -0
- package/dist/types-Beqn1Un7.mjs.map +1 -0
- package/dist/types-CIgB7UUl.d.mts +446 -0
- package/dist/types-CIgB7UUl.d.mts.map +1 -0
- package/dist/types-aYB4V7uN.d.mts +87 -0
- package/dist/types-aYB4V7uN.d.mts.map +1 -0
- package/dist/utils/index.d.mts +748 -0
- package/dist/utils/index.d.mts.map +1 -0
- package/dist/utils/index.mjs +6 -0
- package/package.json +194 -68
- package/dist/BaseController-DVAiHxEQ.d.ts +0 -233
- package/dist/adapters/index.d.ts +0 -237
- package/dist/adapters/index.js +0 -668
- package/dist/arcCorePlugin-CsShQdyP.d.ts +0 -273
- package/dist/audit/index.d.ts +0 -195
- package/dist/audit/index.js +0 -319
- package/dist/auth/index.d.ts +0 -47
- package/dist/auth/index.js +0 -174
- package/dist/cli/commands/docs.d.ts +0 -11
- package/dist/cli/commands/docs.js +0 -474
- package/dist/cli/commands/generate.js +0 -334
- package/dist/cli/commands/introspect.d.ts +0 -8
- package/dist/cli/commands/introspect.js +0 -338
- package/dist/cli/index.d.ts +0 -4
- package/dist/cli/index.js +0 -3269
- package/dist/core/index.d.ts +0 -220
- package/dist/core/index.js +0 -2786
- package/dist/createApp-Ce9wl8W9.d.ts +0 -77
- package/dist/docs/index.d.ts +0 -166
- package/dist/docs/index.js +0 -658
- package/dist/errors-8WIxGS_6.d.ts +0 -122
- package/dist/events/index.d.ts +0 -117
- package/dist/events/index.js +0 -89
- package/dist/factory/index.d.ts +0 -38
- package/dist/factory/index.js +0 -1652
- package/dist/hooks/index.d.ts +0 -4
- package/dist/hooks/index.js +0 -199
- package/dist/idempotency/index.d.ts +0 -323
- package/dist/idempotency/index.js +0 -500
- package/dist/index-B4t03KQ0.d.ts +0 -1366
- package/dist/index.d.ts +0 -135
- package/dist/index.js +0 -4756
- package/dist/migrations/index.d.ts +0 -185
- package/dist/migrations/index.js +0 -274
- package/dist/org/index.d.ts +0 -129
- package/dist/org/index.js +0 -220
- package/dist/permissions/index.d.ts +0 -144
- package/dist/permissions/index.js +0 -103
- package/dist/plugins/index.d.ts +0 -46
- package/dist/plugins/index.js +0 -1069
- package/dist/policies/index.js +0 -196
- package/dist/presets/index.js +0 -384
- package/dist/presets/multiTenant.d.ts +0 -39
- package/dist/presets/multiTenant.js +0 -112
- package/dist/registry/index.d.ts +0 -16
- package/dist/registry/index.js +0 -253
- package/dist/testing/index.d.ts +0 -618
- package/dist/testing/index.js +0 -48020
- package/dist/types/index.d.ts +0 -4
- package/dist/types/index.js +0 -8
- package/dist/types-B99TBmFV.d.ts +0 -76
- package/dist/types-BvckRbs2.d.ts +0 -143
- package/dist/utils/index.d.ts +0 -679
- package/dist/utils/index.js +0 -931
|
@@ -0,0 +1,2197 @@
|
|
|
1
|
+
import { a as DEFAULT_SORT, d as MAX_REGEX_LENGTH, h as SYSTEM_FIELDS, n as DEFAULT_ID_FIELD, o as DEFAULT_TENANT_FIELD, r as DEFAULT_LIMIT, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-DdXFXQtN.mjs";
|
|
2
|
+
import { c as isElevated, l as isMember, n as PUBLIC_SCOPE, r as getOrgId } from "./types-Beqn1Un7.mjs";
|
|
3
|
+
import { getUserId } from "./types/index.mjs";
|
|
4
|
+
import { i as resolveEffectiveRoles, n as applyFieldWritePermissions, t as applyFieldReadPermissions } from "./fields-iagOozy0.mjs";
|
|
5
|
+
import { C as ArcQueryParser, u as getDefaultCrudSchemas } from "./circuitBreaker-DeY4FCjs.mjs";
|
|
6
|
+
import { t as buildQueryKey } from "./keys-BqNejWup.mjs";
|
|
7
|
+
import { r as ForbiddenError } from "./errors-B9bZok84.mjs";
|
|
8
|
+
import { t as hasEvents } from "./typeGuards-DhMNLuvU.mjs";
|
|
9
|
+
import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-BwrmWroW.mjs";
|
|
10
|
+
import { t as requestContext } from "./requestContext-QQD6ROJc.mjs";
|
|
11
|
+
import { applyPresets, getAvailablePresets } from "./presets/index.mjs";
|
|
12
|
+
|
|
13
|
+
//#region src/core/AccessControl.ts
|
|
14
|
+
var AccessControl = class AccessControl {
|
|
15
|
+
tenantField;
|
|
16
|
+
idField;
|
|
17
|
+
_adapterMatchesFilter;
|
|
18
|
+
/** Patterns that indicate dangerous regex (nested quantifiers, excessive backtracking).
|
|
19
|
+
* Uses [^...] character classes instead of .+ to avoid backtracking in the detector itself. */
|
|
20
|
+
static DANGEROUS_REGEX = /(\{[0-9]+,\}[^{]*\{[0-9]+,\})|(\+[^+]*\+)|(\*[^*]*\*)|(\.\*){3,}|\\1/;
|
|
21
|
+
/** Forbidden paths that could lead to prototype pollution */
|
|
22
|
+
static FORBIDDEN_PATHS = [
|
|
23
|
+
"__proto__",
|
|
24
|
+
"constructor",
|
|
25
|
+
"prototype"
|
|
26
|
+
];
|
|
27
|
+
constructor(config) {
|
|
28
|
+
this.tenantField = config.tenantField;
|
|
29
|
+
this.idField = config.idField;
|
|
30
|
+
this._adapterMatchesFilter = config.matchesFilter;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Build filter for single-item operations (get/update/delete)
|
|
34
|
+
* Combines ID filter with policy/org filters for proper security enforcement
|
|
35
|
+
*/
|
|
36
|
+
buildIdFilter(id, req) {
|
|
37
|
+
const filter = { [this.idField]: id };
|
|
38
|
+
const arcContext = this._meta(req);
|
|
39
|
+
const policyFilters = arcContext?._policyFilters;
|
|
40
|
+
if (policyFilters) Object.assign(filter, policyFilters);
|
|
41
|
+
const scope = arcContext?._scope;
|
|
42
|
+
const orgId = scope ? getOrgId(scope) : void 0;
|
|
43
|
+
if (orgId && !policyFilters?.[this.tenantField]) filter[this.tenantField] = orgId;
|
|
44
|
+
return filter;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Check if item matches policy filters (for get/update/delete operations)
|
|
48
|
+
* Validates that fetched item satisfies all policy constraints
|
|
49
|
+
*
|
|
50
|
+
* Delegates to adapter-provided matchesFilter if available (for SQL, etc.),
|
|
51
|
+
* otherwise falls back to built-in MongoDB-style matching.
|
|
52
|
+
*/
|
|
53
|
+
checkPolicyFilters(item, req) {
|
|
54
|
+
const policyFilters = this._meta(req)?._policyFilters;
|
|
55
|
+
if (!policyFilters) return true;
|
|
56
|
+
if (this._adapterMatchesFilter) return this._adapterMatchesFilter(item, policyFilters);
|
|
57
|
+
return this.defaultMatchesPolicyFilters(item, policyFilters);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Check org/tenant scope for a document — uses configurable tenantField.
|
|
61
|
+
*
|
|
62
|
+
* SECURITY: When org scope is active (orgId present), documents that are
|
|
63
|
+
* missing the tenant field are DENIED by default. This prevents legacy or
|
|
64
|
+
* unscoped records from leaking across tenants.
|
|
65
|
+
*/
|
|
66
|
+
checkOrgScope(item, arcContext) {
|
|
67
|
+
const scope = arcContext?._scope;
|
|
68
|
+
const orgId = scope ? getOrgId(scope) : void 0;
|
|
69
|
+
if (!item || !orgId) return true;
|
|
70
|
+
if (scope && isElevated(scope) && !orgId) return true;
|
|
71
|
+
const itemOrgId = item[this.tenantField];
|
|
72
|
+
if (!itemOrgId) return false;
|
|
73
|
+
return String(itemOrgId) === String(orgId);
|
|
74
|
+
}
|
|
75
|
+
/** Check ownership for update/delete (ownedByUser preset) */
|
|
76
|
+
checkOwnership(item, req) {
|
|
77
|
+
const ownershipCheck = this._meta(req)?._ownershipCheck;
|
|
78
|
+
if (!item || !ownershipCheck) return true;
|
|
79
|
+
const { field, userId } = ownershipCheck;
|
|
80
|
+
const itemOwnerId = item[field];
|
|
81
|
+
if (!itemOwnerId) return true;
|
|
82
|
+
return String(itemOwnerId) === String(userId);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Fetch a single document with full access control enforcement.
|
|
86
|
+
* Combines compound DB filter (ID + org + policy) with post-hoc fallback.
|
|
87
|
+
*
|
|
88
|
+
* Takes repository as a parameter to avoid coupling.
|
|
89
|
+
*
|
|
90
|
+
* Replaces the duplicated pattern in get/update/delete:
|
|
91
|
+
* buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
|
|
92
|
+
*/
|
|
93
|
+
async fetchWithAccessControl(id, req, repository, queryOptions) {
|
|
94
|
+
const compoundFilter = this.buildIdFilter(id, req);
|
|
95
|
+
const hasCompoundFilters = Object.keys(compoundFilter).length > 1;
|
|
96
|
+
try {
|
|
97
|
+
if (hasCompoundFilters && typeof repository.getOne === "function") return await repository.getOne(compoundFilter, queryOptions);
|
|
98
|
+
const item = await repository.getById(id, queryOptions);
|
|
99
|
+
if (!item) return null;
|
|
100
|
+
const arcContext = this._meta(req);
|
|
101
|
+
if (!this.checkOrgScope(item, arcContext) || !this.checkPolicyFilters(item, req)) return null;
|
|
102
|
+
return item;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error instanceof Error && error.message?.includes("not found")) return null;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Extract typed Arc internal metadata from request */
|
|
109
|
+
_meta(req) {
|
|
110
|
+
return req.metadata;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Check if a value matches a MongoDB query operator
|
|
114
|
+
*/
|
|
115
|
+
matchesOperator(itemValue, operator, filterValue) {
|
|
116
|
+
const equalsByValue = (a, b) => String(a) === String(b);
|
|
117
|
+
switch (operator) {
|
|
118
|
+
case "$eq": return equalsByValue(itemValue, filterValue);
|
|
119
|
+
case "$ne": return !equalsByValue(itemValue, filterValue);
|
|
120
|
+
case "$gt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue > filterValue;
|
|
121
|
+
case "$gte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue >= filterValue;
|
|
122
|
+
case "$lt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue < filterValue;
|
|
123
|
+
case "$lte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue <= filterValue;
|
|
124
|
+
case "$in":
|
|
125
|
+
if (!Array.isArray(filterValue)) return false;
|
|
126
|
+
if (Array.isArray(itemValue)) return itemValue.some((v) => filterValue.some((fv) => equalsByValue(v, fv)));
|
|
127
|
+
return filterValue.some((fv) => equalsByValue(itemValue, fv));
|
|
128
|
+
case "$nin":
|
|
129
|
+
if (!Array.isArray(filterValue)) return false;
|
|
130
|
+
if (Array.isArray(itemValue)) return itemValue.every((v) => filterValue.every((fv) => !equalsByValue(v, fv)));
|
|
131
|
+
return filterValue.every((fv) => !equalsByValue(itemValue, fv));
|
|
132
|
+
case "$exists": return filterValue ? itemValue !== void 0 : itemValue === void 0;
|
|
133
|
+
case "$regex":
|
|
134
|
+
if (typeof itemValue === "string" && (typeof filterValue === "string" || filterValue instanceof RegExp)) {
|
|
135
|
+
const regex = typeof filterValue === "string" ? AccessControl.safeRegex(filterValue) : filterValue;
|
|
136
|
+
return regex !== null && regex.test(itemValue);
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
default: return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Check if item matches a single filter condition
|
|
144
|
+
* Supports nested paths (e.g., "owner.id", "metadata.status")
|
|
145
|
+
*/
|
|
146
|
+
matchesFilter(item, key, filterValue) {
|
|
147
|
+
const itemValue = key.includes(".") ? this.getNestedValue(item, key) : item[key];
|
|
148
|
+
if (filterValue && typeof filterValue === "object" && !Array.isArray(filterValue)) {
|
|
149
|
+
if (Object.keys(filterValue).some((op) => op.startsWith("$"))) {
|
|
150
|
+
for (const [operator, opValue] of Object.entries(filterValue)) if (!this.matchesOperator(itemValue, operator, opValue)) return false;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (Array.isArray(itemValue)) return itemValue.some((v) => String(v) === String(filterValue));
|
|
155
|
+
return String(itemValue) === String(filterValue);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Built-in MongoDB-style policy filter matching.
|
|
159
|
+
* Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
|
|
160
|
+
*/
|
|
161
|
+
defaultMatchesPolicyFilters(item, policyFilters) {
|
|
162
|
+
if (policyFilters.$and && Array.isArray(policyFilters.$and)) return policyFilters.$and.every((condition) => {
|
|
163
|
+
return Object.entries(condition).every(([key, value]) => {
|
|
164
|
+
return this.matchesFilter(item, key, value);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
if (policyFilters.$or && Array.isArray(policyFilters.$or)) return policyFilters.$or.some((condition) => {
|
|
168
|
+
return Object.entries(condition).every(([key, value]) => {
|
|
169
|
+
return this.matchesFilter(item, key, value);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
for (const [key, value] of Object.entries(policyFilters)) {
|
|
173
|
+
if (key.startsWith("$")) continue;
|
|
174
|
+
if (!this.matchesFilter(item, key, value)) return false;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Get nested value from object using dot notation (e.g., "owner.id")
|
|
180
|
+
* Security: Validates path against forbidden patterns to prevent prototype pollution
|
|
181
|
+
*/
|
|
182
|
+
getNestedValue(obj, path) {
|
|
183
|
+
if (AccessControl.FORBIDDEN_PATHS.some((p) => path.toLowerCase().includes(p))) return;
|
|
184
|
+
const keys = path.split(".");
|
|
185
|
+
let value = obj;
|
|
186
|
+
for (const key of keys) {
|
|
187
|
+
if (value == null) return void 0;
|
|
188
|
+
if (AccessControl.FORBIDDEN_PATHS.includes(key.toLowerCase())) return;
|
|
189
|
+
value = value[key];
|
|
190
|
+
}
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Create a safe RegExp from a string, guarding against ReDoS.
|
|
195
|
+
* Returns null if the pattern is invalid or dangerous.
|
|
196
|
+
*/
|
|
197
|
+
static safeRegex(pattern) {
|
|
198
|
+
if (pattern.length > MAX_REGEX_LENGTH) return null;
|
|
199
|
+
if (AccessControl.DANGEROUS_REGEX.test(pattern)) return null;
|
|
200
|
+
try {
|
|
201
|
+
return new RegExp(pattern);
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/core/BodySanitizer.ts
|
|
210
|
+
var BodySanitizer = class {
|
|
211
|
+
schemaOptions;
|
|
212
|
+
constructor(config) {
|
|
213
|
+
this.schemaOptions = config.schemaOptions;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Strip readonly and system-managed fields from request body.
|
|
217
|
+
* Prevents clients from overwriting _id, timestamps, __v, etc.
|
|
218
|
+
*
|
|
219
|
+
* Also applies field-level write permissions when the request has
|
|
220
|
+
* field permission metadata.
|
|
221
|
+
*/
|
|
222
|
+
sanitize(body, _operation, req, meta) {
|
|
223
|
+
let sanitized = { ...body };
|
|
224
|
+
for (const field of SYSTEM_FIELDS) delete sanitized[field];
|
|
225
|
+
const fieldRules = this.schemaOptions.fieldRules ?? {};
|
|
226
|
+
for (const [field, rules] of Object.entries(fieldRules)) if (rules.systemManaged || rules.readonly) delete sanitized[field];
|
|
227
|
+
if (req) {
|
|
228
|
+
const arcContext = meta ?? req.metadata;
|
|
229
|
+
const scope = arcContext?._scope ?? PUBLIC_SCOPE;
|
|
230
|
+
if (!isElevated(scope)) {
|
|
231
|
+
const fieldPerms = arcContext?.arc?.fields;
|
|
232
|
+
if (fieldPerms) {
|
|
233
|
+
const effectiveRoles = resolveEffectiveRoles(req.user?.roles ?? [], isMember(scope) ? scope.orgRoles : []);
|
|
234
|
+
sanitized = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return sanitized;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/core/QueryResolver.ts
|
|
244
|
+
const defaultParser = new ArcQueryParser();
|
|
245
|
+
function getDefaultQueryParser() {
|
|
246
|
+
return defaultParser;
|
|
247
|
+
}
|
|
248
|
+
var QueryResolver = class {
|
|
249
|
+
queryParser;
|
|
250
|
+
maxLimit;
|
|
251
|
+
defaultLimit;
|
|
252
|
+
defaultSort;
|
|
253
|
+
schemaOptions;
|
|
254
|
+
tenantField;
|
|
255
|
+
constructor(config = {}) {
|
|
256
|
+
this.queryParser = config.queryParser ?? getDefaultQueryParser();
|
|
257
|
+
this.maxLimit = config.maxLimit ?? 100;
|
|
258
|
+
this.defaultLimit = config.defaultLimit ?? DEFAULT_LIMIT;
|
|
259
|
+
this.defaultSort = config.defaultSort ?? DEFAULT_SORT;
|
|
260
|
+
this.schemaOptions = config.schemaOptions ?? {};
|
|
261
|
+
this.tenantField = config.tenantField ?? DEFAULT_TENANT_FIELD;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Resolve a request into parsed query options -- ONE parse per request.
|
|
265
|
+
* Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
|
|
266
|
+
*/
|
|
267
|
+
resolve(req, meta) {
|
|
268
|
+
const parsed = this.queryParser.parse(req.query);
|
|
269
|
+
const arcContext = meta ?? req.metadata;
|
|
270
|
+
delete parsed.filters?._policyFilters;
|
|
271
|
+
const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
|
|
272
|
+
const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
|
|
273
|
+
const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.defaultSort;
|
|
274
|
+
const selectString = this.selectToString(parsed.select) ?? req.query?.select;
|
|
275
|
+
const filters = { ...parsed.filters };
|
|
276
|
+
const policyFilters = arcContext?._policyFilters;
|
|
277
|
+
if (policyFilters) Object.assign(filters, policyFilters);
|
|
278
|
+
const scope = arcContext?._scope;
|
|
279
|
+
const orgId = scope ? getOrgId(scope) : void 0;
|
|
280
|
+
if (orgId && !policyFilters?.[this.tenantField]) filters[this.tenantField] = orgId;
|
|
281
|
+
return {
|
|
282
|
+
page,
|
|
283
|
+
limit,
|
|
284
|
+
sort: sortString,
|
|
285
|
+
select: this.sanitizeSelect(selectString, this.schemaOptions),
|
|
286
|
+
populate: this.sanitizePopulate(parsed.populate, this.schemaOptions),
|
|
287
|
+
populateOptions: parsed.populateOptions,
|
|
288
|
+
filters,
|
|
289
|
+
search: parsed.search,
|
|
290
|
+
after: parsed.after,
|
|
291
|
+
user: req.user,
|
|
292
|
+
context: arcContext
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Convert parsed select object to string format
|
|
297
|
+
* Converts { name: 1, email: 1, password: 0 } -> 'name email -password'
|
|
298
|
+
*/
|
|
299
|
+
selectToString(select) {
|
|
300
|
+
if (!select) return void 0;
|
|
301
|
+
if (typeof select === "string") return select;
|
|
302
|
+
if (Array.isArray(select)) return select.join(" ");
|
|
303
|
+
if (Object.keys(select).length === 0) return void 0;
|
|
304
|
+
return Object.entries(select).map(([field, include]) => include === 0 ? `-${field}` : field).join(" ");
|
|
305
|
+
}
|
|
306
|
+
/** Sanitize select fields */
|
|
307
|
+
sanitizeSelect(select, schemaOptions) {
|
|
308
|
+
if (!select) return void 0;
|
|
309
|
+
const blockedFields = this.getBlockedFields(schemaOptions);
|
|
310
|
+
if (blockedFields.length === 0) return select;
|
|
311
|
+
const sanitized = select.split(/[\s,]+/).filter(Boolean).filter((f) => {
|
|
312
|
+
const fieldName = f.replace(/^-/, "");
|
|
313
|
+
return !blockedFields.includes(fieldName);
|
|
314
|
+
});
|
|
315
|
+
return sanitized.length > 0 ? sanitized.join(" ") : void 0;
|
|
316
|
+
}
|
|
317
|
+
/** Sanitize populate fields */
|
|
318
|
+
sanitizePopulate(populate, schemaOptions) {
|
|
319
|
+
if (!populate) return void 0;
|
|
320
|
+
const allowedPopulate = schemaOptions.query?.allowedPopulate;
|
|
321
|
+
const requested = typeof populate === "string" ? populate.split(",").map((p) => p.trim()) : Array.isArray(populate) ? populate.map(String) : [];
|
|
322
|
+
if (requested.length === 0) return void 0;
|
|
323
|
+
if (!allowedPopulate) return requested;
|
|
324
|
+
const sanitized = requested.filter((p) => allowedPopulate.includes(p));
|
|
325
|
+
return sanitized.length > 0 ? sanitized : void 0;
|
|
326
|
+
}
|
|
327
|
+
/** Get blocked fields from schema options */
|
|
328
|
+
getBlockedFields(schemaOptions) {
|
|
329
|
+
const fieldRules = schemaOptions.fieldRules ?? {};
|
|
330
|
+
return Object.entries(fieldRules).filter(([, rules]) => rules.systemManaged || rules.hidden).map(([field]) => field);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
//#endregion
|
|
335
|
+
//#region src/core/BaseController.ts
|
|
336
|
+
/**
|
|
337
|
+
* Framework-agnostic base controller implementing IController.
|
|
338
|
+
*
|
|
339
|
+
* Composes AccessControl, BodySanitizer, and QueryResolver for clean
|
|
340
|
+
* separation of concerns. CRUD methods delegate directly to these
|
|
341
|
+
* composed classes — no intermediate wrapper methods.
|
|
342
|
+
*
|
|
343
|
+
* @template TDoc - The document type
|
|
344
|
+
* @template TRepository - The repository type (defaults to RepositoryLike)
|
|
345
|
+
*/
|
|
346
|
+
var BaseController = class {
|
|
347
|
+
repository;
|
|
348
|
+
schemaOptions;
|
|
349
|
+
queryParser;
|
|
350
|
+
maxLimit;
|
|
351
|
+
defaultLimit;
|
|
352
|
+
defaultSort;
|
|
353
|
+
resourceName;
|
|
354
|
+
tenantField;
|
|
355
|
+
idField = DEFAULT_ID_FIELD;
|
|
356
|
+
/** Composable access control (ID filtering, policy checks, org scope, ownership) */
|
|
357
|
+
accessControl;
|
|
358
|
+
/** Composable body sanitization (field permissions, system fields) */
|
|
359
|
+
bodySanitizer;
|
|
360
|
+
/** Composable query resolution (parsing, pagination, sort, select/populate) */
|
|
361
|
+
queryResolver;
|
|
362
|
+
_matchesFilter;
|
|
363
|
+
_presetFields = {};
|
|
364
|
+
_cacheConfig;
|
|
365
|
+
constructor(repository, options = {}) {
|
|
366
|
+
this.repository = repository;
|
|
367
|
+
this.schemaOptions = options.schemaOptions ?? {};
|
|
368
|
+
this.queryParser = options.queryParser ?? getDefaultQueryParser();
|
|
369
|
+
this.maxLimit = options.maxLimit ?? 100;
|
|
370
|
+
this.defaultLimit = options.defaultLimit ?? DEFAULT_LIMIT;
|
|
371
|
+
this.defaultSort = options.defaultSort ?? DEFAULT_SORT;
|
|
372
|
+
this.resourceName = options.resourceName;
|
|
373
|
+
this.tenantField = options.tenantField ?? DEFAULT_TENANT_FIELD;
|
|
374
|
+
this.idField = options.idField ?? DEFAULT_ID_FIELD;
|
|
375
|
+
this._matchesFilter = options.matchesFilter;
|
|
376
|
+
if (options.cache) this._cacheConfig = options.cache;
|
|
377
|
+
if (options.presetFields) this._presetFields = options.presetFields;
|
|
378
|
+
this.accessControl = new AccessControl({
|
|
379
|
+
tenantField: this.tenantField,
|
|
380
|
+
idField: this.idField,
|
|
381
|
+
matchesFilter: this._matchesFilter
|
|
382
|
+
});
|
|
383
|
+
this.bodySanitizer = new BodySanitizer({ schemaOptions: this.schemaOptions });
|
|
384
|
+
this.queryResolver = new QueryResolver({
|
|
385
|
+
queryParser: this.queryParser,
|
|
386
|
+
maxLimit: this.maxLimit,
|
|
387
|
+
defaultLimit: this.defaultLimit,
|
|
388
|
+
defaultSort: this.defaultSort,
|
|
389
|
+
schemaOptions: this.schemaOptions,
|
|
390
|
+
tenantField: this.tenantField
|
|
391
|
+
});
|
|
392
|
+
this.list = this.list.bind(this);
|
|
393
|
+
this.get = this.get.bind(this);
|
|
394
|
+
this.create = this.create.bind(this);
|
|
395
|
+
this.update = this.update.bind(this);
|
|
396
|
+
this.delete = this.delete.bind(this);
|
|
397
|
+
}
|
|
398
|
+
/** Extract typed Arc internal metadata from request */
|
|
399
|
+
meta(req) {
|
|
400
|
+
return req.metadata;
|
|
401
|
+
}
|
|
402
|
+
/** Get hook system from request context (instance-scoped) */
|
|
403
|
+
getHooks(req) {
|
|
404
|
+
return this.meta(req)?.arc?.hooks ?? null;
|
|
405
|
+
}
|
|
406
|
+
/** Resolve cache config for a specific operation, merging per-op overrides */
|
|
407
|
+
resolveCacheConfig(operation) {
|
|
408
|
+
const cfg = this._cacheConfig;
|
|
409
|
+
if (!cfg || cfg.disabled) return null;
|
|
410
|
+
const opOverride = cfg[operation];
|
|
411
|
+
return {
|
|
412
|
+
staleTime: opOverride?.staleTime ?? cfg.staleTime ?? 0,
|
|
413
|
+
gcTime: opOverride?.gcTime ?? cfg.gcTime ?? 60,
|
|
414
|
+
tags: cfg.tags
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/** Extract user/org IDs from request for cache key scoping */
|
|
418
|
+
cacheScope(req) {
|
|
419
|
+
const userId = getUserId(req.user);
|
|
420
|
+
const scope = this.meta(req)?._scope;
|
|
421
|
+
return {
|
|
422
|
+
userId,
|
|
423
|
+
orgId: scope ? getOrgId(scope) : void 0
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
async list(req) {
|
|
427
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
428
|
+
const cacheConfig = this.resolveCacheConfig("list");
|
|
429
|
+
const qc = req.server?.queryCache;
|
|
430
|
+
if (cacheConfig && qc) {
|
|
431
|
+
const version = await qc.getResourceVersion(this.resourceName);
|
|
432
|
+
const { userId, orgId } = this.cacheScope(req);
|
|
433
|
+
const key = buildQueryKey(this.resourceName, "list", version, options, userId, orgId);
|
|
434
|
+
const { data, status } = await qc.get(key);
|
|
435
|
+
if (status === "fresh") return {
|
|
436
|
+
success: true,
|
|
437
|
+
data,
|
|
438
|
+
status: 200,
|
|
439
|
+
headers: { "x-cache": "HIT" }
|
|
440
|
+
};
|
|
441
|
+
if (status === "stale") {
|
|
442
|
+
setImmediate(() => {
|
|
443
|
+
this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
|
|
444
|
+
});
|
|
445
|
+
return {
|
|
446
|
+
success: true,
|
|
447
|
+
data,
|
|
448
|
+
status: 200,
|
|
449
|
+
headers: { "x-cache": "STALE" }
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
const result = await this.executeListQuery(options, req);
|
|
453
|
+
await qc.set(key, result, cacheConfig);
|
|
454
|
+
return {
|
|
455
|
+
success: true,
|
|
456
|
+
data: result,
|
|
457
|
+
status: 200,
|
|
458
|
+
headers: { "x-cache": "MISS" }
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
success: true,
|
|
463
|
+
data: await this.executeListQuery(options, req),
|
|
464
|
+
status: 200
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/** Execute list query through hooks (extracted for cache revalidation) */
|
|
468
|
+
async executeListQuery(options, req) {
|
|
469
|
+
const hooks = this.getHooks(req);
|
|
470
|
+
const repoGetAll = async () => this.repository.getAll(options);
|
|
471
|
+
const result = hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "list", options, repoGetAll, {
|
|
472
|
+
user: req.user,
|
|
473
|
+
context: this.meta(req)
|
|
474
|
+
}) : await repoGetAll();
|
|
475
|
+
if (Array.isArray(result)) return {
|
|
476
|
+
docs: result,
|
|
477
|
+
page: 1,
|
|
478
|
+
limit: result.length,
|
|
479
|
+
total: result.length,
|
|
480
|
+
pages: 1,
|
|
481
|
+
hasNext: false,
|
|
482
|
+
hasPrev: false
|
|
483
|
+
};
|
|
484
|
+
return result;
|
|
485
|
+
}
|
|
486
|
+
async get(req) {
|
|
487
|
+
const id = req.params.id;
|
|
488
|
+
if (!id) return {
|
|
489
|
+
success: false,
|
|
490
|
+
error: "ID parameter is required",
|
|
491
|
+
status: 400
|
|
492
|
+
};
|
|
493
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
494
|
+
const cacheConfig = this.resolveCacheConfig("byId");
|
|
495
|
+
const qc = req.server?.queryCache;
|
|
496
|
+
if (cacheConfig && qc) {
|
|
497
|
+
const version = await qc.getResourceVersion(this.resourceName);
|
|
498
|
+
const { userId, orgId } = this.cacheScope(req);
|
|
499
|
+
const key = buildQueryKey(this.resourceName, "get", version, {
|
|
500
|
+
id,
|
|
501
|
+
...options
|
|
502
|
+
}, userId, orgId);
|
|
503
|
+
const { data, status } = await qc.get(key);
|
|
504
|
+
if (status === "fresh") return {
|
|
505
|
+
success: true,
|
|
506
|
+
data,
|
|
507
|
+
status: 200,
|
|
508
|
+
headers: { "x-cache": "HIT" }
|
|
509
|
+
};
|
|
510
|
+
if (status === "stale") {
|
|
511
|
+
setImmediate(() => {
|
|
512
|
+
this.executeGetQuery(id, options, req).then((fresh) => {
|
|
513
|
+
if (fresh) qc.set(key, fresh, cacheConfig);
|
|
514
|
+
}).catch(() => {});
|
|
515
|
+
});
|
|
516
|
+
return {
|
|
517
|
+
success: true,
|
|
518
|
+
data,
|
|
519
|
+
status: 200,
|
|
520
|
+
headers: { "x-cache": "STALE" }
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
const item = await this.executeGetQuery(id, options, req);
|
|
524
|
+
if (!item) return {
|
|
525
|
+
success: false,
|
|
526
|
+
error: "Resource not found",
|
|
527
|
+
status: 404
|
|
528
|
+
};
|
|
529
|
+
await qc.set(key, item, cacheConfig);
|
|
530
|
+
return {
|
|
531
|
+
success: true,
|
|
532
|
+
data: item,
|
|
533
|
+
status: 200,
|
|
534
|
+
headers: { "x-cache": "MISS" }
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
try {
|
|
538
|
+
const item = await this.executeGetQuery(id, options, req);
|
|
539
|
+
if (!item) return {
|
|
540
|
+
success: false,
|
|
541
|
+
error: "Resource not found",
|
|
542
|
+
status: 404
|
|
543
|
+
};
|
|
544
|
+
return {
|
|
545
|
+
success: true,
|
|
546
|
+
data: item,
|
|
547
|
+
status: 200
|
|
548
|
+
};
|
|
549
|
+
} catch (error) {
|
|
550
|
+
if (error instanceof Error && error.message?.includes("not found")) return {
|
|
551
|
+
success: false,
|
|
552
|
+
error: "Resource not found",
|
|
553
|
+
status: 404
|
|
554
|
+
};
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/** Execute get query through hooks (extracted for cache revalidation) */
|
|
559
|
+
async executeGetQuery(id, options, req) {
|
|
560
|
+
const hooks = this.getHooks(req);
|
|
561
|
+
const fetchItem = async () => this.accessControl.fetchWithAccessControl(id, req, this.repository, options);
|
|
562
|
+
return (hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "read", null, fetchItem, {
|
|
563
|
+
user: req.user,
|
|
564
|
+
context: this.meta(req)
|
|
565
|
+
}) : await fetchItem()) ?? null;
|
|
566
|
+
}
|
|
567
|
+
async create(req) {
|
|
568
|
+
const arcContext = this.meta(req);
|
|
569
|
+
const data = this.bodySanitizer.sanitize(req.body ?? {}, "create", req, arcContext);
|
|
570
|
+
const scope = arcContext?._scope;
|
|
571
|
+
const createOrgId = scope ? getOrgId(scope) : void 0;
|
|
572
|
+
if (createOrgId) data[this.tenantField] = createOrgId;
|
|
573
|
+
const userId = getUserId(req.user);
|
|
574
|
+
if (userId) data.createdBy = userId;
|
|
575
|
+
const hooks = this.getHooks(req);
|
|
576
|
+
const user = req.user;
|
|
577
|
+
let processedData = data;
|
|
578
|
+
if (hooks && this.resourceName) try {
|
|
579
|
+
processedData = await hooks.executeBefore(this.resourceName, "create", data, {
|
|
580
|
+
user,
|
|
581
|
+
context: arcContext
|
|
582
|
+
});
|
|
583
|
+
} catch (err) {
|
|
584
|
+
return {
|
|
585
|
+
success: false,
|
|
586
|
+
error: "Hook execution failed",
|
|
587
|
+
details: {
|
|
588
|
+
code: "BEFORE_CREATE_HOOK_ERROR",
|
|
589
|
+
message: err.message
|
|
590
|
+
},
|
|
591
|
+
status: 400
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
const repoCreate = async () => this.repository.create(processedData, {
|
|
595
|
+
user,
|
|
596
|
+
context: arcContext
|
|
597
|
+
});
|
|
598
|
+
let item;
|
|
599
|
+
if (hooks && this.resourceName) {
|
|
600
|
+
item = await hooks.executeAround(this.resourceName, "create", processedData, repoCreate, {
|
|
601
|
+
user,
|
|
602
|
+
context: arcContext
|
|
603
|
+
});
|
|
604
|
+
await hooks.executeAfter(this.resourceName, "create", item, {
|
|
605
|
+
user,
|
|
606
|
+
context: arcContext
|
|
607
|
+
});
|
|
608
|
+
} else item = await repoCreate();
|
|
609
|
+
return {
|
|
610
|
+
success: true,
|
|
611
|
+
data: item,
|
|
612
|
+
status: 201,
|
|
613
|
+
meta: { message: "Created successfully" }
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
async update(req) {
|
|
617
|
+
const id = req.params.id;
|
|
618
|
+
if (!id) return {
|
|
619
|
+
success: false,
|
|
620
|
+
error: "ID parameter is required",
|
|
621
|
+
status: 400
|
|
622
|
+
};
|
|
623
|
+
const arcContext = this.meta(req);
|
|
624
|
+
const data = this.bodySanitizer.sanitize(req.body ?? {}, "update", req, arcContext);
|
|
625
|
+
const user = req.user;
|
|
626
|
+
const userId = getUserId(user);
|
|
627
|
+
if (userId) data.updatedBy = userId;
|
|
628
|
+
const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
|
|
629
|
+
if (!existing) return {
|
|
630
|
+
success: false,
|
|
631
|
+
error: "Resource not found",
|
|
632
|
+
status: 404
|
|
633
|
+
};
|
|
634
|
+
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
635
|
+
success: false,
|
|
636
|
+
error: "You do not have permission to modify this resource",
|
|
637
|
+
details: { code: "OWNERSHIP_DENIED" },
|
|
638
|
+
status: 403
|
|
639
|
+
};
|
|
640
|
+
const hooks = this.getHooks(req);
|
|
641
|
+
let processedData = data;
|
|
642
|
+
if (hooks && this.resourceName) try {
|
|
643
|
+
processedData = await hooks.executeBefore(this.resourceName, "update", data, {
|
|
644
|
+
user,
|
|
645
|
+
context: arcContext,
|
|
646
|
+
meta: {
|
|
647
|
+
id,
|
|
648
|
+
existing
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
} catch (err) {
|
|
652
|
+
return {
|
|
653
|
+
success: false,
|
|
654
|
+
error: "Hook execution failed",
|
|
655
|
+
details: {
|
|
656
|
+
code: "BEFORE_UPDATE_HOOK_ERROR",
|
|
657
|
+
message: err.message
|
|
658
|
+
},
|
|
659
|
+
status: 400
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
const repoUpdate = async () => this.repository.update(id, processedData, {
|
|
663
|
+
user,
|
|
664
|
+
context: arcContext
|
|
665
|
+
});
|
|
666
|
+
let item;
|
|
667
|
+
if (hooks && this.resourceName) {
|
|
668
|
+
item = await hooks.executeAround(this.resourceName, "update", processedData, repoUpdate, {
|
|
669
|
+
user,
|
|
670
|
+
context: arcContext,
|
|
671
|
+
meta: {
|
|
672
|
+
id,
|
|
673
|
+
existing
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
if (item) await hooks.executeAfter(this.resourceName, "update", item, {
|
|
677
|
+
user,
|
|
678
|
+
context: arcContext,
|
|
679
|
+
meta: {
|
|
680
|
+
id,
|
|
681
|
+
existing
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
} else item = await repoUpdate();
|
|
685
|
+
if (!item) return {
|
|
686
|
+
success: false,
|
|
687
|
+
error: "Resource not found",
|
|
688
|
+
status: 404
|
|
689
|
+
};
|
|
690
|
+
return {
|
|
691
|
+
success: true,
|
|
692
|
+
data: item,
|
|
693
|
+
status: 200,
|
|
694
|
+
meta: { message: "Updated successfully" }
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
async delete(req) {
|
|
698
|
+
const id = req.params.id;
|
|
699
|
+
if (!id) return {
|
|
700
|
+
success: false,
|
|
701
|
+
error: "ID parameter is required",
|
|
702
|
+
status: 400
|
|
703
|
+
};
|
|
704
|
+
const arcContext = this.meta(req);
|
|
705
|
+
const user = req.user;
|
|
706
|
+
const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
|
|
707
|
+
if (!existing) return {
|
|
708
|
+
success: false,
|
|
709
|
+
error: "Resource not found",
|
|
710
|
+
status: 404
|
|
711
|
+
};
|
|
712
|
+
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
713
|
+
success: false,
|
|
714
|
+
error: "You do not have permission to delete this resource",
|
|
715
|
+
details: { code: "OWNERSHIP_DENIED" },
|
|
716
|
+
status: 403
|
|
717
|
+
};
|
|
718
|
+
const hooks = this.getHooks(req);
|
|
719
|
+
if (hooks && this.resourceName) try {
|
|
720
|
+
await hooks.executeBefore(this.resourceName, "delete", existing, {
|
|
721
|
+
user,
|
|
722
|
+
context: arcContext,
|
|
723
|
+
meta: { id }
|
|
724
|
+
});
|
|
725
|
+
} catch (err) {
|
|
726
|
+
return {
|
|
727
|
+
success: false,
|
|
728
|
+
error: "Hook execution failed",
|
|
729
|
+
details: {
|
|
730
|
+
code: "BEFORE_DELETE_HOOK_ERROR",
|
|
731
|
+
message: err.message
|
|
732
|
+
},
|
|
733
|
+
status: 400
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
const repoDelete = async () => this.repository.delete(id, {
|
|
737
|
+
user,
|
|
738
|
+
context: arcContext
|
|
739
|
+
});
|
|
740
|
+
let result;
|
|
741
|
+
if (hooks && this.resourceName) result = await hooks.executeAround(this.resourceName, "delete", existing, repoDelete, {
|
|
742
|
+
user,
|
|
743
|
+
context: arcContext,
|
|
744
|
+
meta: { id }
|
|
745
|
+
});
|
|
746
|
+
else result = await repoDelete();
|
|
747
|
+
if (!(typeof result === "object" && result !== null ? result.success : result)) return {
|
|
748
|
+
success: false,
|
|
749
|
+
error: "Resource not found",
|
|
750
|
+
status: 404
|
|
751
|
+
};
|
|
752
|
+
if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "delete", existing, {
|
|
753
|
+
user,
|
|
754
|
+
context: arcContext,
|
|
755
|
+
meta: { id }
|
|
756
|
+
});
|
|
757
|
+
return {
|
|
758
|
+
success: true,
|
|
759
|
+
data: { message: "Deleted successfully" },
|
|
760
|
+
status: 200
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
async getBySlug(req) {
|
|
764
|
+
const repo = this.repository;
|
|
765
|
+
if (!repo.getBySlug) return {
|
|
766
|
+
success: false,
|
|
767
|
+
error: "Slug lookup not implemented",
|
|
768
|
+
status: 501
|
|
769
|
+
};
|
|
770
|
+
const slugField = this._presetFields.slugField ?? "slug";
|
|
771
|
+
const slug = req.params[slugField] ?? req.params.slug;
|
|
772
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
773
|
+
const arcContext = this.meta(req);
|
|
774
|
+
const item = await repo.getBySlug(slug, options);
|
|
775
|
+
if (!item || !this.accessControl.checkOrgScope(item, arcContext)) return {
|
|
776
|
+
success: false,
|
|
777
|
+
error: "Resource not found",
|
|
778
|
+
status: 404
|
|
779
|
+
};
|
|
780
|
+
return {
|
|
781
|
+
success: true,
|
|
782
|
+
data: item,
|
|
783
|
+
status: 200
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
async getDeleted(req) {
|
|
787
|
+
const repo = this.repository;
|
|
788
|
+
if (!repo.getDeleted) return {
|
|
789
|
+
success: false,
|
|
790
|
+
error: "Soft delete not implemented",
|
|
791
|
+
status: 501
|
|
792
|
+
};
|
|
793
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
794
|
+
const result = await repo.getDeleted(options);
|
|
795
|
+
if (Array.isArray(result)) return {
|
|
796
|
+
success: true,
|
|
797
|
+
data: {
|
|
798
|
+
docs: result,
|
|
799
|
+
page: 1,
|
|
800
|
+
limit: result.length,
|
|
801
|
+
total: result.length,
|
|
802
|
+
pages: 1,
|
|
803
|
+
hasNext: false,
|
|
804
|
+
hasPrev: false
|
|
805
|
+
},
|
|
806
|
+
status: 200
|
|
807
|
+
};
|
|
808
|
+
return {
|
|
809
|
+
success: true,
|
|
810
|
+
data: result,
|
|
811
|
+
status: 200
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
async restore(req) {
|
|
815
|
+
const repo = this.repository;
|
|
816
|
+
if (!repo.restore) return {
|
|
817
|
+
success: false,
|
|
818
|
+
error: "Restore not implemented",
|
|
819
|
+
status: 501
|
|
820
|
+
};
|
|
821
|
+
const id = req.params.id;
|
|
822
|
+
if (!id) return {
|
|
823
|
+
success: false,
|
|
824
|
+
error: "ID parameter is required",
|
|
825
|
+
status: 400
|
|
826
|
+
};
|
|
827
|
+
const item = await repo.restore(id);
|
|
828
|
+
if (!item) return {
|
|
829
|
+
success: false,
|
|
830
|
+
error: "Resource not found",
|
|
831
|
+
status: 404
|
|
832
|
+
};
|
|
833
|
+
return {
|
|
834
|
+
success: true,
|
|
835
|
+
data: item,
|
|
836
|
+
status: 200,
|
|
837
|
+
meta: { message: "Restored successfully" }
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
async getTree(req) {
|
|
841
|
+
const repo = this.repository;
|
|
842
|
+
if (!repo.getTree) return {
|
|
843
|
+
success: false,
|
|
844
|
+
error: "Tree structure not implemented",
|
|
845
|
+
status: 501
|
|
846
|
+
};
|
|
847
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
848
|
+
return {
|
|
849
|
+
success: true,
|
|
850
|
+
data: await repo.getTree(options),
|
|
851
|
+
status: 200
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
async getChildren(req) {
|
|
855
|
+
const repo = this.repository;
|
|
856
|
+
if (!repo.getChildren) return {
|
|
857
|
+
success: false,
|
|
858
|
+
error: "Tree structure not implemented",
|
|
859
|
+
status: 501
|
|
860
|
+
};
|
|
861
|
+
const parentField = this._presetFields.parentField ?? "parent";
|
|
862
|
+
const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
|
|
863
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
864
|
+
return {
|
|
865
|
+
success: true,
|
|
866
|
+
data: await repo.getChildren(parentId, options),
|
|
867
|
+
status: 200
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
//#endregion
|
|
873
|
+
//#region src/core/fastifyAdapter.ts
|
|
874
|
+
/** Type guard for Mongoose-like documents with toObject() */
|
|
875
|
+
function isMongooseDoc(obj) {
|
|
876
|
+
return !!obj && typeof obj === "object" && "toObject" in obj && typeof obj.toObject === "function";
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Apply field mask to a single object
|
|
880
|
+
* Filters fields based on include/exclude rules
|
|
881
|
+
*/
|
|
882
|
+
function applyFieldMaskToObject(obj, fieldMask) {
|
|
883
|
+
if (!obj || typeof obj !== "object") return obj;
|
|
884
|
+
const plain = isMongooseDoc(obj) ? obj.toObject() : obj;
|
|
885
|
+
const { include, exclude } = fieldMask;
|
|
886
|
+
if (include && include.length > 0) {
|
|
887
|
+
const filtered = {};
|
|
888
|
+
for (const field of include) if (field in plain) filtered[field] = plain[field];
|
|
889
|
+
return filtered;
|
|
890
|
+
}
|
|
891
|
+
if (exclude && exclude.length > 0) {
|
|
892
|
+
const filtered = { ...plain };
|
|
893
|
+
for (const field of exclude) delete filtered[field];
|
|
894
|
+
return filtered;
|
|
895
|
+
}
|
|
896
|
+
return plain;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Apply field mask to response data (handles both objects and arrays)
|
|
900
|
+
*/
|
|
901
|
+
function applyFieldMask(data, fieldMask) {
|
|
902
|
+
if (!fieldMask) return data;
|
|
903
|
+
if (Array.isArray(data)) return data.map((item) => applyFieldMaskToObject(item, fieldMask));
|
|
904
|
+
if (data && typeof data === "object") return applyFieldMaskToObject(data, fieldMask);
|
|
905
|
+
return data;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Create IRequestContext from Fastify request
|
|
909
|
+
*
|
|
910
|
+
* Extracts framework-agnostic context from Fastify-specific request object
|
|
911
|
+
*/
|
|
912
|
+
function createRequestContext(req) {
|
|
913
|
+
const reqWithExtras = req;
|
|
914
|
+
const requestContext = reqWithExtras.context ?? {};
|
|
915
|
+
const srv = req.server;
|
|
916
|
+
const serverAccessor = {
|
|
917
|
+
events: srv && "events" in srv ? srv.events : void 0,
|
|
918
|
+
audit: srv && "audit" in srv ? srv.audit : void 0,
|
|
919
|
+
queryCache: srv && "queryCache" in srv ? srv.queryCache : void 0,
|
|
920
|
+
log: req.log
|
|
921
|
+
};
|
|
922
|
+
return {
|
|
923
|
+
query: reqWithExtras.query ?? {},
|
|
924
|
+
body: reqWithExtras.body ?? {},
|
|
925
|
+
params: reqWithExtras.params ?? {},
|
|
926
|
+
headers: reqWithExtras.headers,
|
|
927
|
+
user: reqWithExtras.user ? (() => {
|
|
928
|
+
const user = reqWithExtras.user;
|
|
929
|
+
const rawId = user._id ?? user.id;
|
|
930
|
+
const normalizedId = rawId ? String(rawId) : void 0;
|
|
931
|
+
return {
|
|
932
|
+
...user,
|
|
933
|
+
id: normalizedId,
|
|
934
|
+
_id: normalizedId
|
|
935
|
+
};
|
|
936
|
+
})() : null,
|
|
937
|
+
context: requestContext,
|
|
938
|
+
metadata: {
|
|
939
|
+
...reqWithExtras.context,
|
|
940
|
+
arc: reqWithExtras.arc,
|
|
941
|
+
_scope: reqWithExtras.scope,
|
|
942
|
+
_ownershipCheck: reqWithExtras._ownershipCheck,
|
|
943
|
+
_policyFilters: reqWithExtras._policyFilters ?? {},
|
|
944
|
+
log: reqWithExtras.log
|
|
945
|
+
},
|
|
946
|
+
server: serverAccessor
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Get typed auth context from an IRequestContext.
|
|
951
|
+
* Use this in controller overrides to access request context.
|
|
952
|
+
*
|
|
953
|
+
* For org scope, use `getControllerScope(req)` instead.
|
|
954
|
+
*/
|
|
955
|
+
function getControllerContext(req) {
|
|
956
|
+
return req.context ?? req.metadata ?? {};
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Get request scope from an IRequestContext.
|
|
960
|
+
* Returns the RequestScope set by auth adapters.
|
|
961
|
+
*/
|
|
962
|
+
function getControllerScope(req) {
|
|
963
|
+
return req.metadata?._scope ?? PUBLIC_SCOPE;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Compute per-field capability metadata for the current user.
|
|
967
|
+
* Only includes fields that have restrictions — unrestricted fields
|
|
968
|
+
* are omitted (frontend defaults to { readable: true, writable: true }).
|
|
969
|
+
*/
|
|
970
|
+
function computeFieldCapabilities(fieldPerms, effectiveRoles) {
|
|
971
|
+
const caps = {};
|
|
972
|
+
for (const [field, perm] of Object.entries(fieldPerms)) {
|
|
973
|
+
let readable = true;
|
|
974
|
+
let writable = true;
|
|
975
|
+
switch (perm._type) {
|
|
976
|
+
case "hidden":
|
|
977
|
+
readable = false;
|
|
978
|
+
writable = false;
|
|
979
|
+
break;
|
|
980
|
+
case "visibleTo":
|
|
981
|
+
readable = perm.roles?.some((r) => effectiveRoles.includes(r)) ?? false;
|
|
982
|
+
break;
|
|
983
|
+
case "writableBy":
|
|
984
|
+
writable = perm.roles?.some((r) => effectiveRoles.includes(r)) ?? false;
|
|
985
|
+
break;
|
|
986
|
+
}
|
|
987
|
+
caps[field] = {
|
|
988
|
+
readable,
|
|
989
|
+
writable
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
return caps;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Send IControllerResponse via Fastify reply
|
|
996
|
+
*
|
|
997
|
+
* Converts framework-agnostic response to Fastify response
|
|
998
|
+
* Applies field masking if specified in request
|
|
999
|
+
*/
|
|
1000
|
+
function sendControllerResponse(reply, response, request) {
|
|
1001
|
+
const reqWithExtras = request;
|
|
1002
|
+
const fieldMaskConfig = reqWithExtras?.fieldMask;
|
|
1003
|
+
const arcMeta = reqWithExtras?.arc;
|
|
1004
|
+
const scope = reqWithExtras?.scope ?? PUBLIC_SCOPE;
|
|
1005
|
+
const fieldPerms = isElevated(scope) ? void 0 : arcMeta?.fields;
|
|
1006
|
+
const effectiveRoles = fieldPerms ? resolveEffectiveRoles((reqWithExtras?.user)?.roles ?? [], isMember(scope) ? scope.orgRoles : []) : [];
|
|
1007
|
+
const fieldCaps = fieldPerms ? computeFieldCapabilities(fieldPerms, effectiveRoles) : void 0;
|
|
1008
|
+
const hasFieldRestrictions = !!(fieldMaskConfig || fieldPerms);
|
|
1009
|
+
/** Apply both field mask and field-level permissions to a data item */
|
|
1010
|
+
const applyPermissions = (data) => {
|
|
1011
|
+
let result = fieldMaskConfig ? applyFieldMask(data, fieldMaskConfig) : data;
|
|
1012
|
+
if (fieldPerms && result && typeof result === "object") if (Array.isArray(result)) result = result.map((item) => applyFieldReadPermissions(item, fieldPerms, effectiveRoles));
|
|
1013
|
+
else result = applyFieldReadPermissions(result, fieldPerms, effectiveRoles);
|
|
1014
|
+
return result;
|
|
1015
|
+
};
|
|
1016
|
+
if (response.headers) for (const [key, value] of Object.entries(response.headers)) reply.header(key, value);
|
|
1017
|
+
if (response.success && response.data && typeof response.data === "object" && "docs" in response.data) {
|
|
1018
|
+
const paginatedData = response.data;
|
|
1019
|
+
const filteredDocs = hasFieldRestrictions ? applyPermissions(paginatedData.docs) : paginatedData.docs;
|
|
1020
|
+
reply.code(response.status ?? 200).send({
|
|
1021
|
+
success: true,
|
|
1022
|
+
docs: filteredDocs,
|
|
1023
|
+
page: paginatedData.page,
|
|
1024
|
+
limit: paginatedData.limit,
|
|
1025
|
+
total: paginatedData.total,
|
|
1026
|
+
pages: paginatedData.pages,
|
|
1027
|
+
hasNext: paginatedData.hasNext,
|
|
1028
|
+
hasPrev: paginatedData.hasPrev,
|
|
1029
|
+
...response.meta ?? {},
|
|
1030
|
+
...fieldCaps ? { fieldPermissions: fieldCaps } : {}
|
|
1031
|
+
});
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const filteredData = hasFieldRestrictions ? applyPermissions(response.data) : response.data;
|
|
1035
|
+
reply.code(response.status ?? (response.success ? 200 : 400)).send({
|
|
1036
|
+
success: response.success,
|
|
1037
|
+
data: filteredData,
|
|
1038
|
+
error: response.error,
|
|
1039
|
+
details: response.details,
|
|
1040
|
+
...response.meta ?? {},
|
|
1041
|
+
...fieldCaps ? { fieldPermissions: fieldCaps } : {}
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Create Fastify route handler from IController method
|
|
1046
|
+
*
|
|
1047
|
+
* Wraps framework-agnostic controller method in Fastify-specific handler
|
|
1048
|
+
*
|
|
1049
|
+
* @example
|
|
1050
|
+
* ```typescript
|
|
1051
|
+
* const controller = new BaseController(repository);
|
|
1052
|
+
*
|
|
1053
|
+
* // Create Fastify handler
|
|
1054
|
+
* const listHandler = createFastifyHandler(controller.list.bind(controller));
|
|
1055
|
+
*
|
|
1056
|
+
* // Register route
|
|
1057
|
+
* fastify.get('/products', listHandler);
|
|
1058
|
+
* ```
|
|
1059
|
+
*/
|
|
1060
|
+
function createFastifyHandler(controllerMethod) {
|
|
1061
|
+
return async (req, reply) => {
|
|
1062
|
+
sendControllerResponse(reply, await controllerMethod(createRequestContext(req)), req);
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Create Fastify adapters for all CRUD methods of an IController
|
|
1067
|
+
*
|
|
1068
|
+
* Returns Fastify-compatible handlers for each CRUD operation
|
|
1069
|
+
*
|
|
1070
|
+
* @example
|
|
1071
|
+
* ```typescript
|
|
1072
|
+
* const controller = new BaseController(repository);
|
|
1073
|
+
* const handlers = createCrudHandlers(controller);
|
|
1074
|
+
*
|
|
1075
|
+
* fastify.get('/', handlers.list);
|
|
1076
|
+
* fastify.get('/:id', handlers.get);
|
|
1077
|
+
* fastify.post('/', handlers.create);
|
|
1078
|
+
* fastify.patch('/:id', handlers.update);
|
|
1079
|
+
* fastify.delete('/:id', handlers.delete);
|
|
1080
|
+
* ```
|
|
1081
|
+
*/
|
|
1082
|
+
function createCrudHandlers(controller) {
|
|
1083
|
+
return {
|
|
1084
|
+
list: createFastifyHandler(controller.list.bind(controller)),
|
|
1085
|
+
get: createFastifyHandler(controller.get.bind(controller)),
|
|
1086
|
+
create: createFastifyHandler(controller.create.bind(controller)),
|
|
1087
|
+
update: createFastifyHandler(controller.update.bind(controller)),
|
|
1088
|
+
delete: createFastifyHandler(controller.delete.bind(controller))
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
//#endregion
|
|
1093
|
+
//#region src/pipeline/pipe.ts
|
|
1094
|
+
/**
|
|
1095
|
+
* Compose pipeline steps into an ordered array.
|
|
1096
|
+
* Accepts guards, transforms, and interceptors in any order.
|
|
1097
|
+
*/
|
|
1098
|
+
function pipe(...steps) {
|
|
1099
|
+
return steps;
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Check if a step applies to the given operation.
|
|
1103
|
+
*/
|
|
1104
|
+
function appliesTo(step, operation) {
|
|
1105
|
+
if (!step.operations || step.operations.length === 0) return true;
|
|
1106
|
+
return step.operations.includes(operation);
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Execute a pipeline against a request context.
|
|
1110
|
+
*
|
|
1111
|
+
* This is the core runtime that createCrudRouter uses to execute pipelines.
|
|
1112
|
+
* External usage is not needed — this is wired automatically when `pipe` is set.
|
|
1113
|
+
*
|
|
1114
|
+
* @param steps - Pipeline steps to execute
|
|
1115
|
+
* @param ctx - The pipeline context (extends IRequestContext)
|
|
1116
|
+
* @param handler - The actual controller method to call
|
|
1117
|
+
* @param operation - The CRUD operation name
|
|
1118
|
+
* @returns The controller response (possibly modified by interceptors)
|
|
1119
|
+
*/
|
|
1120
|
+
async function executePipeline(steps, ctx, handler, operation) {
|
|
1121
|
+
const guards = [];
|
|
1122
|
+
const transforms = [];
|
|
1123
|
+
const interceptors = [];
|
|
1124
|
+
for (const step of steps) {
|
|
1125
|
+
if (!appliesTo(step, operation)) continue;
|
|
1126
|
+
switch (step._type) {
|
|
1127
|
+
case "guard":
|
|
1128
|
+
guards.push(step);
|
|
1129
|
+
break;
|
|
1130
|
+
case "transform":
|
|
1131
|
+
transforms.push(step);
|
|
1132
|
+
break;
|
|
1133
|
+
case "interceptor":
|
|
1134
|
+
interceptors.push(step);
|
|
1135
|
+
break;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
for (const g of guards) if (!await g.handler(ctx)) throw new ForbiddenError(`Guard '${g.name}' denied access`);
|
|
1139
|
+
let currentCtx = ctx;
|
|
1140
|
+
for (const t of transforms) {
|
|
1141
|
+
const result = await t.handler(currentCtx);
|
|
1142
|
+
if (result) currentCtx = result;
|
|
1143
|
+
}
|
|
1144
|
+
let chain = () => handler(currentCtx);
|
|
1145
|
+
for (let i = interceptors.length - 1; i >= 0; i--) {
|
|
1146
|
+
const interceptor = interceptors[i];
|
|
1147
|
+
const next = chain;
|
|
1148
|
+
chain = () => interceptor.handler(currentCtx, next);
|
|
1149
|
+
}
|
|
1150
|
+
return chain();
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
//#endregion
|
|
1154
|
+
//#region src/core/createCrudRouter.ts
|
|
1155
|
+
/**
|
|
1156
|
+
* Build per-route rate limit config object.
|
|
1157
|
+
*
|
|
1158
|
+
* Returns a `config` object suitable for Fastify's `route()` options,
|
|
1159
|
+
* or `undefined` if no rate limit is configured for this resource.
|
|
1160
|
+
*
|
|
1161
|
+
* - `RateLimitConfig` object -> apply that limit to the route
|
|
1162
|
+
* - `false` -> explicitly disable rate limiting for the route
|
|
1163
|
+
* - `undefined` -> no override (inherits instance-level config)
|
|
1164
|
+
*/
|
|
1165
|
+
function buildRateLimitConfig(rateLimit) {
|
|
1166
|
+
if (rateLimit === void 0) return void 0;
|
|
1167
|
+
if (rateLimit === false) return { rateLimit: false };
|
|
1168
|
+
return { rateLimit: {
|
|
1169
|
+
max: rateLimit.max,
|
|
1170
|
+
timeWindow: rateLimit.timeWindow
|
|
1171
|
+
} };
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Check if a permission requires authentication
|
|
1175
|
+
*
|
|
1176
|
+
* A permission requires auth if:
|
|
1177
|
+
* - It exists AND
|
|
1178
|
+
* - It doesn't have _isPublic flag set to true
|
|
1179
|
+
*
|
|
1180
|
+
* This is used to automatically add fastify.authenticate
|
|
1181
|
+
* to the preHandler chain for non-public routes.
|
|
1182
|
+
*/
|
|
1183
|
+
function requiresAuthentication(permission) {
|
|
1184
|
+
if (!permission) return false;
|
|
1185
|
+
return !permission._isPublic;
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Build authentication middleware
|
|
1189
|
+
*
|
|
1190
|
+
* - Protected routes (requireAuth, requireRoles, etc.): uses fastify.authenticate (fails without token)
|
|
1191
|
+
* - Public routes (allowPublic): uses fastify.optionalAuthenticate (parses token if present, doesn't fail)
|
|
1192
|
+
*
|
|
1193
|
+
* This ensures request.user is populated on public routes when a Bearer token is sent,
|
|
1194
|
+
* enabling downstream middleware (e.g. multiTenant flexible filter) to apply org-scoped queries.
|
|
1195
|
+
*/
|
|
1196
|
+
function buildAuthMiddleware(fastify, permission) {
|
|
1197
|
+
if (requiresAuthentication(permission)) return fastify.authenticate ?? null;
|
|
1198
|
+
return fastify.optionalAuthenticate ?? null;
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Build permission middleware from PermissionCheck function
|
|
1202
|
+
*
|
|
1203
|
+
* Creates a Fastify preHandler that:
|
|
1204
|
+
* 1. Executes the permission check
|
|
1205
|
+
* 2. Returns 401 if authentication required but user absent
|
|
1206
|
+
* 3. Returns 403 if permission denied
|
|
1207
|
+
* 4. Applies query filters from PermissionResult if present
|
|
1208
|
+
*/
|
|
1209
|
+
function buildPermissionMiddleware(permissionCheck, resourceName, action) {
|
|
1210
|
+
if (!permissionCheck) return null;
|
|
1211
|
+
return async (request, reply) => {
|
|
1212
|
+
const reqWithExtras = request;
|
|
1213
|
+
const params = request.params;
|
|
1214
|
+
const context = {
|
|
1215
|
+
user: reqWithExtras.user ?? null,
|
|
1216
|
+
request,
|
|
1217
|
+
resource: resourceName,
|
|
1218
|
+
action,
|
|
1219
|
+
resourceId: params?.id,
|
|
1220
|
+
params,
|
|
1221
|
+
data: request.body
|
|
1222
|
+
};
|
|
1223
|
+
let result;
|
|
1224
|
+
try {
|
|
1225
|
+
result = await permissionCheck(context);
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
request.log?.warn?.({
|
|
1228
|
+
err,
|
|
1229
|
+
resource: resourceName,
|
|
1230
|
+
action
|
|
1231
|
+
}, "Permission check threw");
|
|
1232
|
+
reply.code(403).send({
|
|
1233
|
+
success: false,
|
|
1234
|
+
error: "Permission denied"
|
|
1235
|
+
});
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
if (typeof result === "boolean") {
|
|
1239
|
+
if (!result) {
|
|
1240
|
+
reply.code(context.user ? 403 : 401).send({
|
|
1241
|
+
success: false,
|
|
1242
|
+
error: context.user ? "Permission denied" : "Authentication required"
|
|
1243
|
+
});
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
const permResult = result;
|
|
1249
|
+
if (!permResult.granted) {
|
|
1250
|
+
reply.code(context.user ? 403 : 401).send({
|
|
1251
|
+
success: false,
|
|
1252
|
+
error: permResult.reason ?? (context.user ? "Permission denied" : "Authentication required")
|
|
1253
|
+
});
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
if (permResult.filters) reqWithExtras._policyFilters = {
|
|
1257
|
+
...reqWithExtras._policyFilters ?? {},
|
|
1258
|
+
...permResult.filters
|
|
1259
|
+
};
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Create additional routes from preset/custom definitions
|
|
1264
|
+
*/
|
|
1265
|
+
function createAdditionalRoutes(fastify, routes, controller, options) {
|
|
1266
|
+
const { tag, resourceName, arcDecorator, rateLimitConfig, cacheMw, idempotencyMw, pipeline } = options;
|
|
1267
|
+
for (const route of routes) {
|
|
1268
|
+
const opName = route.operation ?? (typeof route.handler === "string" ? route.handler : `${route.method.toLowerCase()}${route.path.replace(/[/:]/g, "_")}`);
|
|
1269
|
+
let handler;
|
|
1270
|
+
if (typeof route.handler === "string") {
|
|
1271
|
+
if (!controller) throw new Error(`Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller. Either provide a controller or use a function handler instead.`);
|
|
1272
|
+
const method = controller[route.handler];
|
|
1273
|
+
if (typeof method !== "function") throw new Error(`Handler '${route.handler}' not found on controller`);
|
|
1274
|
+
const boundMethod = method.bind(controller);
|
|
1275
|
+
if (route.wrapHandler) {
|
|
1276
|
+
const steps = pipeline ? resolvePipelineSteps(pipeline, opName) : [];
|
|
1277
|
+
if (steps.length > 0) handler = createPipelineHandler(boundMethod, steps, opName, resourceName);
|
|
1278
|
+
else handler = createFastifyHandler(boundMethod);
|
|
1279
|
+
} else handler = boundMethod;
|
|
1280
|
+
} else if (route.wrapHandler) {
|
|
1281
|
+
const steps = pipeline ? resolvePipelineSteps(pipeline, opName) : [];
|
|
1282
|
+
if (steps.length > 0) handler = createPipelineHandler(route.handler, steps, opName, resourceName);
|
|
1283
|
+
else handler = createFastifyHandler(route.handler);
|
|
1284
|
+
} else handler = route.handler;
|
|
1285
|
+
const routeTags = route.tags ?? (tag ? [tag] : void 0);
|
|
1286
|
+
const convertedSchema = route.schema ? convertRouteSchema(route.schema) : void 0;
|
|
1287
|
+
const schema = {
|
|
1288
|
+
...routeTags ? { tags: routeTags } : {},
|
|
1289
|
+
...route.summary ? { summary: route.summary } : {},
|
|
1290
|
+
...route.description ? { description: route.description } : {},
|
|
1291
|
+
...convertedSchema ?? {}
|
|
1292
|
+
};
|
|
1293
|
+
const authMw = buildAuthMiddleware(fastify, route.permissions);
|
|
1294
|
+
const permissionMw = buildPermissionMiddleware(route.permissions, resourceName, opName);
|
|
1295
|
+
const customPreHandlers = typeof route.preHandler === "function" ? route.preHandler(fastify) : route.preHandler ?? [];
|
|
1296
|
+
const preHandler = [
|
|
1297
|
+
arcDecorator,
|
|
1298
|
+
authMw,
|
|
1299
|
+
permissionMw,
|
|
1300
|
+
route.method === "GET" ? cacheMw : [
|
|
1301
|
+
"POST",
|
|
1302
|
+
"PUT",
|
|
1303
|
+
"PATCH"
|
|
1304
|
+
].includes(route.method) ? idempotencyMw : null,
|
|
1305
|
+
...customPreHandlers
|
|
1306
|
+
].filter(Boolean);
|
|
1307
|
+
fastify.route({
|
|
1308
|
+
method: route.method,
|
|
1309
|
+
url: route.path,
|
|
1310
|
+
schema,
|
|
1311
|
+
preHandler: preHandler.length > 0 ? preHandler : void 0,
|
|
1312
|
+
handler,
|
|
1313
|
+
...rateLimitConfig ? { config: rateLimitConfig } : {}
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Resolve pipeline steps for a specific operation.
|
|
1319
|
+
* If pipeline is a flat array, all steps are returned.
|
|
1320
|
+
* If it's a per-operation map, only matching steps are returned.
|
|
1321
|
+
*/
|
|
1322
|
+
function resolvePipelineSteps(pipeline, operation) {
|
|
1323
|
+
if (!pipeline) return [];
|
|
1324
|
+
if (Array.isArray(pipeline)) return pipeline;
|
|
1325
|
+
return pipeline[operation] ?? [];
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Create a Fastify handler that wraps a controller method with pipeline execution.
|
|
1329
|
+
*/
|
|
1330
|
+
function createPipelineHandler(controllerMethod, steps, operation, resourceName) {
|
|
1331
|
+
return async (req, reply) => {
|
|
1332
|
+
sendControllerResponse(reply, await executePipeline(steps, {
|
|
1333
|
+
...createRequestContext(req),
|
|
1334
|
+
resource: resourceName,
|
|
1335
|
+
operation
|
|
1336
|
+
}, (ctx) => controllerMethod(ctx), operation), req);
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Create CRUD routes for a controller
|
|
1341
|
+
*
|
|
1342
|
+
* @param fastify - Fastify instance with Arc decorators
|
|
1343
|
+
* @param controller - CRUD controller with handler methods
|
|
1344
|
+
* @param options - Router configuration
|
|
1345
|
+
*/
|
|
1346
|
+
function createCrudRouter(fastify, controller, options = {}) {
|
|
1347
|
+
const { tag = "Resource", schemas = {}, permissions = {}, middlewares = {}, additionalRoutes = [], disableDefaultRoutes = false, disabledRoutes = [], resourceName = "unknown", schemaOptions, rateLimit, pipe: pipeline, fields: fieldPermissions, updateMethod = DEFAULT_UPDATE_METHOD } = options;
|
|
1348
|
+
const rateLimitConfig = buildRateLimitConfig(rateLimit);
|
|
1349
|
+
const cacheMw = !(fastify.hasDecorator("queryCache") && controller && typeof controller._cacheConfig !== "undefined" && controller._cacheConfig !== void 0) && fastify.hasDecorator("responseCache") ? fastify.responseCache.middleware : null;
|
|
1350
|
+
const idempotencyMw = fastify.hasDecorator("idempotency") ? fastify.idempotency.middleware : null;
|
|
1351
|
+
const arcMeta = Object.freeze({
|
|
1352
|
+
resourceName,
|
|
1353
|
+
schemaOptions,
|
|
1354
|
+
permissions,
|
|
1355
|
+
hooks: fastify.arc?.hooks,
|
|
1356
|
+
events: fastify.events,
|
|
1357
|
+
fields: fieldPermissions
|
|
1358
|
+
});
|
|
1359
|
+
const arcDecorator = async (req, _reply) => {
|
|
1360
|
+
req.arc = arcMeta;
|
|
1361
|
+
const store = requestContext.get();
|
|
1362
|
+
if (store) store.resourceName = resourceName;
|
|
1363
|
+
};
|
|
1364
|
+
const mw = {
|
|
1365
|
+
list: middlewares.list ?? [],
|
|
1366
|
+
get: middlewares.get ?? [],
|
|
1367
|
+
create: middlewares.create ?? [],
|
|
1368
|
+
update: middlewares.update ?? [],
|
|
1369
|
+
delete: middlewares.delete ?? []
|
|
1370
|
+
};
|
|
1371
|
+
const idParamsSchema = {
|
|
1372
|
+
type: "object",
|
|
1373
|
+
properties: { id: { type: "string" } },
|
|
1374
|
+
required: ["id"]
|
|
1375
|
+
};
|
|
1376
|
+
const defaultSchemas = getDefaultCrudSchemas();
|
|
1377
|
+
/**
|
|
1378
|
+
* Build route schema by merging: base (tags/summary) → defaults (response/querystring) → user overrides.
|
|
1379
|
+
* User-provided schemas always take precedence. Defaults enable fast-json-stringify when no user schema is set.
|
|
1380
|
+
*/
|
|
1381
|
+
const buildSchema = (base, defaults, userSchema) => ({
|
|
1382
|
+
...defaults,
|
|
1383
|
+
...base,
|
|
1384
|
+
...userSchema ?? {}
|
|
1385
|
+
});
|
|
1386
|
+
let handlers;
|
|
1387
|
+
if (!disableDefaultRoutes) {
|
|
1388
|
+
if (!controller) throw new Error("Controller is required when disableDefaultRoutes is not true. Provide a controller or use defineResource which auto-creates BaseController.");
|
|
1389
|
+
const ctrl = controller;
|
|
1390
|
+
if (pipeline) {
|
|
1391
|
+
const ops = CRUD_OPERATIONS;
|
|
1392
|
+
const wrapped = {};
|
|
1393
|
+
for (const op of ops) {
|
|
1394
|
+
const steps = resolvePipelineSteps(pipeline, op);
|
|
1395
|
+
if (steps.length > 0) wrapped[op] = createPipelineHandler(ctrl[op].bind(ctrl), steps, op, resourceName);
|
|
1396
|
+
}
|
|
1397
|
+
handlers = {
|
|
1398
|
+
...createCrudHandlers(ctrl),
|
|
1399
|
+
...wrapped
|
|
1400
|
+
};
|
|
1401
|
+
} else handlers = createCrudHandlers(ctrl);
|
|
1402
|
+
}
|
|
1403
|
+
if (!disableDefaultRoutes && handlers) {
|
|
1404
|
+
if (!disabledRoutes.includes("list")) {
|
|
1405
|
+
const listPreHandler = [
|
|
1406
|
+
arcDecorator,
|
|
1407
|
+
buildAuthMiddleware(fastify, permissions.list),
|
|
1408
|
+
buildPermissionMiddleware(permissions.list, resourceName, "list"),
|
|
1409
|
+
cacheMw,
|
|
1410
|
+
...mw.list
|
|
1411
|
+
].filter(Boolean);
|
|
1412
|
+
fastify.route({
|
|
1413
|
+
method: "GET",
|
|
1414
|
+
url: "/",
|
|
1415
|
+
schema: buildSchema({
|
|
1416
|
+
tags: [tag],
|
|
1417
|
+
summary: `List ${tag}`
|
|
1418
|
+
}, defaultSchemas.list, schemas.list),
|
|
1419
|
+
preHandler: listPreHandler.length > 0 ? listPreHandler : void 0,
|
|
1420
|
+
handler: handlers.list,
|
|
1421
|
+
...rateLimitConfig ? { config: rateLimitConfig } : {}
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
if (!disabledRoutes.includes("get")) {
|
|
1425
|
+
const getPreHandler = [
|
|
1426
|
+
arcDecorator,
|
|
1427
|
+
buildAuthMiddleware(fastify, permissions.get),
|
|
1428
|
+
buildPermissionMiddleware(permissions.get, resourceName, "get"),
|
|
1429
|
+
cacheMw,
|
|
1430
|
+
...mw.get
|
|
1431
|
+
].filter(Boolean);
|
|
1432
|
+
fastify.route({
|
|
1433
|
+
method: "GET",
|
|
1434
|
+
url: "/:id",
|
|
1435
|
+
schema: buildSchema({
|
|
1436
|
+
tags: [tag],
|
|
1437
|
+
summary: `Get ${tag} by ID`,
|
|
1438
|
+
params: idParamsSchema
|
|
1439
|
+
}, defaultSchemas.get, schemas.get),
|
|
1440
|
+
preHandler: getPreHandler.length > 0 ? getPreHandler : void 0,
|
|
1441
|
+
handler: handlers.get,
|
|
1442
|
+
...rateLimitConfig ? { config: rateLimitConfig } : {}
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
if (!disabledRoutes.includes("create")) {
|
|
1446
|
+
const createPreHandler = [
|
|
1447
|
+
arcDecorator,
|
|
1448
|
+
buildAuthMiddleware(fastify, permissions.create),
|
|
1449
|
+
buildPermissionMiddleware(permissions.create, resourceName, "create"),
|
|
1450
|
+
idempotencyMw,
|
|
1451
|
+
...mw.create
|
|
1452
|
+
].filter(Boolean);
|
|
1453
|
+
fastify.route({
|
|
1454
|
+
method: "POST",
|
|
1455
|
+
url: "/",
|
|
1456
|
+
schema: buildSchema({
|
|
1457
|
+
tags: [tag],
|
|
1458
|
+
summary: `Create ${tag}`
|
|
1459
|
+
}, defaultSchemas.create, schemas.create),
|
|
1460
|
+
preHandler: createPreHandler.length > 0 ? createPreHandler : void 0,
|
|
1461
|
+
handler: handlers.create,
|
|
1462
|
+
...rateLimitConfig ? { config: rateLimitConfig } : {}
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
if (!disabledRoutes.includes("update")) {
|
|
1466
|
+
const updateMethods = updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod];
|
|
1467
|
+
const updatePreHandler = [
|
|
1468
|
+
arcDecorator,
|
|
1469
|
+
buildAuthMiddleware(fastify, permissions.update),
|
|
1470
|
+
buildPermissionMiddleware(permissions.update, resourceName, "update"),
|
|
1471
|
+
idempotencyMw,
|
|
1472
|
+
...mw.update
|
|
1473
|
+
].filter(Boolean);
|
|
1474
|
+
for (const method of updateMethods) fastify.route({
|
|
1475
|
+
method,
|
|
1476
|
+
url: "/:id",
|
|
1477
|
+
schema: buildSchema({
|
|
1478
|
+
tags: [tag],
|
|
1479
|
+
summary: `${method === "PUT" ? "Replace" : "Update"} ${tag}`,
|
|
1480
|
+
params: idParamsSchema
|
|
1481
|
+
}, defaultSchemas.update, schemas.update),
|
|
1482
|
+
preHandler: updatePreHandler.length > 0 ? updatePreHandler : void 0,
|
|
1483
|
+
handler: handlers.update,
|
|
1484
|
+
...rateLimitConfig ? { config: rateLimitConfig } : {}
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
if (!disabledRoutes.includes("delete")) {
|
|
1488
|
+
const deletePreHandler = [
|
|
1489
|
+
arcDecorator,
|
|
1490
|
+
buildAuthMiddleware(fastify, permissions.delete),
|
|
1491
|
+
buildPermissionMiddleware(permissions.delete, resourceName, "delete"),
|
|
1492
|
+
...mw.delete
|
|
1493
|
+
].filter(Boolean);
|
|
1494
|
+
fastify.route({
|
|
1495
|
+
method: "DELETE",
|
|
1496
|
+
url: "/:id",
|
|
1497
|
+
schema: buildSchema({
|
|
1498
|
+
tags: [tag],
|
|
1499
|
+
summary: `Delete ${tag}`,
|
|
1500
|
+
params: idParamsSchema
|
|
1501
|
+
}, defaultSchemas.delete, schemas.delete),
|
|
1502
|
+
preHandler: deletePreHandler.length > 0 ? deletePreHandler : void 0,
|
|
1503
|
+
handler: handlers.delete,
|
|
1504
|
+
...rateLimitConfig ? { config: rateLimitConfig } : {}
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
if (additionalRoutes.length > 0) createAdditionalRoutes(fastify, additionalRoutes, controller, {
|
|
1509
|
+
tag,
|
|
1510
|
+
resourceName,
|
|
1511
|
+
arcDecorator,
|
|
1512
|
+
rateLimitConfig,
|
|
1513
|
+
cacheMw,
|
|
1514
|
+
idempotencyMw,
|
|
1515
|
+
pipeline
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Create permission middleware from PermissionCheck
|
|
1520
|
+
* Useful for custom route registration
|
|
1521
|
+
*/
|
|
1522
|
+
function createPermissionMiddleware(permission, resourceName, action) {
|
|
1523
|
+
return buildPermissionMiddleware(permission, resourceName, action);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
//#endregion
|
|
1527
|
+
//#region src/core/createActionRouter.ts
|
|
1528
|
+
/**
|
|
1529
|
+
* Create action-based state transition endpoint
|
|
1530
|
+
*
|
|
1531
|
+
* Registers: POST /:id/action
|
|
1532
|
+
* Body: { action: string, ...actionData }
|
|
1533
|
+
*
|
|
1534
|
+
* @param fastify - Fastify instance
|
|
1535
|
+
* @param config - Action router configuration
|
|
1536
|
+
*/
|
|
1537
|
+
function createActionRouter(fastify, config) {
|
|
1538
|
+
const { tag, actions, actionPermissions = {}, actionSchemas = {}, globalAuth, idempotencyService, onError } = config;
|
|
1539
|
+
const actionEnum = Object.keys(actions);
|
|
1540
|
+
if (actionEnum.length === 0) {
|
|
1541
|
+
fastify.log.warn("[createActionRouter] No actions defined, skipping route creation");
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
const bodyProperties = { action: {
|
|
1545
|
+
type: "string",
|
|
1546
|
+
enum: actionEnum,
|
|
1547
|
+
description: `Action to perform: ${actionEnum.join(" | ")}`
|
|
1548
|
+
} };
|
|
1549
|
+
Object.entries(actionSchemas).forEach(([actionName, schema]) => {
|
|
1550
|
+
if (schema && typeof schema === "object") Object.entries(schema).forEach(([propName, propSchema]) => {
|
|
1551
|
+
bodyProperties[propName] = {
|
|
1552
|
+
...propSchema,
|
|
1553
|
+
description: `${propSchema.description || ""} (for ${actionName} action)`.trim()
|
|
1554
|
+
};
|
|
1555
|
+
});
|
|
1556
|
+
});
|
|
1557
|
+
const routeSchema = {
|
|
1558
|
+
tags: tag ? [tag] : void 0,
|
|
1559
|
+
summary: `Perform action (${actionEnum.join("/")})`,
|
|
1560
|
+
description: buildActionDescription(actions, actionPermissions),
|
|
1561
|
+
params: {
|
|
1562
|
+
type: "object",
|
|
1563
|
+
properties: { id: {
|
|
1564
|
+
type: "string",
|
|
1565
|
+
description: "Resource ID"
|
|
1566
|
+
} },
|
|
1567
|
+
required: ["id"]
|
|
1568
|
+
},
|
|
1569
|
+
body: {
|
|
1570
|
+
type: "object",
|
|
1571
|
+
properties: bodyProperties,
|
|
1572
|
+
required: ["action"]
|
|
1573
|
+
},
|
|
1574
|
+
response: {
|
|
1575
|
+
200: {
|
|
1576
|
+
type: "object",
|
|
1577
|
+
properties: {
|
|
1578
|
+
success: { type: "boolean" },
|
|
1579
|
+
data: { type: "object" }
|
|
1580
|
+
}
|
|
1581
|
+
},
|
|
1582
|
+
400: {
|
|
1583
|
+
type: "object",
|
|
1584
|
+
properties: {
|
|
1585
|
+
success: { type: "boolean" },
|
|
1586
|
+
error: { type: "string" }
|
|
1587
|
+
}
|
|
1588
|
+
},
|
|
1589
|
+
403: {
|
|
1590
|
+
type: "object",
|
|
1591
|
+
properties: {
|
|
1592
|
+
success: { type: "boolean" },
|
|
1593
|
+
error: { type: "string" }
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
};
|
|
1598
|
+
const preHandler = [];
|
|
1599
|
+
const hasPublicActions = Object.entries(actionPermissions).some(([, p]) => p?._isPublic) || globalAuth && globalAuth?._isPublic;
|
|
1600
|
+
const hasProtectedActions = Object.entries(actionPermissions).some(([, p]) => !p?._isPublic) || globalAuth && !globalAuth?._isPublic;
|
|
1601
|
+
if (hasProtectedActions && !hasPublicActions && fastify.authenticate) preHandler.push(fastify.authenticate);
|
|
1602
|
+
fastify.post("/:id/action", {
|
|
1603
|
+
schema: routeSchema,
|
|
1604
|
+
preHandler: preHandler.length ? preHandler : void 0
|
|
1605
|
+
}, async (req, reply) => {
|
|
1606
|
+
const { action, ...data } = req.body;
|
|
1607
|
+
const { id } = req.params;
|
|
1608
|
+
const rawIdempotencyKey = req.headers["idempotency-key"];
|
|
1609
|
+
const idempotencyKey = Array.isArray(rawIdempotencyKey) ? rawIdempotencyKey[0] : rawIdempotencyKey;
|
|
1610
|
+
const handler = actions[action];
|
|
1611
|
+
if (!handler) return reply.code(400).send({
|
|
1612
|
+
success: false,
|
|
1613
|
+
error: `Invalid action '${action}'. Valid actions: ${actionEnum.join(", ")}`,
|
|
1614
|
+
validActions: actionEnum
|
|
1615
|
+
});
|
|
1616
|
+
const permissionCheck = actionPermissions[action] ?? globalAuth;
|
|
1617
|
+
if (hasPublicActions && hasProtectedActions && permissionCheck) {
|
|
1618
|
+
if (!permissionCheck?._isPublic && fastify.authenticate) {
|
|
1619
|
+
try {
|
|
1620
|
+
await fastify.authenticate(req, reply);
|
|
1621
|
+
} catch {
|
|
1622
|
+
if (!reply.sent) return reply.code(401).send({
|
|
1623
|
+
success: false,
|
|
1624
|
+
error: "Authentication required"
|
|
1625
|
+
});
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
if (reply.sent) return;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
if (permissionCheck) {
|
|
1632
|
+
const context = {
|
|
1633
|
+
user: req.user ?? null,
|
|
1634
|
+
request: req,
|
|
1635
|
+
resource: tag ?? "action",
|
|
1636
|
+
action,
|
|
1637
|
+
resourceId: id,
|
|
1638
|
+
params: req.params,
|
|
1639
|
+
data
|
|
1640
|
+
};
|
|
1641
|
+
let result;
|
|
1642
|
+
try {
|
|
1643
|
+
result = await permissionCheck(context);
|
|
1644
|
+
} catch (err) {
|
|
1645
|
+
req.log?.warn?.({
|
|
1646
|
+
err,
|
|
1647
|
+
resource: tag ?? "action",
|
|
1648
|
+
action
|
|
1649
|
+
}, "Permission check threw");
|
|
1650
|
+
return reply.code(403).send({
|
|
1651
|
+
success: false,
|
|
1652
|
+
error: "Permission denied"
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
if (typeof result === "boolean") {
|
|
1656
|
+
if (!result) return reply.code(context.user ? 403 : 401).send({
|
|
1657
|
+
success: false,
|
|
1658
|
+
error: context.user ? `Permission denied for '${action}'` : "Authentication required"
|
|
1659
|
+
});
|
|
1660
|
+
} else {
|
|
1661
|
+
const permResult = result;
|
|
1662
|
+
if (!permResult.granted) return reply.code(context.user ? 403 : 401).send({
|
|
1663
|
+
success: false,
|
|
1664
|
+
error: permResult.reason ?? (context.user ? `Permission denied for '${action}'` : "Authentication required")
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
try {
|
|
1669
|
+
if (idempotencyKey && idempotencyService) {
|
|
1670
|
+
const user = req.user;
|
|
1671
|
+
const payloadForHash = {
|
|
1672
|
+
action,
|
|
1673
|
+
id,
|
|
1674
|
+
data,
|
|
1675
|
+
userId: (user?._id)?.toString?.() || user?.id || null
|
|
1676
|
+
};
|
|
1677
|
+
const idempotencyResult = await idempotencyService.check(idempotencyKey, payloadForHash);
|
|
1678
|
+
if (!idempotencyResult.isNew && "existingResult" in idempotencyResult) return reply.send({
|
|
1679
|
+
success: true,
|
|
1680
|
+
data: idempotencyResult.existingResult,
|
|
1681
|
+
cached: true
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
const result = await handler(id, data, req);
|
|
1685
|
+
if (idempotencyService) await idempotencyService.complete(idempotencyKey, result);
|
|
1686
|
+
return reply.send({
|
|
1687
|
+
success: true,
|
|
1688
|
+
data: result
|
|
1689
|
+
});
|
|
1690
|
+
} catch (error) {
|
|
1691
|
+
if (idempotencyService) await idempotencyService.fail(idempotencyKey, error);
|
|
1692
|
+
if (onError) {
|
|
1693
|
+
const { statusCode, error: errorMsg, code } = onError(error, action, id);
|
|
1694
|
+
return reply.code(statusCode).send({
|
|
1695
|
+
success: false,
|
|
1696
|
+
error: errorMsg,
|
|
1697
|
+
code
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
const err = error;
|
|
1701
|
+
const statusCode = err.statusCode || err.status || 500;
|
|
1702
|
+
const errorCode = err.code || "ACTION_FAILED";
|
|
1703
|
+
if (statusCode >= 500) req.log.error({
|
|
1704
|
+
err: error,
|
|
1705
|
+
action,
|
|
1706
|
+
id
|
|
1707
|
+
}, "Action handler error");
|
|
1708
|
+
return reply.code(statusCode).send({
|
|
1709
|
+
success: false,
|
|
1710
|
+
error: err.message || `Failed to execute '${action}' action`,
|
|
1711
|
+
code: errorCode
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
});
|
|
1715
|
+
fastify.log.debug({
|
|
1716
|
+
actions: actionEnum,
|
|
1717
|
+
tag
|
|
1718
|
+
}, "[createActionRouter] Registered action endpoint: POST /:id/action");
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Build description with action details
|
|
1722
|
+
* Uses _roles metadata from PermissionCheck functions for OpenAPI docs
|
|
1723
|
+
*/
|
|
1724
|
+
function buildActionDescription(actions, actionPermissions) {
|
|
1725
|
+
const lines = ["Unified action endpoint for state transitions.\n\n**Available actions:**"];
|
|
1726
|
+
Object.keys(actions).forEach((action) => {
|
|
1727
|
+
const roles = actionPermissions[action]?._roles;
|
|
1728
|
+
const roleStr = roles?.length ? ` (requires: ${roles.join(" or ")})` : "";
|
|
1729
|
+
lines.push(`- \`${action}\`${roleStr}`);
|
|
1730
|
+
});
|
|
1731
|
+
return lines.join("\n");
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
//#endregion
|
|
1735
|
+
//#region src/core/validateResourceConfig.ts
|
|
1736
|
+
/**
|
|
1737
|
+
* Validate a resource configuration
|
|
1738
|
+
*/
|
|
1739
|
+
function validateResourceConfig(config, options = {}) {
|
|
1740
|
+
const errors = [];
|
|
1741
|
+
const warnings = [];
|
|
1742
|
+
if (!config.name) errors.push({
|
|
1743
|
+
field: "name",
|
|
1744
|
+
message: "Resource name is required",
|
|
1745
|
+
suggestion: "Add a unique resource name (e.g., \"product\", \"user\")"
|
|
1746
|
+
});
|
|
1747
|
+
else if (!/^[a-z][a-z0-9-]*$/i.test(config.name)) errors.push({
|
|
1748
|
+
field: "name",
|
|
1749
|
+
message: `Invalid resource name "${config.name}"`,
|
|
1750
|
+
suggestion: "Use alphanumeric characters and hyphens, starting with a letter"
|
|
1751
|
+
});
|
|
1752
|
+
const crudRoutes = CRUD_OPERATIONS;
|
|
1753
|
+
const disabledRoutes = new Set(config.disabledRoutes ?? []);
|
|
1754
|
+
const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
|
|
1755
|
+
if (!config.disableDefaultRoutes && enabledCrudRoutes.length > 0) {
|
|
1756
|
+
if (!config.adapter) errors.push({
|
|
1757
|
+
field: "adapter",
|
|
1758
|
+
message: "Data adapter is required when CRUD routes are enabled",
|
|
1759
|
+
suggestion: "Provide an adapter: createMongooseAdapter({ model, repository })"
|
|
1760
|
+
});
|
|
1761
|
+
else if (!config.adapter.repository) errors.push({
|
|
1762
|
+
field: "adapter.repository",
|
|
1763
|
+
message: "Adapter must provide a repository",
|
|
1764
|
+
suggestion: "Ensure your adapter returns a valid CrudRepository"
|
|
1765
|
+
});
|
|
1766
|
+
} else if (!config.adapter && !config.additionalRoutes?.length) warnings.push({
|
|
1767
|
+
field: "config",
|
|
1768
|
+
message: "Resource has no adapter and no additionalRoutes",
|
|
1769
|
+
suggestion: "Provide either adapter for CRUD or additionalRoutes for custom logic"
|
|
1770
|
+
});
|
|
1771
|
+
if (config.controller && !options.skipControllerCheck && !config.disableDefaultRoutes) {
|
|
1772
|
+
const ctrl = config.controller;
|
|
1773
|
+
const requiredMethods = CRUD_OPERATIONS;
|
|
1774
|
+
for (const method of requiredMethods) if (typeof ctrl[method] !== "function") errors.push({
|
|
1775
|
+
field: `controller.${method}`,
|
|
1776
|
+
message: `Missing required CRUD method "${method}"`,
|
|
1777
|
+
suggestion: "Extend BaseController which implements IController interface"
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
if (config.controller && config.additionalRoutes) validateAdditionalRouteHandlers(config.controller, config.additionalRoutes, errors);
|
|
1781
|
+
if (config.permissions) validatePermissionKeys(config, options, errors, warnings);
|
|
1782
|
+
if (config.presets && !options.allowUnknownPresets) validatePresets(config.presets, errors, warnings);
|
|
1783
|
+
if (config.prefix) {
|
|
1784
|
+
if (!config.prefix.startsWith("/")) errors.push({
|
|
1785
|
+
field: "prefix",
|
|
1786
|
+
message: `Prefix must start with "/" (got "${config.prefix}")`,
|
|
1787
|
+
suggestion: `Change to "/${config.prefix}"`
|
|
1788
|
+
});
|
|
1789
|
+
if (config.prefix.endsWith("/") && config.prefix !== "/") warnings.push({
|
|
1790
|
+
field: "prefix",
|
|
1791
|
+
message: `Prefix should not end with "/" (got "${config.prefix}")`,
|
|
1792
|
+
suggestion: `Change to "${config.prefix.slice(0, -1)}"`
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
if (config.additionalRoutes) validateAdditionalRoutes(config.additionalRoutes, errors);
|
|
1796
|
+
return {
|
|
1797
|
+
valid: errors.length === 0,
|
|
1798
|
+
errors,
|
|
1799
|
+
warnings
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
function validateAdditionalRouteHandlers(controller, routes, errors) {
|
|
1803
|
+
const ctrl = controller;
|
|
1804
|
+
for (const route of routes) if (typeof route.handler === "string") {
|
|
1805
|
+
if (typeof ctrl[route.handler] !== "function") errors.push({
|
|
1806
|
+
field: `additionalRoutes[${route.method} ${route.path}]`,
|
|
1807
|
+
message: `Handler "${route.handler}" not found on controller`,
|
|
1808
|
+
suggestion: `Add method "${route.handler}" to controller or use a function handler`
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
function validatePermissionKeys(config, options, errors, warnings) {
|
|
1813
|
+
const validKeys = new Set([...CRUD_OPERATIONS, ...options.additionalPermissionKeys ?? []]);
|
|
1814
|
+
for (const route of config.additionalRoutes ?? []) if (typeof route.handler === "string") validKeys.add(route.handler);
|
|
1815
|
+
for (const preset of config.presets ?? []) {
|
|
1816
|
+
const presetName = typeof preset === "string" ? preset : preset.name;
|
|
1817
|
+
if (presetName === "softDelete") {
|
|
1818
|
+
validKeys.add("deleted");
|
|
1819
|
+
validKeys.add("restore");
|
|
1820
|
+
}
|
|
1821
|
+
if (presetName === "slugLookup") validKeys.add("getBySlug");
|
|
1822
|
+
if (presetName === "tree") {
|
|
1823
|
+
validKeys.add("tree");
|
|
1824
|
+
validKeys.add("children");
|
|
1825
|
+
validKeys.add("getTree");
|
|
1826
|
+
validKeys.add("getChildren");
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
for (const key of Object.keys(config.permissions ?? {})) if (!validKeys.has(key)) warnings.push({
|
|
1830
|
+
field: `permissions.${key}`,
|
|
1831
|
+
message: `Unknown permission key "${key}"`,
|
|
1832
|
+
suggestion: `Valid keys: ${Array.from(validKeys).join(", ")}`
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
function validatePresets(presets, errors, warnings) {
|
|
1836
|
+
const availablePresets = getAvailablePresets();
|
|
1837
|
+
for (const preset of presets) {
|
|
1838
|
+
if (typeof preset === "object" && ("middlewares" in preset || "additionalRoutes" in preset)) continue;
|
|
1839
|
+
const presetName = typeof preset === "string" ? preset : preset.name;
|
|
1840
|
+
if (!availablePresets.includes(presetName)) errors.push({
|
|
1841
|
+
field: "presets",
|
|
1842
|
+
message: `Unknown preset "${presetName}"`,
|
|
1843
|
+
suggestion: `Available presets: ${availablePresets.join(", ")}`
|
|
1844
|
+
});
|
|
1845
|
+
if (typeof preset === "object") validatePresetOptions(preset, warnings);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
function validatePresetOptions(preset, warnings) {
|
|
1849
|
+
const validOptions = {
|
|
1850
|
+
slugLookup: ["slugField"],
|
|
1851
|
+
tree: ["parentField"],
|
|
1852
|
+
softDelete: ["deletedField"],
|
|
1853
|
+
ownedByUser: ["ownerField"],
|
|
1854
|
+
multiTenant: ["tenantField", "allowPublic"]
|
|
1855
|
+
}[preset.name] ?? [];
|
|
1856
|
+
const providedOptions = Object.keys(preset).filter((k) => k !== "name");
|
|
1857
|
+
for (const opt of providedOptions) if (!validOptions.includes(opt)) warnings.push({
|
|
1858
|
+
field: `presets[${preset.name}].${opt}`,
|
|
1859
|
+
message: `Unknown option "${opt}" for preset "${preset.name}"`,
|
|
1860
|
+
suggestion: validOptions.length > 0 ? `Valid options: ${validOptions.join(", ")}` : `Preset "${preset.name}" has no configurable options`
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
function validateAdditionalRoutes(routes, errors) {
|
|
1864
|
+
const validMethods = [
|
|
1865
|
+
"GET",
|
|
1866
|
+
"POST",
|
|
1867
|
+
"PUT",
|
|
1868
|
+
"PATCH",
|
|
1869
|
+
"DELETE",
|
|
1870
|
+
"OPTIONS",
|
|
1871
|
+
"HEAD"
|
|
1872
|
+
];
|
|
1873
|
+
const seenRoutes = /* @__PURE__ */ new Set();
|
|
1874
|
+
for (const [i, route] of routes.entries()) {
|
|
1875
|
+
if (!validMethods.includes(route.method)) errors.push({
|
|
1876
|
+
field: `additionalRoutes[${i}].method`,
|
|
1877
|
+
message: `Invalid HTTP method "${route.method}"`,
|
|
1878
|
+
suggestion: `Valid methods: ${validMethods.join(", ")}`
|
|
1879
|
+
});
|
|
1880
|
+
if (!route.path) errors.push({
|
|
1881
|
+
field: `additionalRoutes[${i}].path`,
|
|
1882
|
+
message: "Route path is required"
|
|
1883
|
+
});
|
|
1884
|
+
else if (!route.path.startsWith("/")) errors.push({
|
|
1885
|
+
field: `additionalRoutes[${i}].path`,
|
|
1886
|
+
message: `Route path must start with "/" (got "${route.path}")`,
|
|
1887
|
+
suggestion: `Change to "/${route.path}"`
|
|
1888
|
+
});
|
|
1889
|
+
if (!route.handler) errors.push({
|
|
1890
|
+
field: `additionalRoutes[${i}].handler`,
|
|
1891
|
+
message: "Route handler is required"
|
|
1892
|
+
});
|
|
1893
|
+
const routeKey = `${route.method} ${route.path}`;
|
|
1894
|
+
if (seenRoutes.has(routeKey)) errors.push({
|
|
1895
|
+
field: `additionalRoutes[${i}]`,
|
|
1896
|
+
message: `Duplicate route "${routeKey}"`
|
|
1897
|
+
});
|
|
1898
|
+
seenRoutes.add(routeKey);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Format validation errors for display
|
|
1903
|
+
*/
|
|
1904
|
+
function formatValidationErrors(resourceName, result) {
|
|
1905
|
+
const lines = [];
|
|
1906
|
+
if (result.errors.length > 0) {
|
|
1907
|
+
lines.push(`Resource "${resourceName}" validation failed:`);
|
|
1908
|
+
lines.push("");
|
|
1909
|
+
lines.push("ERRORS:");
|
|
1910
|
+
for (const err of result.errors) {
|
|
1911
|
+
lines.push(` ✗ ${err.field}: ${err.message}`);
|
|
1912
|
+
if (err.suggestion) lines.push(` → ${err.suggestion}`);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
if (result.warnings.length > 0) {
|
|
1916
|
+
if (lines.length > 0) lines.push("");
|
|
1917
|
+
lines.push("WARNINGS:");
|
|
1918
|
+
for (const warn of result.warnings) {
|
|
1919
|
+
lines.push(` ⚠ ${warn.field}: ${warn.message}`);
|
|
1920
|
+
if (warn.suggestion) lines.push(` → ${warn.suggestion}`);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
return lines.join("\n");
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* Validate and throw if invalid
|
|
1927
|
+
*/
|
|
1928
|
+
function assertValidConfig(config, options) {
|
|
1929
|
+
const result = validateResourceConfig(config, options);
|
|
1930
|
+
if (!result.valid) {
|
|
1931
|
+
const errorMsg = formatValidationErrors(config.name ?? "unknown", result);
|
|
1932
|
+
throw new Error(errorMsg);
|
|
1933
|
+
}
|
|
1934
|
+
if (result.warnings.length > 0 && process.env.NODE_ENV !== "production") console.warn(formatValidationErrors(config.name ?? "unknown", {
|
|
1935
|
+
valid: true,
|
|
1936
|
+
errors: [],
|
|
1937
|
+
warnings: result.warnings
|
|
1938
|
+
}));
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
//#endregion
|
|
1942
|
+
//#region src/core/defineResource.ts
|
|
1943
|
+
/**
|
|
1944
|
+
* Define a resource with database adapter
|
|
1945
|
+
*
|
|
1946
|
+
* This is the MAIN entry point for creating Arc resources.
|
|
1947
|
+
* The adapter provides both repository and schema metadata.
|
|
1948
|
+
*/
|
|
1949
|
+
function defineResource(config) {
|
|
1950
|
+
if (!config.skipValidation) {
|
|
1951
|
+
assertValidConfig(config, { skipControllerCheck: true });
|
|
1952
|
+
if (config.permissions) {
|
|
1953
|
+
for (const [key, value] of Object.entries(config.permissions)) if (value !== void 0 && typeof value !== "function") throw new Error(`[Arc] Resource '${config.name}': permissions.${key} must be a PermissionCheck function.\nUse allowPublic(), requireAuth(), or requireRoles(['role']) from @classytic/arc/permissions.`);
|
|
1954
|
+
}
|
|
1955
|
+
for (const route of config.additionalRoutes ?? []) {
|
|
1956
|
+
if (typeof route.permissions !== "function") throw new Error(`[Arc] Resource '${config.name}' route ${route.method} ${route.path}: permissions is required and must be a PermissionCheck function.\nUse allowPublic() or requireAuth() from @classytic/arc/permissions.`);
|
|
1957
|
+
if (typeof route.wrapHandler !== "boolean") throw new Error(`[Arc] Resource '${config.name}' route ${route.method} ${route.path}: wrapHandler is required.\nSet true for ControllerHandler (context object) or false for FastifyHandler (req, reply).`);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
const repository = config.adapter?.repository;
|
|
1961
|
+
const crudRoutes = CRUD_OPERATIONS;
|
|
1962
|
+
const disabledRoutes = new Set(config.disabledRoutes ?? []);
|
|
1963
|
+
const hasCrudRoutes = !config.disableDefaultRoutes && crudRoutes.some((route) => !disabledRoutes.has(route));
|
|
1964
|
+
const originalPresets = (config.presets ?? []).map((p) => typeof p === "string" ? p : p.name);
|
|
1965
|
+
const resolvedConfig = config.presets?.length ? applyPresets(config, config.presets) : config;
|
|
1966
|
+
resolvedConfig._appliedPresets = originalPresets;
|
|
1967
|
+
let controller = resolvedConfig.controller;
|
|
1968
|
+
if (!controller && hasCrudRoutes && repository) controller = new BaseController(repository, {
|
|
1969
|
+
resourceName: resolvedConfig.name,
|
|
1970
|
+
schemaOptions: resolvedConfig.schemaOptions,
|
|
1971
|
+
queryParser: resolvedConfig.queryParser,
|
|
1972
|
+
tenantField: resolvedConfig.tenantField,
|
|
1973
|
+
idField: resolvedConfig.idField,
|
|
1974
|
+
matchesFilter: config.adapter?.matchesFilter,
|
|
1975
|
+
cache: resolvedConfig.cache,
|
|
1976
|
+
presetFields: resolvedConfig._controllerOptions ? {
|
|
1977
|
+
slugField: resolvedConfig._controllerOptions.slugField,
|
|
1978
|
+
parentField: resolvedConfig._controllerOptions.parentField
|
|
1979
|
+
} : void 0
|
|
1980
|
+
});
|
|
1981
|
+
const resource = new ResourceDefinition({
|
|
1982
|
+
...resolvedConfig,
|
|
1983
|
+
adapter: config.adapter,
|
|
1984
|
+
controller
|
|
1985
|
+
});
|
|
1986
|
+
if (!config.skipValidation && controller) resource._validateControllerMethods();
|
|
1987
|
+
if (resolvedConfig._hooks?.length) resource._pendingHooks.push(...resolvedConfig._hooks.map((hook) => ({
|
|
1988
|
+
operation: hook.operation,
|
|
1989
|
+
phase: hook.phase,
|
|
1990
|
+
handler: hook.handler,
|
|
1991
|
+
priority: hook.priority ?? 10
|
|
1992
|
+
})));
|
|
1993
|
+
if (!config.skipRegistry) try {
|
|
1994
|
+
let openApiSchemas = config.openApiSchemas;
|
|
1995
|
+
if (!openApiSchemas && config.adapter?.generateSchemas) {
|
|
1996
|
+
const generated = config.adapter.generateSchemas(config.schemaOptions);
|
|
1997
|
+
if (generated) openApiSchemas = generated;
|
|
1998
|
+
}
|
|
1999
|
+
const queryParser = config.queryParser;
|
|
2000
|
+
if (!openApiSchemas?.listQuery && queryParser?.getQuerySchema) {
|
|
2001
|
+
const querySchema = queryParser.getQuerySchema();
|
|
2002
|
+
if (querySchema) openApiSchemas = {
|
|
2003
|
+
...openApiSchemas,
|
|
2004
|
+
listQuery: querySchema
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
if (openApiSchemas) openApiSchemas = convertOpenApiSchemas(openApiSchemas);
|
|
2008
|
+
resource._registryMeta = {
|
|
2009
|
+
module: config.module,
|
|
2010
|
+
openApiSchemas
|
|
2011
|
+
};
|
|
2012
|
+
} catch {}
|
|
2013
|
+
return resource;
|
|
2014
|
+
}
|
|
2015
|
+
var ResourceDefinition = class {
|
|
2016
|
+
name;
|
|
2017
|
+
displayName;
|
|
2018
|
+
tag;
|
|
2019
|
+
prefix;
|
|
2020
|
+
adapter;
|
|
2021
|
+
controller;
|
|
2022
|
+
schemaOptions;
|
|
2023
|
+
customSchemas;
|
|
2024
|
+
permissions;
|
|
2025
|
+
additionalRoutes;
|
|
2026
|
+
middlewares;
|
|
2027
|
+
disableDefaultRoutes;
|
|
2028
|
+
disabledRoutes;
|
|
2029
|
+
events;
|
|
2030
|
+
rateLimit;
|
|
2031
|
+
updateMethod;
|
|
2032
|
+
pipe;
|
|
2033
|
+
fields;
|
|
2034
|
+
cache;
|
|
2035
|
+
_appliedPresets;
|
|
2036
|
+
_pendingHooks;
|
|
2037
|
+
_registryMeta;
|
|
2038
|
+
constructor(config) {
|
|
2039
|
+
this.name = config.name;
|
|
2040
|
+
this.displayName = config.displayName ?? capitalize(config.name) + "s";
|
|
2041
|
+
this.tag = config.tag ?? this.displayName;
|
|
2042
|
+
this.prefix = config.prefix ?? `/${config.name}s`;
|
|
2043
|
+
this.adapter = config.adapter;
|
|
2044
|
+
this.controller = config.controller;
|
|
2045
|
+
this.schemaOptions = config.schemaOptions ?? {};
|
|
2046
|
+
this.customSchemas = config.customSchemas ?? {};
|
|
2047
|
+
this.permissions = config.permissions ?? {};
|
|
2048
|
+
this.additionalRoutes = config.additionalRoutes ?? [];
|
|
2049
|
+
this.middlewares = config.middlewares ?? {};
|
|
2050
|
+
this.disableDefaultRoutes = config.disableDefaultRoutes ?? false;
|
|
2051
|
+
this.disabledRoutes = config.disabledRoutes ?? [];
|
|
2052
|
+
this.events = config.events ?? {};
|
|
2053
|
+
this.rateLimit = config.rateLimit;
|
|
2054
|
+
this.updateMethod = config.updateMethod;
|
|
2055
|
+
this.pipe = config.pipe;
|
|
2056
|
+
this.fields = config.fields;
|
|
2057
|
+
this.cache = config.cache;
|
|
2058
|
+
this._appliedPresets = config._appliedPresets ?? [];
|
|
2059
|
+
this._pendingHooks = config._pendingHooks ?? [];
|
|
2060
|
+
}
|
|
2061
|
+
/** Get repository from adapter (if available) */
|
|
2062
|
+
get repository() {
|
|
2063
|
+
return this.adapter?.repository;
|
|
2064
|
+
}
|
|
2065
|
+
_validateControllerMethods() {
|
|
2066
|
+
const errors = [];
|
|
2067
|
+
const crudRoutes = CRUD_OPERATIONS;
|
|
2068
|
+
const disabledRoutes = new Set(this.disabledRoutes ?? []);
|
|
2069
|
+
const enabledCrudRoutes = crudRoutes.filter((route) => !disabledRoutes.has(route));
|
|
2070
|
+
if (!this.disableDefaultRoutes && enabledCrudRoutes.length > 0) if (!this.controller) errors.push("Controller is required when CRUD routes are enabled");
|
|
2071
|
+
else {
|
|
2072
|
+
const ctrl = this.controller;
|
|
2073
|
+
for (const method of enabledCrudRoutes) if (typeof ctrl[method] !== "function") errors.push(`CRUD method '${method}' not found on controller`);
|
|
2074
|
+
}
|
|
2075
|
+
for (const route of this.additionalRoutes) if (typeof route.handler === "string") {
|
|
2076
|
+
if (!this.controller) errors.push(`Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller`);
|
|
2077
|
+
else if (typeof this.controller[route.handler] !== "function") errors.push(`Route ${route.method} ${route.path}: handler '${route.handler}' not found`);
|
|
2078
|
+
}
|
|
2079
|
+
if (errors.length > 0) {
|
|
2080
|
+
const errorMsg = [
|
|
2081
|
+
`Resource '${this.name}' validation failed:`,
|
|
2082
|
+
...errors.map((e) => ` - ${e}`),
|
|
2083
|
+
"",
|
|
2084
|
+
"Ensure controller implements IController<TDoc> interface.",
|
|
2085
|
+
"For preset routes (softDelete, tree), add corresponding methods to controller."
|
|
2086
|
+
].join("\n");
|
|
2087
|
+
throw new Error(errorMsg);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
toPlugin() {
|
|
2091
|
+
const self = this;
|
|
2092
|
+
return async function resourcePlugin(fastify, _opts) {
|
|
2093
|
+
const arc = fastify.arc;
|
|
2094
|
+
if (arc?.registry && self._registryMeta) try {
|
|
2095
|
+
arc.registry.register(self, self._registryMeta);
|
|
2096
|
+
} catch (err) {
|
|
2097
|
+
fastify.log?.warn?.(`Failed to register resource '${self.name}' in registry: ${err instanceof Error ? err.message : err}`);
|
|
2098
|
+
}
|
|
2099
|
+
if (self._pendingHooks.length > 0) {
|
|
2100
|
+
const arc = fastify.arc;
|
|
2101
|
+
if (arc?.hooks) for (const hook of self._pendingHooks) arc.hooks.register({
|
|
2102
|
+
resource: self.name,
|
|
2103
|
+
operation: hook.operation,
|
|
2104
|
+
phase: hook.phase,
|
|
2105
|
+
handler: hook.handler,
|
|
2106
|
+
priority: hook.priority
|
|
2107
|
+
});
|
|
2108
|
+
}
|
|
2109
|
+
const registerRule = fastify.registerCacheInvalidationRule;
|
|
2110
|
+
if (self.cache?.invalidateOn && typeof registerRule === "function") for (const [pattern, tags] of Object.entries(self.cache.invalidateOn)) registerRule({
|
|
2111
|
+
pattern,
|
|
2112
|
+
tags
|
|
2113
|
+
});
|
|
2114
|
+
await fastify.register(async (instance) => {
|
|
2115
|
+
const typedInstance = instance;
|
|
2116
|
+
let schemas = null;
|
|
2117
|
+
if (self.customSchemas && Object.keys(self.customSchemas).length > 0) {
|
|
2118
|
+
schemas = schemas ?? {};
|
|
2119
|
+
for (const [op, customSchema] of Object.entries(self.customSchemas)) {
|
|
2120
|
+
const key = op;
|
|
2121
|
+
const converted = convertRouteSchema(customSchema);
|
|
2122
|
+
schemas[key] = schemas[key] ? deepMergeSchemas(schemas[key], converted) : converted;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
const resolvedRoutes = self.additionalRoutes;
|
|
2126
|
+
createCrudRouter(typedInstance, self.controller, {
|
|
2127
|
+
tag: self.tag,
|
|
2128
|
+
schemas: schemas ?? void 0,
|
|
2129
|
+
permissions: self.permissions,
|
|
2130
|
+
middlewares: self.middlewares,
|
|
2131
|
+
additionalRoutes: resolvedRoutes,
|
|
2132
|
+
disableDefaultRoutes: self.disableDefaultRoutes,
|
|
2133
|
+
disabledRoutes: self.disabledRoutes,
|
|
2134
|
+
resourceName: self.name,
|
|
2135
|
+
schemaOptions: self.schemaOptions,
|
|
2136
|
+
rateLimit: self.rateLimit,
|
|
2137
|
+
updateMethod: self.updateMethod,
|
|
2138
|
+
pipe: self.pipe,
|
|
2139
|
+
fields: self.fields
|
|
2140
|
+
});
|
|
2141
|
+
if (self.events && Object.keys(self.events).length > 0) typedInstance.log?.debug?.(`Resource '${self.name}' defined ${Object.keys(self.events).length} events`);
|
|
2142
|
+
}, { prefix: self.prefix });
|
|
2143
|
+
if (hasEvents(fastify)) try {
|
|
2144
|
+
await fastify.events.publish("arc.resource.registered", {
|
|
2145
|
+
resource: self.name,
|
|
2146
|
+
prefix: self.prefix,
|
|
2147
|
+
presets: self._appliedPresets,
|
|
2148
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2149
|
+
});
|
|
2150
|
+
} catch {}
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Get event definitions for registry
|
|
2155
|
+
*/
|
|
2156
|
+
getEvents() {
|
|
2157
|
+
return Object.entries(this.events).map(([action, meta]) => ({
|
|
2158
|
+
name: `${this.name}:${action}`,
|
|
2159
|
+
module: this.name,
|
|
2160
|
+
schema: meta.schema,
|
|
2161
|
+
description: meta.description
|
|
2162
|
+
}));
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* Get resource metadata
|
|
2166
|
+
*/
|
|
2167
|
+
getMetadata() {
|
|
2168
|
+
return {
|
|
2169
|
+
name: this.name,
|
|
2170
|
+
displayName: this.displayName,
|
|
2171
|
+
tag: this.tag,
|
|
2172
|
+
prefix: this.prefix,
|
|
2173
|
+
presets: this._appliedPresets,
|
|
2174
|
+
permissions: this.permissions,
|
|
2175
|
+
additionalRoutes: this.additionalRoutes,
|
|
2176
|
+
routes: [],
|
|
2177
|
+
events: Object.keys(this.events)
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
};
|
|
2181
|
+
function deepMergeSchemas(base, override) {
|
|
2182
|
+
if (!override) return base;
|
|
2183
|
+
if (!base) return override;
|
|
2184
|
+
const result = { ...base };
|
|
2185
|
+
for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [...new Set([...result[key], ...value])];
|
|
2186
|
+
else if (value && typeof value === "object" && !Array.isArray(value)) result[key] = deepMergeSchemas(result[key], value);
|
|
2187
|
+
else result[key] = value;
|
|
2188
|
+
return result;
|
|
2189
|
+
}
|
|
2190
|
+
function capitalize(str) {
|
|
2191
|
+
if (!str) return "";
|
|
2192
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
//#endregion
|
|
2196
|
+
export { QueryResolver as _, validateResourceConfig as a, createPermissionMiddleware as c, createFastifyHandler as d, createRequestContext as f, BaseController as g, sendControllerResponse as h, formatValidationErrors as i, pipe as l, getControllerScope as m, defineResource as n, createActionRouter as o, getControllerContext as p, assertValidConfig as r, createCrudRouter as s, ResourceDefinition as t, createCrudHandlers as u, BodySanitizer as v, AccessControl as y };
|
|
2197
|
+
//# sourceMappingURL=defineResource-k0_BDn8v.mjs.map
|