@classytic/arc 2.2.5 → 2.4.1
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 +187 -18
- package/bin/arc.js +11 -3
- package/dist/BaseController-CkM5dUh_.mjs +1031 -0
- package/dist/{EventTransport-BkUDYZEb.d.mts → EventTransport-wc5hSLik.d.mts} +1 -1
- package/dist/{HookSystem-BsGV-j2l.mjs → HookSystem-COkyWztM.mjs} +2 -3
- package/dist/{ResourceRegistry-7Ic20ZMw.mjs → ResourceRegistry-DeCIFlix.mjs} +8 -5
- package/dist/adapters/index.d.mts +3 -5
- package/dist/adapters/index.mjs +2 -3
- package/dist/{prisma-DJbMt3yf.mjs → adapters-DTC4Ug66.mjs} +45 -12
- package/dist/audit/index.d.mts +4 -7
- package/dist/audit/index.mjs +2 -29
- package/dist/audit/mongodb.d.mts +1 -4
- package/dist/audit/mongodb.mjs +2 -3
- package/dist/auth/index.d.mts +7 -9
- package/dist/auth/index.mjs +65 -63
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/auth/redis-session.mjs +1 -2
- package/dist/{betterAuthOpenApi-DjWDddNc.mjs → betterAuthOpenApi-lz0IRbXJ.mjs} +4 -6
- package/dist/cache/index.d.mts +23 -23
- package/dist/cache/index.mjs +4 -6
- package/dist/{caching-GSDJcA6-.mjs → caching-BSXB-Xr7.mjs} +2 -24
- package/dist/chunk-BpYLSNr0.mjs +14 -0
- package/dist/circuitBreaker-BOBOpN2w.mjs +284 -0
- package/dist/circuitBreaker-JP2GdJ4b.d.mts +206 -0
- package/dist/cli/commands/describe.mjs +24 -7
- package/dist/cli/commands/docs.mjs +6 -7
- package/dist/cli/commands/doctor.d.mts +10 -0
- package/dist/cli/commands/doctor.mjs +156 -0
- package/dist/cli/commands/generate.mjs +66 -17
- package/dist/cli/commands/init.mjs +315 -45
- package/dist/cli/commands/introspect.mjs +2 -4
- package/dist/cli/index.d.mts +1 -10
- package/dist/cli/index.mjs +4 -153
- package/dist/{constants-DdXFXQtN.mjs → constants-Cxde4rpC.mjs} +1 -2
- package/dist/core/index.d.mts +3 -5
- package/dist/core/index.mjs +5 -4
- package/dist/core-C1XCMtqM.mjs +185 -0
- package/dist/{createApp-BKHSl2nT.mjs → createApp-ByWNRsZj.mjs} +65 -36
- package/dist/{defineResource-DO9ONe_D.mjs → defineResource-D9aY5Cy6.mjs} +154 -1165
- package/dist/discovery/index.mjs +37 -5
- package/dist/docs/index.d.mts +6 -9
- package/dist/docs/index.mjs +3 -21
- package/dist/dynamic/index.d.mts +93 -0
- package/dist/dynamic/index.mjs +122 -0
- package/dist/{elevation-DSTbVvYj.mjs → elevation-BEdACOLB.mjs} +5 -36
- package/dist/{elevation-DGo5shaX.d.mts → elevation-Ca_yveIO.d.mts} +41 -7
- package/dist/{errorHandler-C3GY3_ow.mjs → errorHandler--zp54tGc.mjs} +3 -5
- package/dist/errorHandler-Do4vVQ1f.d.mts +139 -0
- package/dist/{errors-DBANPbGr.mjs → errors-rxhfP7Hf.mjs} +1 -2
- package/dist/{eventPlugin-BEOvaDqo.mjs → eventPlugin-Ba00swHF.mjs} +25 -27
- package/dist/{eventPlugin-H6wDDjGO.d.mts → eventPlugin-iGrSEmwJ.d.mts} +105 -5
- package/dist/events/index.d.mts +72 -7
- package/dist/events/index.mjs +216 -4
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +19 -7
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +3 -4
- package/dist/factory/index.d.mts +23 -9
- package/dist/factory/index.mjs +48 -3
- package/dist/{fields-Bi_AVKSo.d.mts → fields-DFwdaWCq.d.mts} +1 -1
- package/dist/{fields-CTd_CrKr.mjs → fields-ipsbIRPK.mjs} +1 -2
- package/dist/hooks/index.d.mts +1 -3
- package/dist/hooks/index.mjs +2 -3
- package/dist/idempotency/index.d.mts +5 -5
- package/dist/idempotency/index.mjs +3 -7
- package/dist/idempotency/mongodb.d.mts +1 -1
- package/dist/idempotency/mongodb.mjs +4 -5
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +2 -5
- package/dist/{fastifyAdapter-CyAA2zlB.d.mts → index-BL8CaQih.d.mts} +56 -57
- package/dist/index-Diqcm14c.d.mts +369 -0
- package/dist/{prisma-xjhMEq_S.d.mts → index-yhxyjqNb.d.mts} +4 -5
- package/dist/index.d.mts +100 -105
- package/dist/index.mjs +85 -58
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +8 -4
- package/dist/integrations/index.d.mts +4 -2
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs.d.mts +2 -2
- package/dist/integrations/jobs.mjs +63 -14
- package/dist/integrations/mcp/index.d.mts +219 -0
- package/dist/integrations/mcp/index.mjs +572 -0
- package/dist/integrations/mcp/testing.d.mts +53 -0
- package/dist/integrations/mcp/testing.mjs +104 -0
- package/dist/integrations/streamline.mjs +39 -19
- package/dist/integrations/webhooks.d.mts +56 -0
- package/dist/integrations/webhooks.mjs +139 -0
- package/dist/integrations/websocket-redis.d.mts +46 -0
- package/dist/integrations/websocket-redis.mjs +50 -0
- package/dist/integrations/websocket.d.mts +68 -2
- package/dist/integrations/websocket.mjs +96 -13
- package/dist/{interface-CSNjltAc.d.mts → interface-B4awm1RJ.d.mts} +2 -2
- package/dist/interface-DGmPxakH.d.mts +2213 -0
- package/dist/{keys-DhqDRxv3.mjs → keys-qcD-TVJl.mjs} +3 -4
- package/dist/{logger-ByrvQWZO.mjs → logger-Dz3j1ItV.mjs} +2 -4
- package/dist/{memory-B2v7KrCB.mjs → memory-Cb_7iy9e.mjs} +2 -4
- package/dist/metrics-Csh4nsvv.mjs +224 -0
- package/dist/migrations/index.mjs +3 -7
- package/dist/{mongodb-DNKEExbf.mjs → mongodb-BuQ7fNTg.mjs} +1 -4
- package/dist/{mongodb-ClykrfGo.d.mts → mongodb-CUpYfxfD.d.mts} +2 -3
- package/dist/{mongodb-Dg8O_gvd.d.mts → mongodb-bga9AbkD.d.mts} +2 -2
- package/dist/{openapi-9nB_kiuR.mjs → openapi-CBmZ6EQN.mjs} +4 -21
- package/dist/org/index.d.mts +12 -14
- package/dist/org/index.mjs +92 -119
- package/dist/org/types.d.mts +2 -2
- package/dist/org/types.mjs +1 -1
- package/dist/permissions/index.d.mts +4 -278
- package/dist/permissions/index.mjs +4 -579
- package/dist/permissions-CA5zg0yK.mjs +751 -0
- package/dist/plugins/index.d.mts +104 -107
- package/dist/plugins/index.mjs +203 -313
- package/dist/plugins/response-cache.mjs +4 -69
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +24 -11
- package/dist/{pluralize-CM-jZg7p.mjs → pluralize-CcT6qF0a.mjs} +12 -13
- package/dist/policies/index.d.mts +2 -2
- package/dist/policies/index.mjs +80 -83
- package/dist/presets/index.d.mts +26 -19
- package/dist/presets/index.mjs +2 -142
- package/dist/presets/multiTenant.d.mts +1 -4
- package/dist/presets/multiTenant.mjs +4 -6
- package/dist/presets-C9QXJV1u.mjs +422 -0
- package/dist/{queryCachePlugin-B6R0d4av.mjs → queryCachePlugin-ClosZdNS.mjs} +6 -27
- package/dist/{queryCachePlugin-Q6SYuHZ6.d.mts → queryCachePlugin-DcmETvcB.d.mts} +3 -3
- package/dist/queryParser-CgCtsjti.mjs +352 -0
- package/dist/{redis-UwjEp8Ea.d.mts → redis-CQ5YxMC5.d.mts} +2 -2
- package/dist/{redis-stream-CBg0upHI.d.mts → redis-stream-BW9UKLZM.d.mts} +9 -2
- package/dist/registry/index.d.mts +1 -4
- package/dist/registry/index.mjs +3 -4
- package/dist/{introspectionPlugin-B3JkrjwU.mjs → registry-I-ogLgL9.mjs} +1 -8
- package/dist/{requestContext-xi6OKBL-.mjs → requestContext-DYtmNpm5.mjs} +1 -3
- package/dist/resourceToTools-B6ZN9Ing.mjs +489 -0
- package/dist/rpc/index.d.mts +90 -0
- package/dist/rpc/index.mjs +248 -0
- package/dist/{schemaConverter-Dtg0Kt9T.mjs → schemaConverter-DjzHpFam.mjs} +1 -2
- package/dist/schemas/index.d.mts +30 -30
- package/dist/schemas/index.mjs +4 -6
- package/dist/scope/index.d.mts +13 -2
- package/dist/scope/index.mjs +18 -5
- package/dist/{sessionManager-D_iEHjQl.d.mts → sessionManager-wbkYj2HL.d.mts} +2 -2
- package/dist/{sse-DkqQ1uxb.mjs → sse-BkViJPlT.mjs} +4 -25
- package/dist/testing/index.d.mts +551 -567
- package/dist/testing/index.mjs +1744 -1799
- package/dist/{tracing-8CEbhF0w.d.mts → tracing-bz_U4EM1.d.mts} +6 -1
- package/dist/{typeGuards-DwxA1t_L.mjs → typeGuards-Cj5Rgvlg.mjs} +1 -2
- package/dist/types/index.d.mts +4 -946
- package/dist/types/index.mjs +2 -4
- package/dist/types-BJmgxNbF.d.mts +275 -0
- package/dist/{types-RLkFVgaw.d.mts → types-BNUccdcf.d.mts} +2 -2
- package/dist/{types-Beqn1Un7.mjs → types-C6TQjtdi.mjs} +30 -2
- package/dist/{types-DMSBMkaZ.d.mts → types-Dt0-AI6E.d.mts} +85 -27
- package/dist/{types-DelU6kln.mjs → types-ZUu_h0jp.mjs} +1 -2
- package/dist/utils/index.d.mts +255 -352
- package/dist/utils/index.mjs +7 -6
- package/dist/utils-Dc0WhlIl.mjs +594 -0
- package/dist/versioning-BzfeHmhj.mjs +37 -0
- package/package.json +46 -12
- package/skills/arc/SKILL.md +506 -0
- package/skills/arc/references/auth.md +250 -0
- package/skills/arc/references/events.md +272 -0
- package/skills/arc/references/integrations.md +385 -0
- package/skills/arc/references/mcp.md +386 -0
- package/skills/arc/references/production.md +610 -0
- package/skills/arc/references/testing.md +183 -0
- package/dist/audited-CGdLiSlE.mjs +0 -140
- package/dist/chunk-C7Uep-_p.mjs +0 -20
- package/dist/circuitBreaker-DYhWBW_D.mjs +0 -1096
- package/dist/errorHandler-CW3OOeYq.d.mts +0 -72
- package/dist/interface-DZYNK9bb.d.mts +0 -1112
- package/dist/presets-BTeYbw7h.d.mts +0 -57
- package/dist/presets-CeFtfDR8.mjs +0 -119
- /package/dist/{errors-DAWRdiYP.d.mts → errors-CPpvPHT0.d.mts} +0 -0
- /package/dist/{externalPaths-SyPF2tgK.d.mts → externalPaths-DpO-s7r8.d.mts} +0 -0
- /package/dist/{interface-DTbsvIWe.d.mts → interface-D_BWALyZ.d.mts} +0 -0
|
@@ -1,913 +1,73 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { i as resolveEffectiveRoles,
|
|
5
|
-
import { t as getUserRoles } from "./types-
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { r as ForbiddenError } from "./errors-
|
|
9
|
-
import { t as
|
|
10
|
-
import {
|
|
11
|
-
import { t as
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"prototype"
|
|
27
|
-
];
|
|
28
|
-
constructor(config) {
|
|
29
|
-
this.tenantField = config.tenantField;
|
|
30
|
-
this.idField = config.idField;
|
|
31
|
-
this._adapterMatchesFilter = config.matchesFilter;
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Build filter for single-item operations (get/update/delete)
|
|
35
|
-
* Combines ID filter with policy/org filters for proper security enforcement
|
|
36
|
-
*/
|
|
37
|
-
buildIdFilter(id, req) {
|
|
38
|
-
const filter = { [this.idField]: id };
|
|
39
|
-
const arcContext = this._meta(req);
|
|
40
|
-
const policyFilters = arcContext?._policyFilters;
|
|
41
|
-
if (policyFilters) Object.assign(filter, policyFilters);
|
|
42
|
-
const scope = arcContext?._scope;
|
|
43
|
-
const orgId = scope ? getOrgId(scope) : void 0;
|
|
44
|
-
if (this.tenantField && orgId && !policyFilters?.[this.tenantField]) filter[this.tenantField] = orgId;
|
|
45
|
-
return filter;
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Check if item matches policy filters (for get/update/delete operations)
|
|
49
|
-
* Validates that fetched item satisfies all policy constraints
|
|
50
|
-
*
|
|
51
|
-
* Delegates to adapter-provided matchesFilter if available (for SQL, etc.),
|
|
52
|
-
* otherwise falls back to built-in MongoDB-style matching.
|
|
53
|
-
*/
|
|
54
|
-
checkPolicyFilters(item, req) {
|
|
55
|
-
const policyFilters = this._meta(req)?._policyFilters;
|
|
56
|
-
if (!policyFilters) return true;
|
|
57
|
-
if (this._adapterMatchesFilter) return this._adapterMatchesFilter(item, policyFilters);
|
|
58
|
-
return this.defaultMatchesPolicyFilters(item, policyFilters);
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Check org/tenant scope for a document — uses configurable tenantField.
|
|
62
|
-
*
|
|
63
|
-
* SECURITY: When org scope is active (orgId present), documents that are
|
|
64
|
-
* missing the tenant field are DENIED by default. This prevents legacy or
|
|
65
|
-
* unscoped records from leaking across tenants.
|
|
66
|
-
*/
|
|
67
|
-
checkOrgScope(item, arcContext) {
|
|
68
|
-
if (!this.tenantField) return true;
|
|
69
|
-
const scope = arcContext?._scope;
|
|
70
|
-
const orgId = scope ? getOrgId(scope) : void 0;
|
|
71
|
-
if (!item || !orgId) return true;
|
|
72
|
-
if (scope && isElevated(scope) && !orgId) return true;
|
|
73
|
-
const itemOrgId = item[this.tenantField];
|
|
74
|
-
if (!itemOrgId) return false;
|
|
75
|
-
return String(itemOrgId) === String(orgId);
|
|
76
|
-
}
|
|
77
|
-
/** Check ownership for update/delete (ownedByUser preset) */
|
|
78
|
-
checkOwnership(item, req) {
|
|
79
|
-
const ownershipCheck = this._meta(req)?._ownershipCheck;
|
|
80
|
-
if (!item || !ownershipCheck) return true;
|
|
81
|
-
const { field, userId } = ownershipCheck;
|
|
82
|
-
const itemOwnerId = item[field];
|
|
83
|
-
if (!itemOwnerId) return true;
|
|
84
|
-
return String(itemOwnerId) === String(userId);
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Fetch a single document with full access control enforcement.
|
|
88
|
-
* Combines compound DB filter (ID + org + policy) with post-hoc fallback.
|
|
89
|
-
*
|
|
90
|
-
* Takes repository as a parameter to avoid coupling.
|
|
91
|
-
*
|
|
92
|
-
* Replaces the duplicated pattern in get/update/delete:
|
|
93
|
-
* buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
|
|
94
|
-
*/
|
|
95
|
-
async fetchWithAccessControl(id, req, repository, queryOptions) {
|
|
96
|
-
const compoundFilter = this.buildIdFilter(id, req);
|
|
97
|
-
const hasCompoundFilters = Object.keys(compoundFilter).length > 1;
|
|
98
|
-
try {
|
|
99
|
-
if (hasCompoundFilters && typeof repository.getOne === "function") return await repository.getOne(compoundFilter, queryOptions);
|
|
100
|
-
const item = await repository.getById(id, queryOptions);
|
|
101
|
-
if (!item) return null;
|
|
102
|
-
const arcContext = this._meta(req);
|
|
103
|
-
if (!this.checkOrgScope(item, arcContext) || !this.checkPolicyFilters(item, req)) return null;
|
|
104
|
-
return item;
|
|
105
|
-
} catch (error) {
|
|
106
|
-
if (error instanceof Error && error.message?.includes("not found")) return null;
|
|
107
|
-
throw error;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Post-fetch access control validation for items fetched by non-ID queries
|
|
112
|
-
* (e.g., getBySlug, restore). Applies org scope, policy filters, and
|
|
113
|
-
* ownership checks — the same guarantees as fetchWithAccessControl.
|
|
114
|
-
*/
|
|
115
|
-
validateItemAccess(item, req) {
|
|
116
|
-
if (!item) return false;
|
|
117
|
-
const arcContext = this._meta(req);
|
|
118
|
-
if (!this.checkOrgScope(item, arcContext)) return false;
|
|
119
|
-
if (!this.checkPolicyFilters(item, req)) return false;
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
/** Extract typed Arc internal metadata from request */
|
|
123
|
-
_meta(req) {
|
|
124
|
-
return req.metadata;
|
|
125
|
-
}
|
|
126
|
-
/**
|
|
127
|
-
* Check if a value matches a MongoDB query operator
|
|
128
|
-
*/
|
|
129
|
-
matchesOperator(itemValue, operator, filterValue) {
|
|
130
|
-
const equalsByValue = (a, b) => String(a) === String(b);
|
|
131
|
-
switch (operator) {
|
|
132
|
-
case "$eq": return equalsByValue(itemValue, filterValue);
|
|
133
|
-
case "$ne": return !equalsByValue(itemValue, filterValue);
|
|
134
|
-
case "$gt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue > filterValue;
|
|
135
|
-
case "$gte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue >= filterValue;
|
|
136
|
-
case "$lt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue < filterValue;
|
|
137
|
-
case "$lte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue <= filterValue;
|
|
138
|
-
case "$in":
|
|
139
|
-
if (!Array.isArray(filterValue)) return false;
|
|
140
|
-
if (Array.isArray(itemValue)) return itemValue.some((v) => filterValue.some((fv) => equalsByValue(v, fv)));
|
|
141
|
-
return filterValue.some((fv) => equalsByValue(itemValue, fv));
|
|
142
|
-
case "$nin":
|
|
143
|
-
if (!Array.isArray(filterValue)) return false;
|
|
144
|
-
if (Array.isArray(itemValue)) return itemValue.every((v) => filterValue.every((fv) => !equalsByValue(v, fv)));
|
|
145
|
-
return filterValue.every((fv) => !equalsByValue(itemValue, fv));
|
|
146
|
-
case "$exists": return filterValue ? itemValue !== void 0 : itemValue === void 0;
|
|
147
|
-
case "$regex":
|
|
148
|
-
if (typeof itemValue === "string" && (typeof filterValue === "string" || filterValue instanceof RegExp)) {
|
|
149
|
-
const regex = typeof filterValue === "string" ? AccessControl.safeRegex(filterValue) : filterValue;
|
|
150
|
-
return regex !== null && regex.test(itemValue);
|
|
151
|
-
}
|
|
152
|
-
return false;
|
|
153
|
-
default: return false;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* Check if item matches a single filter condition
|
|
158
|
-
* Supports nested paths (e.g., "owner.id", "metadata.status")
|
|
159
|
-
*/
|
|
160
|
-
matchesFilter(item, key, filterValue) {
|
|
161
|
-
const itemValue = key.includes(".") ? this.getNestedValue(item, key) : item[key];
|
|
162
|
-
if (filterValue && typeof filterValue === "object" && !Array.isArray(filterValue)) {
|
|
163
|
-
if (Object.keys(filterValue).some((op) => op.startsWith("$"))) {
|
|
164
|
-
for (const [operator, opValue] of Object.entries(filterValue)) if (!this.matchesOperator(itemValue, operator, opValue)) return false;
|
|
165
|
-
return true;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
if (Array.isArray(itemValue)) return itemValue.some((v) => String(v) === String(filterValue));
|
|
169
|
-
return String(itemValue) === String(filterValue);
|
|
170
|
-
}
|
|
171
|
-
/**
|
|
172
|
-
* Built-in MongoDB-style policy filter matching.
|
|
173
|
-
* Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
|
|
174
|
-
*/
|
|
175
|
-
defaultMatchesPolicyFilters(item, policyFilters) {
|
|
176
|
-
if (policyFilters.$and && Array.isArray(policyFilters.$and)) {
|
|
177
|
-
if (!policyFilters.$and.every((condition) => {
|
|
178
|
-
return Object.entries(condition).every(([key, value]) => {
|
|
179
|
-
return this.matchesFilter(item, key, value);
|
|
180
|
-
});
|
|
181
|
-
})) return false;
|
|
182
|
-
}
|
|
183
|
-
if (policyFilters.$or && Array.isArray(policyFilters.$or)) {
|
|
184
|
-
if (!policyFilters.$or.some((condition) => {
|
|
185
|
-
return Object.entries(condition).every(([key, value]) => {
|
|
186
|
-
return this.matchesFilter(item, key, value);
|
|
187
|
-
});
|
|
188
|
-
})) return false;
|
|
189
|
-
}
|
|
190
|
-
for (const [key, value] of Object.entries(policyFilters)) {
|
|
191
|
-
if (key.startsWith("$")) continue;
|
|
192
|
-
if (!this.matchesFilter(item, key, value)) return false;
|
|
193
|
-
}
|
|
194
|
-
return true;
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Get nested value from object using dot notation (e.g., "owner.id")
|
|
198
|
-
* Security: Validates path against forbidden patterns to prevent prototype pollution
|
|
199
|
-
*/
|
|
200
|
-
getNestedValue(obj, path) {
|
|
201
|
-
if (AccessControl.FORBIDDEN_PATHS.some((p) => path.toLowerCase().includes(p))) return;
|
|
202
|
-
const keys = path.split(".");
|
|
203
|
-
let value = obj;
|
|
204
|
-
for (const key of keys) {
|
|
205
|
-
if (value == null) return void 0;
|
|
206
|
-
if (AccessControl.FORBIDDEN_PATHS.includes(key.toLowerCase())) return;
|
|
207
|
-
value = value[key];
|
|
208
|
-
}
|
|
209
|
-
return value;
|
|
210
|
-
}
|
|
211
|
-
/**
|
|
212
|
-
* Create a safe RegExp from a string, guarding against ReDoS.
|
|
213
|
-
* Returns null if the pattern is invalid or dangerous.
|
|
214
|
-
*/
|
|
215
|
-
static safeRegex(pattern) {
|
|
216
|
-
if (pattern.length > MAX_REGEX_LENGTH) return null;
|
|
217
|
-
if (AccessControl.DANGEROUS_REGEX.test(pattern)) return null;
|
|
218
|
-
try {
|
|
219
|
-
return new RegExp(pattern);
|
|
220
|
-
} catch {
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
//#endregion
|
|
227
|
-
//#region src/core/BodySanitizer.ts
|
|
228
|
-
var BodySanitizer = class {
|
|
229
|
-
schemaOptions;
|
|
230
|
-
constructor(config) {
|
|
231
|
-
this.schemaOptions = config.schemaOptions;
|
|
232
|
-
}
|
|
233
|
-
/**
|
|
234
|
-
* Strip readonly and system-managed fields from request body.
|
|
235
|
-
* Prevents clients from overwriting _id, timestamps, __v, etc.
|
|
236
|
-
*
|
|
237
|
-
* Also applies field-level write permissions when the request has
|
|
238
|
-
* field permission metadata.
|
|
239
|
-
*/
|
|
240
|
-
sanitize(body, _operation, req, meta) {
|
|
241
|
-
let sanitized = { ...body };
|
|
242
|
-
for (const field of SYSTEM_FIELDS) delete sanitized[field];
|
|
243
|
-
const fieldRules = this.schemaOptions.fieldRules ?? {};
|
|
244
|
-
for (const [field, rules] of Object.entries(fieldRules)) if (rules.systemManaged || rules.readonly) delete sanitized[field];
|
|
245
|
-
if (req) {
|
|
246
|
-
const arcContext = meta ?? req.metadata;
|
|
247
|
-
const scope = arcContext?._scope ?? PUBLIC_SCOPE;
|
|
248
|
-
if (!isElevated(scope)) {
|
|
249
|
-
const fieldPerms = arcContext?.arc?.fields;
|
|
250
|
-
if (fieldPerms) {
|
|
251
|
-
const effectiveRoles = resolveEffectiveRoles(getUserRoles(req.user), isMember(scope) ? scope.orgRoles : []);
|
|
252
|
-
sanitized = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
return sanitized;
|
|
257
|
-
}
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
//#endregion
|
|
261
|
-
//#region src/core/QueryResolver.ts
|
|
262
|
-
const defaultParser = new ArcQueryParser();
|
|
263
|
-
function getDefaultQueryParser() {
|
|
264
|
-
return defaultParser;
|
|
1
|
+
import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-Cxde4rpC.mjs";
|
|
2
|
+
import { d as isMember, n as PUBLIC_SCOPE, u as isElevated } from "./types-C6TQjtdi.mjs";
|
|
3
|
+
import { t as BaseController } from "./BaseController-CkM5dUh_.mjs";
|
|
4
|
+
import { i as resolveEffectiveRoles, t as applyFieldReadPermissions } from "./fields-ipsbIRPK.mjs";
|
|
5
|
+
import { t as getUserRoles } from "./types-ZUu_h0jp.mjs";
|
|
6
|
+
import { t as requestContext } from "./requestContext-DYtmNpm5.mjs";
|
|
7
|
+
import { i as getDefaultCrudSchemas } from "./utils-Dc0WhlIl.mjs";
|
|
8
|
+
import { r as ForbiddenError } from "./errors-rxhfP7Hf.mjs";
|
|
9
|
+
import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-DjzHpFam.mjs";
|
|
10
|
+
import { t as hasEvents } from "./typeGuards-Cj5Rgvlg.mjs";
|
|
11
|
+
import { r as getAvailablePresets, t as applyPresets } from "./presets-C9QXJV1u.mjs";
|
|
12
|
+
//#region src/pipeline/pipe.ts
|
|
13
|
+
/**
|
|
14
|
+
* Compose pipeline steps into an ordered array.
|
|
15
|
+
* Accepts guards, transforms, and interceptors in any order.
|
|
16
|
+
*/
|
|
17
|
+
function pipe(...steps) {
|
|
18
|
+
return steps;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Check if a step applies to the given operation.
|
|
22
|
+
*/
|
|
23
|
+
function appliesTo(step, operation) {
|
|
24
|
+
if (!step.operations || step.operations.length === 0) return true;
|
|
25
|
+
return step.operations.includes(operation);
|
|
265
26
|
}
|
|
266
|
-
var QueryResolver = class {
|
|
267
|
-
queryParser;
|
|
268
|
-
maxLimit;
|
|
269
|
-
defaultLimit;
|
|
270
|
-
defaultSort;
|
|
271
|
-
schemaOptions;
|
|
272
|
-
tenantField;
|
|
273
|
-
constructor(config = {}) {
|
|
274
|
-
this.queryParser = config.queryParser ?? getDefaultQueryParser();
|
|
275
|
-
this.maxLimit = config.maxLimit ?? 100;
|
|
276
|
-
this.defaultLimit = config.defaultLimit ?? DEFAULT_LIMIT;
|
|
277
|
-
this.defaultSort = config.defaultSort ?? DEFAULT_SORT;
|
|
278
|
-
this.schemaOptions = config.schemaOptions ?? {};
|
|
279
|
-
this.tenantField = config.tenantField !== void 0 ? config.tenantField : DEFAULT_TENANT_FIELD;
|
|
280
|
-
}
|
|
281
|
-
/**
|
|
282
|
-
* Resolve a request into parsed query options -- ONE parse per request.
|
|
283
|
-
* Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
|
|
284
|
-
*/
|
|
285
|
-
resolve(req, meta) {
|
|
286
|
-
const parsed = this.queryParser.parse(req.query);
|
|
287
|
-
const arcContext = meta ?? req.metadata;
|
|
288
|
-
delete parsed.filters?._policyFilters;
|
|
289
|
-
const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
|
|
290
|
-
const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
|
|
291
|
-
const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.defaultSort;
|
|
292
|
-
const selectString = this.selectToString(parsed.select) ?? req.query?.select;
|
|
293
|
-
const filters = { ...parsed.filters };
|
|
294
|
-
const policyFilters = arcContext?._policyFilters;
|
|
295
|
-
if (policyFilters) Object.assign(filters, policyFilters);
|
|
296
|
-
const scope = arcContext?._scope;
|
|
297
|
-
const orgId = scope ? getOrgId(scope) : void 0;
|
|
298
|
-
if (this.tenantField && orgId && !policyFilters?.[this.tenantField]) filters[this.tenantField] = orgId;
|
|
299
|
-
return {
|
|
300
|
-
page,
|
|
301
|
-
limit,
|
|
302
|
-
sort: sortString,
|
|
303
|
-
select: this.sanitizeSelect(selectString, this.schemaOptions),
|
|
304
|
-
populate: this.sanitizePopulate(parsed.populate, this.schemaOptions),
|
|
305
|
-
populateOptions: parsed.populateOptions,
|
|
306
|
-
filters,
|
|
307
|
-
search: parsed.search,
|
|
308
|
-
after: parsed.after,
|
|
309
|
-
user: req.user,
|
|
310
|
-
context: arcContext
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
/**
|
|
314
|
-
* Convert parsed select object to string format
|
|
315
|
-
* Converts { name: 1, email: 1, password: 0 } -> 'name email -password'
|
|
316
|
-
*/
|
|
317
|
-
selectToString(select) {
|
|
318
|
-
if (!select) return void 0;
|
|
319
|
-
if (typeof select === "string") return select;
|
|
320
|
-
if (Array.isArray(select)) return select.join(" ");
|
|
321
|
-
if (Object.keys(select).length === 0) return void 0;
|
|
322
|
-
return Object.entries(select).map(([field, include]) => include === 0 ? `-${field}` : field).join(" ");
|
|
323
|
-
}
|
|
324
|
-
/** Sanitize select fields */
|
|
325
|
-
sanitizeSelect(select, schemaOptions) {
|
|
326
|
-
if (!select) return void 0;
|
|
327
|
-
const blockedFields = this.getBlockedFields(schemaOptions);
|
|
328
|
-
if (blockedFields.length === 0) return select;
|
|
329
|
-
const sanitized = select.split(/[\s,]+/).filter(Boolean).filter((f) => {
|
|
330
|
-
const fieldName = f.replace(/^-/, "");
|
|
331
|
-
return !blockedFields.includes(fieldName);
|
|
332
|
-
});
|
|
333
|
-
return sanitized.length > 0 ? sanitized.join(" ") : void 0;
|
|
334
|
-
}
|
|
335
|
-
/** Sanitize populate fields */
|
|
336
|
-
sanitizePopulate(populate, schemaOptions) {
|
|
337
|
-
if (!populate) return void 0;
|
|
338
|
-
const allowedPopulate = schemaOptions.query?.allowedPopulate;
|
|
339
|
-
const requested = typeof populate === "string" ? populate.split(",").map((p) => p.trim()) : Array.isArray(populate) ? populate.map(String) : [];
|
|
340
|
-
if (requested.length === 0) return void 0;
|
|
341
|
-
if (!allowedPopulate) return requested;
|
|
342
|
-
const sanitized = requested.filter((p) => allowedPopulate.includes(p));
|
|
343
|
-
return sanitized.length > 0 ? sanitized : void 0;
|
|
344
|
-
}
|
|
345
|
-
/** Get blocked fields from schema options */
|
|
346
|
-
getBlockedFields(schemaOptions) {
|
|
347
|
-
const fieldRules = schemaOptions.fieldRules ?? {};
|
|
348
|
-
return Object.entries(fieldRules).filter(([, rules]) => rules.systemManaged || rules.hidden).map(([field]) => field);
|
|
349
|
-
}
|
|
350
|
-
};
|
|
351
|
-
|
|
352
|
-
//#endregion
|
|
353
|
-
//#region src/core/BaseController.ts
|
|
354
27
|
/**
|
|
355
|
-
*
|
|
28
|
+
* Execute a pipeline against a request context.
|
|
356
29
|
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
* composed classes — no intermediate wrapper methods.
|
|
30
|
+
* This is the core runtime that createCrudRouter uses to execute pipelines.
|
|
31
|
+
* External usage is not needed — this is wired automatically when `pipe` is set.
|
|
360
32
|
*
|
|
361
|
-
* @
|
|
362
|
-
* @
|
|
33
|
+
* @param steps - Pipeline steps to execute
|
|
34
|
+
* @param ctx - The pipeline context (extends IRequestContext)
|
|
35
|
+
* @param handler - The actual controller method to call
|
|
36
|
+
* @param operation - The CRUD operation name
|
|
37
|
+
* @returns The controller response (possibly modified by interceptors)
|
|
363
38
|
*/
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
_matchesFilter;
|
|
381
|
-
_presetFields = {};
|
|
382
|
-
_cacheConfig;
|
|
383
|
-
constructor(repository, options = {}) {
|
|
384
|
-
this.repository = repository;
|
|
385
|
-
this.schemaOptions = options.schemaOptions ?? {};
|
|
386
|
-
this.queryParser = options.queryParser ?? getDefaultQueryParser();
|
|
387
|
-
this.maxLimit = options.maxLimit ?? 100;
|
|
388
|
-
this.defaultLimit = options.defaultLimit ?? DEFAULT_LIMIT;
|
|
389
|
-
this.defaultSort = options.defaultSort ?? DEFAULT_SORT;
|
|
390
|
-
this.resourceName = options.resourceName;
|
|
391
|
-
this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
|
|
392
|
-
this.idField = options.idField ?? DEFAULT_ID_FIELD;
|
|
393
|
-
this._matchesFilter = options.matchesFilter;
|
|
394
|
-
if (options.cache) this._cacheConfig = options.cache;
|
|
395
|
-
if (options.presetFields) this._presetFields = options.presetFields;
|
|
396
|
-
this.accessControl = new AccessControl({
|
|
397
|
-
tenantField: this.tenantField,
|
|
398
|
-
idField: this.idField,
|
|
399
|
-
matchesFilter: this._matchesFilter
|
|
400
|
-
});
|
|
401
|
-
this.bodySanitizer = new BodySanitizer({ schemaOptions: this.schemaOptions });
|
|
402
|
-
this.queryResolver = new QueryResolver({
|
|
403
|
-
queryParser: this.queryParser,
|
|
404
|
-
maxLimit: this.maxLimit,
|
|
405
|
-
defaultLimit: this.defaultLimit,
|
|
406
|
-
defaultSort: this.defaultSort,
|
|
407
|
-
schemaOptions: this.schemaOptions,
|
|
408
|
-
tenantField: this.tenantField
|
|
409
|
-
});
|
|
410
|
-
this.list = this.list.bind(this);
|
|
411
|
-
this.get = this.get.bind(this);
|
|
412
|
-
this.create = this.create.bind(this);
|
|
413
|
-
this.update = this.update.bind(this);
|
|
414
|
-
this.delete = this.delete.bind(this);
|
|
415
|
-
}
|
|
416
|
-
/**
|
|
417
|
-
* Get the tenant field name if multi-tenant scoping is enabled.
|
|
418
|
-
* Returns `undefined` when `tenantField` is `false` (platform-universal mode).
|
|
419
|
-
*
|
|
420
|
-
* Use this in subclass overrides instead of accessing `this.tenantField` directly
|
|
421
|
-
* to avoid TypeScript indexing errors with `string | false`.
|
|
422
|
-
*/
|
|
423
|
-
getTenantField() {
|
|
424
|
-
return this.tenantField || void 0;
|
|
425
|
-
}
|
|
426
|
-
/** Extract typed Arc internal metadata from request */
|
|
427
|
-
meta(req) {
|
|
428
|
-
return req.metadata;
|
|
429
|
-
}
|
|
430
|
-
/** Get hook system from request context (instance-scoped) */
|
|
431
|
-
getHooks(req) {
|
|
432
|
-
return this.meta(req)?.arc?.hooks ?? null;
|
|
433
|
-
}
|
|
434
|
-
/** Resolve cache config for a specific operation, merging per-op overrides */
|
|
435
|
-
resolveCacheConfig(operation) {
|
|
436
|
-
const cfg = this._cacheConfig;
|
|
437
|
-
if (!cfg || cfg.disabled) return null;
|
|
438
|
-
const opOverride = cfg[operation];
|
|
439
|
-
return {
|
|
440
|
-
staleTime: opOverride?.staleTime ?? cfg.staleTime ?? 0,
|
|
441
|
-
gcTime: opOverride?.gcTime ?? cfg.gcTime ?? 60,
|
|
442
|
-
tags: cfg.tags
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
/** Extract user/org IDs from request for cache key scoping */
|
|
446
|
-
cacheScope(req) {
|
|
447
|
-
const userId = getUserId(req.user);
|
|
448
|
-
const scope = this.meta(req)?._scope;
|
|
449
|
-
return {
|
|
450
|
-
userId,
|
|
451
|
-
orgId: scope ? getOrgId(scope) : void 0
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
async list(req) {
|
|
455
|
-
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
456
|
-
const cacheConfig = this.resolveCacheConfig("list");
|
|
457
|
-
const qc = req.server?.queryCache;
|
|
458
|
-
if (cacheConfig && qc) {
|
|
459
|
-
const version = await qc.getResourceVersion(this.resourceName);
|
|
460
|
-
const { userId, orgId } = this.cacheScope(req);
|
|
461
|
-
const key = buildQueryKey(this.resourceName, "list", version, options, userId, orgId);
|
|
462
|
-
const { data, status } = await qc.get(key);
|
|
463
|
-
if (status === "fresh") return {
|
|
464
|
-
success: true,
|
|
465
|
-
data,
|
|
466
|
-
status: 200,
|
|
467
|
-
headers: { "x-cache": "HIT" }
|
|
468
|
-
};
|
|
469
|
-
if (status === "stale") {
|
|
470
|
-
setImmediate(() => {
|
|
471
|
-
this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
|
|
472
|
-
});
|
|
473
|
-
return {
|
|
474
|
-
success: true,
|
|
475
|
-
data,
|
|
476
|
-
status: 200,
|
|
477
|
-
headers: { "x-cache": "STALE" }
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
const result = await this.executeListQuery(options, req);
|
|
481
|
-
await qc.set(key, result, cacheConfig);
|
|
482
|
-
return {
|
|
483
|
-
success: true,
|
|
484
|
-
data: result,
|
|
485
|
-
status: 200,
|
|
486
|
-
headers: { "x-cache": "MISS" }
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
return {
|
|
490
|
-
success: true,
|
|
491
|
-
data: await this.executeListQuery(options, req),
|
|
492
|
-
status: 200
|
|
493
|
-
};
|
|
494
|
-
}
|
|
495
|
-
/** Execute list query through hooks (extracted for cache revalidation) */
|
|
496
|
-
async executeListQuery(options, req) {
|
|
497
|
-
const hooks = this.getHooks(req);
|
|
498
|
-
const repoGetAll = async () => this.repository.getAll(options);
|
|
499
|
-
const result = hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "list", options, repoGetAll, {
|
|
500
|
-
user: req.user,
|
|
501
|
-
context: this.meta(req)
|
|
502
|
-
}) : await repoGetAll();
|
|
503
|
-
if (Array.isArray(result)) return {
|
|
504
|
-
docs: result,
|
|
505
|
-
page: 1,
|
|
506
|
-
limit: result.length,
|
|
507
|
-
total: result.length,
|
|
508
|
-
pages: 1,
|
|
509
|
-
hasNext: false,
|
|
510
|
-
hasPrev: false
|
|
511
|
-
};
|
|
512
|
-
return result;
|
|
513
|
-
}
|
|
514
|
-
async get(req) {
|
|
515
|
-
const id = req.params.id;
|
|
516
|
-
if (!id) return {
|
|
517
|
-
success: false,
|
|
518
|
-
error: "ID parameter is required",
|
|
519
|
-
status: 400
|
|
520
|
-
};
|
|
521
|
-
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
522
|
-
const cacheConfig = this.resolveCacheConfig("byId");
|
|
523
|
-
const qc = req.server?.queryCache;
|
|
524
|
-
if (cacheConfig && qc) {
|
|
525
|
-
const version = await qc.getResourceVersion(this.resourceName);
|
|
526
|
-
const { userId, orgId } = this.cacheScope(req);
|
|
527
|
-
const key = buildQueryKey(this.resourceName, "get", version, {
|
|
528
|
-
id,
|
|
529
|
-
...options
|
|
530
|
-
}, userId, orgId);
|
|
531
|
-
const { data, status } = await qc.get(key);
|
|
532
|
-
if (status === "fresh") return {
|
|
533
|
-
success: true,
|
|
534
|
-
data,
|
|
535
|
-
status: 200,
|
|
536
|
-
headers: { "x-cache": "HIT" }
|
|
537
|
-
};
|
|
538
|
-
if (status === "stale") {
|
|
539
|
-
setImmediate(() => {
|
|
540
|
-
this.executeGetQuery(id, options, req).then((fresh) => {
|
|
541
|
-
if (fresh) qc.set(key, fresh, cacheConfig);
|
|
542
|
-
}).catch(() => {});
|
|
543
|
-
});
|
|
544
|
-
return {
|
|
545
|
-
success: true,
|
|
546
|
-
data,
|
|
547
|
-
status: 200,
|
|
548
|
-
headers: { "x-cache": "STALE" }
|
|
549
|
-
};
|
|
550
|
-
}
|
|
551
|
-
const item = await this.executeGetQuery(id, options, req);
|
|
552
|
-
if (!item) return {
|
|
553
|
-
success: false,
|
|
554
|
-
error: "Resource not found",
|
|
555
|
-
status: 404
|
|
556
|
-
};
|
|
557
|
-
await qc.set(key, item, cacheConfig);
|
|
558
|
-
return {
|
|
559
|
-
success: true,
|
|
560
|
-
data: item,
|
|
561
|
-
status: 200,
|
|
562
|
-
headers: { "x-cache": "MISS" }
|
|
563
|
-
};
|
|
564
|
-
}
|
|
565
|
-
try {
|
|
566
|
-
const item = await this.executeGetQuery(id, options, req);
|
|
567
|
-
if (!item) return {
|
|
568
|
-
success: false,
|
|
569
|
-
error: "Resource not found",
|
|
570
|
-
status: 404
|
|
571
|
-
};
|
|
572
|
-
return {
|
|
573
|
-
success: true,
|
|
574
|
-
data: item,
|
|
575
|
-
status: 200
|
|
576
|
-
};
|
|
577
|
-
} catch (error) {
|
|
578
|
-
if (error instanceof Error && error.message?.includes("not found")) return {
|
|
579
|
-
success: false,
|
|
580
|
-
error: "Resource not found",
|
|
581
|
-
status: 404
|
|
582
|
-
};
|
|
583
|
-
throw error;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
/** Execute get query through hooks (extracted for cache revalidation) */
|
|
587
|
-
async executeGetQuery(id, options, req) {
|
|
588
|
-
const hooks = this.getHooks(req);
|
|
589
|
-
const fetchItem = async () => this.accessControl.fetchWithAccessControl(id, req, this.repository, options);
|
|
590
|
-
return (hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "read", null, fetchItem, {
|
|
591
|
-
user: req.user,
|
|
592
|
-
context: this.meta(req)
|
|
593
|
-
}) : await fetchItem()) ?? null;
|
|
594
|
-
}
|
|
595
|
-
async create(req) {
|
|
596
|
-
const arcContext = this.meta(req);
|
|
597
|
-
const data = this.bodySanitizer.sanitize(req.body ?? {}, "create", req, arcContext);
|
|
598
|
-
const scope = arcContext?._scope;
|
|
599
|
-
const createOrgId = scope ? getOrgId(scope) : void 0;
|
|
600
|
-
if (this.tenantField && createOrgId) data[this.tenantField] = createOrgId;
|
|
601
|
-
const userId = getUserId(req.user);
|
|
602
|
-
if (userId) data.createdBy = userId;
|
|
603
|
-
const hooks = this.getHooks(req);
|
|
604
|
-
const user = req.user;
|
|
605
|
-
let processedData = data;
|
|
606
|
-
if (hooks && this.resourceName) try {
|
|
607
|
-
processedData = await hooks.executeBefore(this.resourceName, "create", data, {
|
|
608
|
-
user,
|
|
609
|
-
context: arcContext
|
|
610
|
-
});
|
|
611
|
-
} catch (err) {
|
|
612
|
-
return {
|
|
613
|
-
success: false,
|
|
614
|
-
error: "Hook execution failed",
|
|
615
|
-
details: {
|
|
616
|
-
code: "BEFORE_CREATE_HOOK_ERROR",
|
|
617
|
-
message: err.message
|
|
618
|
-
},
|
|
619
|
-
status: 400
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
const repoCreate = async () => this.repository.create(processedData, {
|
|
623
|
-
user,
|
|
624
|
-
context: arcContext
|
|
625
|
-
});
|
|
626
|
-
let item;
|
|
627
|
-
if (hooks && this.resourceName) {
|
|
628
|
-
item = await hooks.executeAround(this.resourceName, "create", processedData, repoCreate, {
|
|
629
|
-
user,
|
|
630
|
-
context: arcContext
|
|
631
|
-
});
|
|
632
|
-
await hooks.executeAfter(this.resourceName, "create", item, {
|
|
633
|
-
user,
|
|
634
|
-
context: arcContext
|
|
635
|
-
});
|
|
636
|
-
} else item = await repoCreate();
|
|
637
|
-
return {
|
|
638
|
-
success: true,
|
|
639
|
-
data: item,
|
|
640
|
-
status: 201,
|
|
641
|
-
meta: { message: "Created successfully" }
|
|
642
|
-
};
|
|
643
|
-
}
|
|
644
|
-
async update(req) {
|
|
645
|
-
const id = req.params.id;
|
|
646
|
-
if (!id) return {
|
|
647
|
-
success: false,
|
|
648
|
-
error: "ID parameter is required",
|
|
649
|
-
status: 400
|
|
650
|
-
};
|
|
651
|
-
const arcContext = this.meta(req);
|
|
652
|
-
const data = this.bodySanitizer.sanitize(req.body ?? {}, "update", req, arcContext);
|
|
653
|
-
const user = req.user;
|
|
654
|
-
const userId = getUserId(user);
|
|
655
|
-
if (userId) data.updatedBy = userId;
|
|
656
|
-
const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
|
|
657
|
-
if (!existing) return {
|
|
658
|
-
success: false,
|
|
659
|
-
error: "Resource not found",
|
|
660
|
-
status: 404
|
|
661
|
-
};
|
|
662
|
-
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
663
|
-
success: false,
|
|
664
|
-
error: "You do not have permission to modify this resource",
|
|
665
|
-
details: { code: "OWNERSHIP_DENIED" },
|
|
666
|
-
status: 403
|
|
667
|
-
};
|
|
668
|
-
const hooks = this.getHooks(req);
|
|
669
|
-
let processedData = data;
|
|
670
|
-
if (hooks && this.resourceName) try {
|
|
671
|
-
processedData = await hooks.executeBefore(this.resourceName, "update", data, {
|
|
672
|
-
user,
|
|
673
|
-
context: arcContext,
|
|
674
|
-
meta: {
|
|
675
|
-
id,
|
|
676
|
-
existing
|
|
677
|
-
}
|
|
678
|
-
});
|
|
679
|
-
} catch (err) {
|
|
680
|
-
return {
|
|
681
|
-
success: false,
|
|
682
|
-
error: "Hook execution failed",
|
|
683
|
-
details: {
|
|
684
|
-
code: "BEFORE_UPDATE_HOOK_ERROR",
|
|
685
|
-
message: err.message
|
|
686
|
-
},
|
|
687
|
-
status: 400
|
|
688
|
-
};
|
|
689
|
-
}
|
|
690
|
-
const repoUpdate = async () => this.repository.update(id, processedData, {
|
|
691
|
-
user,
|
|
692
|
-
context: arcContext
|
|
693
|
-
});
|
|
694
|
-
let item;
|
|
695
|
-
if (hooks && this.resourceName) {
|
|
696
|
-
item = await hooks.executeAround(this.resourceName, "update", processedData, repoUpdate, {
|
|
697
|
-
user,
|
|
698
|
-
context: arcContext,
|
|
699
|
-
meta: {
|
|
700
|
-
id,
|
|
701
|
-
existing
|
|
702
|
-
}
|
|
703
|
-
});
|
|
704
|
-
if (item) await hooks.executeAfter(this.resourceName, "update", item, {
|
|
705
|
-
user,
|
|
706
|
-
context: arcContext,
|
|
707
|
-
meta: {
|
|
708
|
-
id,
|
|
709
|
-
existing
|
|
710
|
-
}
|
|
711
|
-
});
|
|
712
|
-
} else item = await repoUpdate();
|
|
713
|
-
if (!item) return {
|
|
714
|
-
success: false,
|
|
715
|
-
error: "Resource not found",
|
|
716
|
-
status: 404
|
|
717
|
-
};
|
|
718
|
-
return {
|
|
719
|
-
success: true,
|
|
720
|
-
data: item,
|
|
721
|
-
status: 200,
|
|
722
|
-
meta: { message: "Updated successfully" }
|
|
723
|
-
};
|
|
724
|
-
}
|
|
725
|
-
async delete(req) {
|
|
726
|
-
const id = req.params.id;
|
|
727
|
-
if (!id) return {
|
|
728
|
-
success: false,
|
|
729
|
-
error: "ID parameter is required",
|
|
730
|
-
status: 400
|
|
731
|
-
};
|
|
732
|
-
const arcContext = this.meta(req);
|
|
733
|
-
const user = req.user;
|
|
734
|
-
const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
|
|
735
|
-
if (!existing) return {
|
|
736
|
-
success: false,
|
|
737
|
-
error: "Resource not found",
|
|
738
|
-
status: 404
|
|
739
|
-
};
|
|
740
|
-
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
741
|
-
success: false,
|
|
742
|
-
error: "You do not have permission to delete this resource",
|
|
743
|
-
details: { code: "OWNERSHIP_DENIED" },
|
|
744
|
-
status: 403
|
|
745
|
-
};
|
|
746
|
-
const hooks = this.getHooks(req);
|
|
747
|
-
if (hooks && this.resourceName) try {
|
|
748
|
-
await hooks.executeBefore(this.resourceName, "delete", existing, {
|
|
749
|
-
user,
|
|
750
|
-
context: arcContext,
|
|
751
|
-
meta: { id }
|
|
752
|
-
});
|
|
753
|
-
} catch (err) {
|
|
754
|
-
return {
|
|
755
|
-
success: false,
|
|
756
|
-
error: "Hook execution failed",
|
|
757
|
-
details: {
|
|
758
|
-
code: "BEFORE_DELETE_HOOK_ERROR",
|
|
759
|
-
message: err.message
|
|
760
|
-
},
|
|
761
|
-
status: 400
|
|
762
|
-
};
|
|
39
|
+
async function executePipeline(steps, ctx, handler, operation) {
|
|
40
|
+
const guards = [];
|
|
41
|
+
const transforms = [];
|
|
42
|
+
const interceptors = [];
|
|
43
|
+
for (const step of steps) {
|
|
44
|
+
if (!appliesTo(step, operation)) continue;
|
|
45
|
+
switch (step._type) {
|
|
46
|
+
case "guard":
|
|
47
|
+
guards.push(step);
|
|
48
|
+
break;
|
|
49
|
+
case "transform":
|
|
50
|
+
transforms.push(step);
|
|
51
|
+
break;
|
|
52
|
+
case "interceptor":
|
|
53
|
+
interceptors.push(step);
|
|
54
|
+
break;
|
|
763
55
|
}
|
|
764
|
-
const repoDelete = async () => this.repository.delete(id, {
|
|
765
|
-
user,
|
|
766
|
-
context: arcContext
|
|
767
|
-
});
|
|
768
|
-
let result;
|
|
769
|
-
if (hooks && this.resourceName) result = await hooks.executeAround(this.resourceName, "delete", existing, repoDelete, {
|
|
770
|
-
user,
|
|
771
|
-
context: arcContext,
|
|
772
|
-
meta: { id }
|
|
773
|
-
});
|
|
774
|
-
else result = await repoDelete();
|
|
775
|
-
if (!(typeof result === "object" && result !== null ? result.success : result)) return {
|
|
776
|
-
success: false,
|
|
777
|
-
error: "Resource not found",
|
|
778
|
-
status: 404
|
|
779
|
-
};
|
|
780
|
-
if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "delete", existing, {
|
|
781
|
-
user,
|
|
782
|
-
context: arcContext,
|
|
783
|
-
meta: { id }
|
|
784
|
-
});
|
|
785
|
-
return {
|
|
786
|
-
success: true,
|
|
787
|
-
data: { message: "Deleted successfully" },
|
|
788
|
-
status: 200
|
|
789
|
-
};
|
|
790
|
-
}
|
|
791
|
-
async getBySlug(req) {
|
|
792
|
-
const repo = this.repository;
|
|
793
|
-
if (!repo.getBySlug) return {
|
|
794
|
-
success: false,
|
|
795
|
-
error: "Slug lookup not implemented",
|
|
796
|
-
status: 501
|
|
797
|
-
};
|
|
798
|
-
const slugField = this._presetFields.slugField ?? "slug";
|
|
799
|
-
const slug = req.params[slugField] ?? req.params.slug;
|
|
800
|
-
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
801
|
-
const item = await repo.getBySlug(slug, options);
|
|
802
|
-
if (!this.accessControl.validateItemAccess(item, req)) return {
|
|
803
|
-
success: false,
|
|
804
|
-
error: "Resource not found",
|
|
805
|
-
status: 404
|
|
806
|
-
};
|
|
807
|
-
return {
|
|
808
|
-
success: true,
|
|
809
|
-
data: item,
|
|
810
|
-
status: 200
|
|
811
|
-
};
|
|
812
|
-
}
|
|
813
|
-
async getDeleted(req) {
|
|
814
|
-
const repo = this.repository;
|
|
815
|
-
if (!repo.getDeleted) return {
|
|
816
|
-
success: false,
|
|
817
|
-
error: "Soft delete not implemented",
|
|
818
|
-
status: 501
|
|
819
|
-
};
|
|
820
|
-
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
821
|
-
const result = await repo.getDeleted(options);
|
|
822
|
-
if (Array.isArray(result)) return {
|
|
823
|
-
success: true,
|
|
824
|
-
data: {
|
|
825
|
-
docs: result,
|
|
826
|
-
page: 1,
|
|
827
|
-
limit: result.length,
|
|
828
|
-
total: result.length,
|
|
829
|
-
pages: 1,
|
|
830
|
-
hasNext: false,
|
|
831
|
-
hasPrev: false
|
|
832
|
-
},
|
|
833
|
-
status: 200
|
|
834
|
-
};
|
|
835
|
-
return {
|
|
836
|
-
success: true,
|
|
837
|
-
data: result,
|
|
838
|
-
status: 200
|
|
839
|
-
};
|
|
840
|
-
}
|
|
841
|
-
async restore(req) {
|
|
842
|
-
const repo = this.repository;
|
|
843
|
-
if (!repo.restore) return {
|
|
844
|
-
success: false,
|
|
845
|
-
error: "Restore not implemented",
|
|
846
|
-
status: 501
|
|
847
|
-
};
|
|
848
|
-
const id = req.params.id;
|
|
849
|
-
if (!id) return {
|
|
850
|
-
success: false,
|
|
851
|
-
error: "ID parameter is required",
|
|
852
|
-
status: 400
|
|
853
|
-
};
|
|
854
|
-
const existing = await this.accessControl.fetchWithAccessControl(id, req, repo);
|
|
855
|
-
if (!existing) return {
|
|
856
|
-
success: false,
|
|
857
|
-
error: "Resource not found",
|
|
858
|
-
status: 404
|
|
859
|
-
};
|
|
860
|
-
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
861
|
-
success: false,
|
|
862
|
-
error: "You do not have permission to restore this resource",
|
|
863
|
-
details: { code: "OWNERSHIP_DENIED" },
|
|
864
|
-
status: 403
|
|
865
|
-
};
|
|
866
|
-
const item = await repo.restore(id);
|
|
867
|
-
if (!item) return {
|
|
868
|
-
success: false,
|
|
869
|
-
error: "Resource not found",
|
|
870
|
-
status: 404
|
|
871
|
-
};
|
|
872
|
-
return {
|
|
873
|
-
success: true,
|
|
874
|
-
data: item,
|
|
875
|
-
status: 200,
|
|
876
|
-
meta: { message: "Restored successfully" }
|
|
877
|
-
};
|
|
878
56
|
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
status: 501
|
|
885
|
-
};
|
|
886
|
-
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
887
|
-
return {
|
|
888
|
-
success: true,
|
|
889
|
-
data: await repo.getTree(options),
|
|
890
|
-
status: 200
|
|
891
|
-
};
|
|
57
|
+
for (const g of guards) if (!await g.handler(ctx)) throw new ForbiddenError(`Guard '${g.name}' denied access`);
|
|
58
|
+
let currentCtx = ctx;
|
|
59
|
+
for (const t of transforms) {
|
|
60
|
+
const result = await t.handler(currentCtx);
|
|
61
|
+
if (result) currentCtx = result;
|
|
892
62
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
status: 501
|
|
899
|
-
};
|
|
900
|
-
const parentField = this._presetFields.parentField ?? "parent";
|
|
901
|
-
const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
|
|
902
|
-
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
903
|
-
return {
|
|
904
|
-
success: true,
|
|
905
|
-
data: await repo.getChildren(parentId, options),
|
|
906
|
-
status: 200
|
|
907
|
-
};
|
|
63
|
+
let chain = () => handler(currentCtx);
|
|
64
|
+
for (let i = interceptors.length - 1; i >= 0; i--) {
|
|
65
|
+
const interceptor = interceptors[i];
|
|
66
|
+
const next = chain;
|
|
67
|
+
chain = () => interceptor.handler(currentCtx, next);
|
|
908
68
|
}
|
|
909
|
-
|
|
910
|
-
|
|
69
|
+
return chain();
|
|
70
|
+
}
|
|
911
71
|
//#endregion
|
|
912
72
|
//#region src/core/fastifyAdapter.ts
|
|
913
73
|
/** Type guard for Mongoose-like documents with toObject() */
|
|
@@ -1127,68 +287,6 @@ function createCrudHandlers(controller) {
|
|
|
1127
287
|
delete: createFastifyHandler(controller.delete.bind(controller))
|
|
1128
288
|
};
|
|
1129
289
|
}
|
|
1130
|
-
|
|
1131
|
-
//#endregion
|
|
1132
|
-
//#region src/pipeline/pipe.ts
|
|
1133
|
-
/**
|
|
1134
|
-
* Compose pipeline steps into an ordered array.
|
|
1135
|
-
* Accepts guards, transforms, and interceptors in any order.
|
|
1136
|
-
*/
|
|
1137
|
-
function pipe(...steps) {
|
|
1138
|
-
return steps;
|
|
1139
|
-
}
|
|
1140
|
-
/**
|
|
1141
|
-
* Check if a step applies to the given operation.
|
|
1142
|
-
*/
|
|
1143
|
-
function appliesTo(step, operation) {
|
|
1144
|
-
if (!step.operations || step.operations.length === 0) return true;
|
|
1145
|
-
return step.operations.includes(operation);
|
|
1146
|
-
}
|
|
1147
|
-
/**
|
|
1148
|
-
* Execute a pipeline against a request context.
|
|
1149
|
-
*
|
|
1150
|
-
* This is the core runtime that createCrudRouter uses to execute pipelines.
|
|
1151
|
-
* External usage is not needed — this is wired automatically when `pipe` is set.
|
|
1152
|
-
*
|
|
1153
|
-
* @param steps - Pipeline steps to execute
|
|
1154
|
-
* @param ctx - The pipeline context (extends IRequestContext)
|
|
1155
|
-
* @param handler - The actual controller method to call
|
|
1156
|
-
* @param operation - The CRUD operation name
|
|
1157
|
-
* @returns The controller response (possibly modified by interceptors)
|
|
1158
|
-
*/
|
|
1159
|
-
async function executePipeline(steps, ctx, handler, operation) {
|
|
1160
|
-
const guards = [];
|
|
1161
|
-
const transforms = [];
|
|
1162
|
-
const interceptors = [];
|
|
1163
|
-
for (const step of steps) {
|
|
1164
|
-
if (!appliesTo(step, operation)) continue;
|
|
1165
|
-
switch (step._type) {
|
|
1166
|
-
case "guard":
|
|
1167
|
-
guards.push(step);
|
|
1168
|
-
break;
|
|
1169
|
-
case "transform":
|
|
1170
|
-
transforms.push(step);
|
|
1171
|
-
break;
|
|
1172
|
-
case "interceptor":
|
|
1173
|
-
interceptors.push(step);
|
|
1174
|
-
break;
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
for (const g of guards) if (!await g.handler(ctx)) throw new ForbiddenError(`Guard '${g.name}' denied access`);
|
|
1178
|
-
let currentCtx = ctx;
|
|
1179
|
-
for (const t of transforms) {
|
|
1180
|
-
const result = await t.handler(currentCtx);
|
|
1181
|
-
if (result) currentCtx = result;
|
|
1182
|
-
}
|
|
1183
|
-
let chain = () => handler(currentCtx);
|
|
1184
|
-
for (let i = interceptors.length - 1; i >= 0; i--) {
|
|
1185
|
-
const interceptor = interceptors[i];
|
|
1186
|
-
const next = chain;
|
|
1187
|
-
chain = () => interceptor.handler(currentCtx, next);
|
|
1188
|
-
}
|
|
1189
|
-
return chain();
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
290
|
//#endregion
|
|
1193
291
|
//#region src/core/createCrudRouter.ts
|
|
1194
292
|
/**
|
|
@@ -1286,9 +384,11 @@ function buildPermissionMiddleware(permissionCheck, resourceName, action) {
|
|
|
1286
384
|
}
|
|
1287
385
|
const permResult = result;
|
|
1288
386
|
if (!permResult.granted) {
|
|
387
|
+
const defaultMsg = context.user ? "Permission denied" : "Authentication required";
|
|
388
|
+
const reason = permResult.reason && permResult.reason.length <= 100 ? permResult.reason : defaultMsg;
|
|
1289
389
|
reply.code(context.user ? 403 : 401).send({
|
|
1290
390
|
success: false,
|
|
1291
|
-
error:
|
|
391
|
+
error: reason
|
|
1292
392
|
});
|
|
1293
393
|
return;
|
|
1294
394
|
}
|
|
@@ -1561,194 +661,20 @@ function createCrudRouter(fastify, controller, options = {}) {
|
|
|
1561
661
|
function createPermissionMiddleware(permission, resourceName, action) {
|
|
1562
662
|
return buildPermissionMiddleware(permission, resourceName, action);
|
|
1563
663
|
}
|
|
1564
|
-
|
|
1565
664
|
//#endregion
|
|
1566
|
-
//#region src/core/
|
|
665
|
+
//#region src/core/validateResourceConfig.ts
|
|
1567
666
|
/**
|
|
1568
|
-
*
|
|
667
|
+
* Resource Configuration Validator
|
|
1569
668
|
*
|
|
1570
|
-
*
|
|
1571
|
-
*
|
|
669
|
+
* Fail-fast validation at definition time.
|
|
670
|
+
* Invalid configs throw immediately with clear, actionable errors.
|
|
1572
671
|
*
|
|
1573
|
-
* @
|
|
1574
|
-
*
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
const actionEnum = Object.keys(actions);
|
|
1579
|
-
if (actionEnum.length === 0) {
|
|
1580
|
-
fastify.log.warn("[createActionRouter] No actions defined, skipping route creation");
|
|
1581
|
-
return;
|
|
1582
|
-
}
|
|
1583
|
-
const bodyProperties = { action: {
|
|
1584
|
-
type: "string",
|
|
1585
|
-
enum: actionEnum,
|
|
1586
|
-
description: `Action to perform: ${actionEnum.join(" | ")}`
|
|
1587
|
-
} };
|
|
1588
|
-
Object.entries(actionSchemas).forEach(([actionName, schema]) => {
|
|
1589
|
-
if (schema && typeof schema === "object") Object.entries(schema).forEach(([propName, propSchema]) => {
|
|
1590
|
-
bodyProperties[propName] = {
|
|
1591
|
-
...propSchema,
|
|
1592
|
-
description: `${propSchema.description || ""} (for ${actionName} action)`.trim()
|
|
1593
|
-
};
|
|
1594
|
-
});
|
|
1595
|
-
});
|
|
1596
|
-
const routeSchema = {
|
|
1597
|
-
tags: tag ? [tag] : void 0,
|
|
1598
|
-
summary: `Perform action (${actionEnum.join("/")})`,
|
|
1599
|
-
description: buildActionDescription(actions, actionPermissions),
|
|
1600
|
-
params: {
|
|
1601
|
-
type: "object",
|
|
1602
|
-
properties: { id: {
|
|
1603
|
-
type: "string",
|
|
1604
|
-
description: "Resource ID"
|
|
1605
|
-
} },
|
|
1606
|
-
required: ["id"]
|
|
1607
|
-
},
|
|
1608
|
-
body: {
|
|
1609
|
-
type: "object",
|
|
1610
|
-
properties: bodyProperties,
|
|
1611
|
-
required: ["action"]
|
|
1612
|
-
}
|
|
1613
|
-
};
|
|
1614
|
-
const preHandler = [];
|
|
1615
|
-
const hasPublicActions = Object.entries(actionPermissions).some(([, p]) => p?._isPublic) || globalAuth && globalAuth?._isPublic;
|
|
1616
|
-
const hasProtectedActions = Object.entries(actionPermissions).some(([, p]) => !p?._isPublic) || globalAuth && !globalAuth?._isPublic;
|
|
1617
|
-
if (hasProtectedActions && !hasPublicActions && fastify.authenticate) preHandler.push(fastify.authenticate);
|
|
1618
|
-
fastify.post("/:id/action", {
|
|
1619
|
-
schema: routeSchema,
|
|
1620
|
-
preHandler: preHandler.length ? preHandler : void 0
|
|
1621
|
-
}, async (req, reply) => {
|
|
1622
|
-
const { action, ...data } = req.body;
|
|
1623
|
-
const { id } = req.params;
|
|
1624
|
-
const rawIdempotencyKey = req.headers["idempotency-key"];
|
|
1625
|
-
const idempotencyKey = Array.isArray(rawIdempotencyKey) ? rawIdempotencyKey[0] : rawIdempotencyKey;
|
|
1626
|
-
const handler = actions[action];
|
|
1627
|
-
if (!handler) return reply.code(400).send({
|
|
1628
|
-
success: false,
|
|
1629
|
-
error: `Invalid action '${action}'. Valid actions: ${actionEnum.join(", ")}`,
|
|
1630
|
-
validActions: actionEnum
|
|
1631
|
-
});
|
|
1632
|
-
const permissionCheck = actionPermissions[action] ?? globalAuth;
|
|
1633
|
-
if (hasPublicActions && hasProtectedActions && permissionCheck) {
|
|
1634
|
-
if (!permissionCheck?._isPublic && fastify.authenticate) {
|
|
1635
|
-
try {
|
|
1636
|
-
await fastify.authenticate(req, reply);
|
|
1637
|
-
} catch {
|
|
1638
|
-
if (!reply.sent) return reply.code(401).send({
|
|
1639
|
-
success: false,
|
|
1640
|
-
error: "Authentication required"
|
|
1641
|
-
});
|
|
1642
|
-
return;
|
|
1643
|
-
}
|
|
1644
|
-
if (reply.sent) return;
|
|
1645
|
-
}
|
|
1646
|
-
}
|
|
1647
|
-
if (permissionCheck) {
|
|
1648
|
-
const context = {
|
|
1649
|
-
user: req.user ?? null,
|
|
1650
|
-
request: req,
|
|
1651
|
-
resource: tag ?? "action",
|
|
1652
|
-
action,
|
|
1653
|
-
resourceId: id,
|
|
1654
|
-
params: req.params,
|
|
1655
|
-
data
|
|
1656
|
-
};
|
|
1657
|
-
let result;
|
|
1658
|
-
try {
|
|
1659
|
-
result = await permissionCheck(context);
|
|
1660
|
-
} catch (err) {
|
|
1661
|
-
req.log?.warn?.({
|
|
1662
|
-
err,
|
|
1663
|
-
resource: tag ?? "action",
|
|
1664
|
-
action
|
|
1665
|
-
}, "Permission check threw");
|
|
1666
|
-
return reply.code(403).send({
|
|
1667
|
-
success: false,
|
|
1668
|
-
error: "Permission denied"
|
|
1669
|
-
});
|
|
1670
|
-
}
|
|
1671
|
-
if (typeof result === "boolean") {
|
|
1672
|
-
if (!result) return reply.code(context.user ? 403 : 401).send({
|
|
1673
|
-
success: false,
|
|
1674
|
-
error: context.user ? `Permission denied for '${action}'` : "Authentication required"
|
|
1675
|
-
});
|
|
1676
|
-
} else {
|
|
1677
|
-
const permResult = result;
|
|
1678
|
-
if (!permResult.granted) return reply.code(context.user ? 403 : 401).send({
|
|
1679
|
-
success: false,
|
|
1680
|
-
error: permResult.reason ?? (context.user ? `Permission denied for '${action}'` : "Authentication required")
|
|
1681
|
-
});
|
|
1682
|
-
}
|
|
1683
|
-
}
|
|
1684
|
-
try {
|
|
1685
|
-
if (idempotencyKey && idempotencyService) {
|
|
1686
|
-
const user = req.user;
|
|
1687
|
-
const payloadForHash = {
|
|
1688
|
-
action,
|
|
1689
|
-
id,
|
|
1690
|
-
data,
|
|
1691
|
-
userId: (user?._id)?.toString?.() || user?.id || null
|
|
1692
|
-
};
|
|
1693
|
-
const idempotencyResult = await idempotencyService.check(idempotencyKey, payloadForHash);
|
|
1694
|
-
if (!idempotencyResult.isNew && "existingResult" in idempotencyResult) return reply.send({
|
|
1695
|
-
success: true,
|
|
1696
|
-
data: idempotencyResult.existingResult,
|
|
1697
|
-
cached: true
|
|
1698
|
-
});
|
|
1699
|
-
}
|
|
1700
|
-
const result = await handler(id, data, req);
|
|
1701
|
-
if (idempotencyService) await idempotencyService.complete(idempotencyKey, result);
|
|
1702
|
-
return reply.send({
|
|
1703
|
-
success: true,
|
|
1704
|
-
data: result
|
|
1705
|
-
});
|
|
1706
|
-
} catch (error) {
|
|
1707
|
-
if (idempotencyService) await idempotencyService.fail(idempotencyKey, error);
|
|
1708
|
-
if (onError) {
|
|
1709
|
-
const { statusCode, error: errorMsg, code } = onError(error, action, id);
|
|
1710
|
-
return reply.code(statusCode).send({
|
|
1711
|
-
success: false,
|
|
1712
|
-
error: errorMsg,
|
|
1713
|
-
code
|
|
1714
|
-
});
|
|
1715
|
-
}
|
|
1716
|
-
const err = error;
|
|
1717
|
-
const statusCode = err.statusCode || err.status || 500;
|
|
1718
|
-
const errorCode = err.code || "ACTION_FAILED";
|
|
1719
|
-
if (statusCode >= 500) req.log.error({
|
|
1720
|
-
err: error,
|
|
1721
|
-
action,
|
|
1722
|
-
id
|
|
1723
|
-
}, "Action handler error");
|
|
1724
|
-
return reply.code(statusCode).send({
|
|
1725
|
-
success: false,
|
|
1726
|
-
error: err.message || `Failed to execute '${action}' action`,
|
|
1727
|
-
code: errorCode
|
|
1728
|
-
});
|
|
1729
|
-
}
|
|
1730
|
-
});
|
|
1731
|
-
fastify.log.debug({
|
|
1732
|
-
actions: actionEnum,
|
|
1733
|
-
tag
|
|
1734
|
-
}, "[createActionRouter] Registered action endpoint: POST /:id/action");
|
|
1735
|
-
}
|
|
1736
|
-
/**
|
|
1737
|
-
* Build description with action details
|
|
1738
|
-
* Uses _roles metadata from PermissionCheck functions for OpenAPI docs
|
|
672
|
+
* @example
|
|
673
|
+
* const result = validateResourceConfig(config);
|
|
674
|
+
* if (!result.valid) {
|
|
675
|
+
* console.error(formatValidationErrors(result.errors));
|
|
676
|
+
* }
|
|
1739
677
|
*/
|
|
1740
|
-
function buildActionDescription(actions, actionPermissions) {
|
|
1741
|
-
const lines = ["Unified action endpoint for state transitions.\n\n**Available actions:**"];
|
|
1742
|
-
Object.keys(actions).forEach((action) => {
|
|
1743
|
-
const roles = actionPermissions[action]?._roles;
|
|
1744
|
-
const roleStr = roles?.length ? ` (requires: ${roles.join(" or ")})` : "";
|
|
1745
|
-
lines.push(`- \`${action}\`${roleStr}`);
|
|
1746
|
-
});
|
|
1747
|
-
return lines.join("\n");
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
//#endregion
|
|
1751
|
-
//#region src/core/validateResourceConfig.ts
|
|
1752
678
|
/**
|
|
1753
679
|
* Validate a resource configuration
|
|
1754
680
|
*/
|
|
@@ -1825,7 +751,7 @@ function validateAdditionalRouteHandlers(controller, routes, errors) {
|
|
|
1825
751
|
});
|
|
1826
752
|
}
|
|
1827
753
|
}
|
|
1828
|
-
function validatePermissionKeys(config, options,
|
|
754
|
+
function validatePermissionKeys(config, options, _errors, warnings) {
|
|
1829
755
|
const validKeys = new Set([...CRUD_OPERATIONS, ...options.additionalPermissionKeys ?? []]);
|
|
1830
756
|
for (const route of config.additionalRoutes ?? []) if (typeof route.handler === "string") validKeys.add(route.handler);
|
|
1831
757
|
for (const preset of config.presets ?? []) {
|
|
@@ -1953,7 +879,6 @@ function assertValidConfig(config, options) {
|
|
|
1953
879
|
warnings: result.warnings
|
|
1954
880
|
}));
|
|
1955
881
|
}
|
|
1956
|
-
|
|
1957
882
|
//#endregion
|
|
1958
883
|
//#region src/core/defineResource.ts
|
|
1959
884
|
/**
|
|
@@ -1981,19 +906,28 @@ function defineResource(config) {
|
|
|
1981
906
|
const resolvedConfig = config.presets?.length ? applyPresets(config, config.presets) : config;
|
|
1982
907
|
resolvedConfig._appliedPresets = originalPresets;
|
|
1983
908
|
let controller = resolvedConfig.controller;
|
|
1984
|
-
if (!controller && hasCrudRoutes && repository)
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
909
|
+
if (!controller && hasCrudRoutes && repository) {
|
|
910
|
+
const qp = resolvedConfig.queryParser;
|
|
911
|
+
let maxLimitFromParser;
|
|
912
|
+
if (qp?.getQuerySchema) {
|
|
913
|
+
const limitProp = qp.getQuerySchema()?.properties?.limit;
|
|
914
|
+
if (limitProp?.maximum) maxLimitFromParser = limitProp.maximum;
|
|
915
|
+
}
|
|
916
|
+
controller = new BaseController(repository, {
|
|
917
|
+
resourceName: resolvedConfig.name,
|
|
918
|
+
schemaOptions: resolvedConfig.schemaOptions,
|
|
919
|
+
queryParser: resolvedConfig.queryParser,
|
|
920
|
+
maxLimit: maxLimitFromParser,
|
|
921
|
+
tenantField: resolvedConfig.tenantField,
|
|
922
|
+
idField: resolvedConfig.idField,
|
|
923
|
+
matchesFilter: config.adapter?.matchesFilter,
|
|
924
|
+
cache: resolvedConfig.cache,
|
|
925
|
+
presetFields: resolvedConfig._controllerOptions ? {
|
|
926
|
+
slugField: resolvedConfig._controllerOptions.slugField,
|
|
927
|
+
parentField: resolvedConfig._controllerOptions.parentField
|
|
928
|
+
} : void 0
|
|
929
|
+
});
|
|
930
|
+
}
|
|
1997
931
|
const resource = new ResourceDefinition({
|
|
1998
932
|
...resolvedConfig,
|
|
1999
933
|
adapter: config.adapter,
|
|
@@ -2048,12 +982,15 @@ var ResourceDefinition = class {
|
|
|
2048
982
|
pipe;
|
|
2049
983
|
fields;
|
|
2050
984
|
cache;
|
|
985
|
+
tenantField;
|
|
986
|
+
idField;
|
|
987
|
+
queryParser;
|
|
2051
988
|
_appliedPresets;
|
|
2052
989
|
_pendingHooks;
|
|
2053
990
|
_registryMeta;
|
|
2054
991
|
constructor(config) {
|
|
2055
992
|
this.name = config.name;
|
|
2056
|
-
this.displayName = config.displayName ?? capitalize(config.name)
|
|
993
|
+
this.displayName = config.displayName ?? `${capitalize(config.name)}s`;
|
|
2057
994
|
this.tag = config.tag ?? this.displayName;
|
|
2058
995
|
this.prefix = config.prefix ?? `/${config.name}s`;
|
|
2059
996
|
this.adapter = config.adapter;
|
|
@@ -2071,6 +1008,9 @@ var ResourceDefinition = class {
|
|
|
2071
1008
|
this.pipe = config.pipe;
|
|
2072
1009
|
this.fields = config.fields;
|
|
2073
1010
|
this.cache = config.cache;
|
|
1011
|
+
this.tenantField = config.tenantField;
|
|
1012
|
+
this.idField = config.idField;
|
|
1013
|
+
this.queryParser = config.queryParser;
|
|
2074
1014
|
this._appliedPresets = config._appliedPresets ?? [];
|
|
2075
1015
|
this._pendingHooks = config._pendingHooks ?? [];
|
|
2076
1016
|
}
|
|
@@ -2130,6 +1070,32 @@ var ResourceDefinition = class {
|
|
|
2130
1070
|
await fastify.register(async (instance) => {
|
|
2131
1071
|
const typedInstance = instance;
|
|
2132
1072
|
let schemas = null;
|
|
1073
|
+
const openApi = self._registryMeta?.openApiSchemas;
|
|
1074
|
+
if (openApi && (!self.customSchemas || Object.keys(self.customSchemas).length === 0)) {
|
|
1075
|
+
const generated = {};
|
|
1076
|
+
const { createBody, updateBody, params, response } = openApi;
|
|
1077
|
+
const safeBody = (schema) => {
|
|
1078
|
+
if (schema && typeof schema === "object" && schema.type === "object") return {
|
|
1079
|
+
additionalProperties: true,
|
|
1080
|
+
...schema
|
|
1081
|
+
};
|
|
1082
|
+
return schema;
|
|
1083
|
+
};
|
|
1084
|
+
if (createBody) generated.create = { body: safeBody(createBody) };
|
|
1085
|
+
if (updateBody) {
|
|
1086
|
+
const patchBody = { ...updateBody };
|
|
1087
|
+
delete patchBody.required;
|
|
1088
|
+
generated.update = { body: safeBody(patchBody) };
|
|
1089
|
+
if (params) generated.update.params = params;
|
|
1090
|
+
}
|
|
1091
|
+
if (params) {
|
|
1092
|
+
generated.get = { params };
|
|
1093
|
+
generated.delete = { params };
|
|
1094
|
+
if (!generated.update) generated.update = { params };
|
|
1095
|
+
else if (!generated.update.params) generated.update.params = params;
|
|
1096
|
+
}
|
|
1097
|
+
if (Object.keys(generated).length > 0) schemas = generated;
|
|
1098
|
+
}
|
|
2133
1099
|
if (self.customSchemas && Object.keys(self.customSchemas).length > 0) {
|
|
2134
1100
|
schemas = schemas ?? {};
|
|
2135
1101
|
for (const [op, customSchema] of Object.entries(self.customSchemas)) {
|
|
@@ -2138,6 +1104,30 @@ var ResourceDefinition = class {
|
|
|
2138
1104
|
schemas[key] = schemas[key] ? deepMergeSchemas(schemas[key], converted) : converted;
|
|
2139
1105
|
}
|
|
2140
1106
|
}
|
|
1107
|
+
const listQuerySchema = self._registryMeta?.openApiSchemas?.listQuery;
|
|
1108
|
+
if (listQuerySchema) {
|
|
1109
|
+
const FLEXIBLE_PARAMS = [
|
|
1110
|
+
"populate",
|
|
1111
|
+
"select",
|
|
1112
|
+
"lookup",
|
|
1113
|
+
"aggregate"
|
|
1114
|
+
];
|
|
1115
|
+
const props = listQuerySchema.properties;
|
|
1116
|
+
const normalizedProps = props ? { ...props } : void 0;
|
|
1117
|
+
if (normalizedProps) {
|
|
1118
|
+
for (const key of FLEXIBLE_PARAMS) if (normalizedProps[key] && typeof normalizedProps[key] === "object") {
|
|
1119
|
+
const { type: _type, ...rest } = normalizedProps[key];
|
|
1120
|
+
normalizedProps[key] = rest;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const normalizedSchema = {
|
|
1124
|
+
...listQuerySchema,
|
|
1125
|
+
...normalizedProps ? { properties: normalizedProps } : {},
|
|
1126
|
+
additionalProperties: listQuerySchema.additionalProperties ?? true
|
|
1127
|
+
};
|
|
1128
|
+
schemas = schemas ?? {};
|
|
1129
|
+
schemas.list = schemas.list ? deepMergeSchemas({ querystring: normalizedSchema }, schemas.list) : { querystring: normalizedSchema };
|
|
1130
|
+
}
|
|
2141
1131
|
const resolvedRoutes = self.additionalRoutes;
|
|
2142
1132
|
createCrudRouter(typedInstance, self.controller, {
|
|
2143
1133
|
tag: self.tag,
|
|
@@ -2207,6 +1197,5 @@ function capitalize(str) {
|
|
|
2207
1197
|
if (!str) return "";
|
|
2208
1198
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
2209
1199
|
}
|
|
2210
|
-
|
|
2211
1200
|
//#endregion
|
|
2212
|
-
export {
|
|
1201
|
+
export { validateResourceConfig as a, createCrudHandlers as c, getControllerContext as d, getControllerScope as f, formatValidationErrors as i, createFastifyHandler as l, pipe as m, defineResource as n, createCrudRouter as o, sendControllerResponse as p, assertValidConfig as r, createPermissionMiddleware as s, ResourceDefinition as t, createRequestContext as u };
|