@classytic/arc 2.3.0 → 2.4.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 +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-CgKOPhA4.mjs → createApp-ByWNRsZj.mjs} +64 -35
- package/dist/{defineResource-DWbpJYtm.mjs → defineResource-D9aY5Cy6.mjs} +108 -1157
- 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-6b_eRDBw.d.mts → index-BL8CaQih.d.mts} +56 -57
- package/dist/index-Diqcm14c.d.mts +369 -0
- package/dist/{prisma-Dy5S5F5i.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.d.mts +113 -44
- package/dist/migrations/index.mjs +84 -102
- 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-PMFE8HIv.mjs +533 -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 +2 -4
- 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-tKwaViYB.d.mts → types-Dt0-AI6E.d.mts} +68 -27
- package/dist/{types-DelU6kln.mjs → types-ZUu_h0jp.mjs} +1 -2
- package/dist/utils/index.d.mts +254 -351
- package/dist/utils/index.mjs +7 -6
- package/dist/utils-Dc0WhlIl.mjs +594 -0
- package/dist/versioning-BzfeHmhj.mjs +37 -0
- package/package.json +44 -10
- package/skills/arc/SKILL.md +518 -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 +431 -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-CSS2VvL6.mjs +0 -1109
- package/dist/errorHandler-CW3OOeYq.d.mts +0 -72
- package/dist/interface-BtdYtQUA.d.mts +0 -1114
- 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
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-Cxde4rpC.mjs";
|
|
2
|
+
import { d as isMember, n as PUBLIC_SCOPE, r as getOrgId, u as isElevated } from "./types-C6TQjtdi.mjs";
|
|
3
|
+
import { t as buildQueryKey } from "./keys-qcD-TVJl.mjs";
|
|
4
|
+
import { getUserId } from "./types/index.mjs";
|
|
5
|
+
import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-ipsbIRPK.mjs";
|
|
6
|
+
import { t as getUserRoles } from "./types-ZUu_h0jp.mjs";
|
|
7
|
+
import { t as ArcQueryParser } from "./queryParser-CgCtsjti.mjs";
|
|
8
|
+
//#region src/core/AccessControl.ts
|
|
9
|
+
/**
|
|
10
|
+
* AccessControl - Composable access control logic extracted from BaseController.
|
|
11
|
+
*
|
|
12
|
+
* Handles ID filtering, policy filter checking, org/tenant scope validation,
|
|
13
|
+
* ownership verification, and fetch-with-access-control patterns.
|
|
14
|
+
*
|
|
15
|
+
* Designed to be used standalone or composed into controllers.
|
|
16
|
+
*/
|
|
17
|
+
var AccessControl = class AccessControl {
|
|
18
|
+
tenantField;
|
|
19
|
+
idField;
|
|
20
|
+
_adapterMatchesFilter;
|
|
21
|
+
/** Patterns that indicate dangerous regex (nested quantifiers, excessive backtracking).
|
|
22
|
+
* Uses [^...] character classes instead of .+ to avoid backtracking in the detector itself. */
|
|
23
|
+
static DANGEROUS_REGEX = /(\{[0-9]+,\}[^{]*\{[0-9]+,\})|(\+[^+]*\+)|(\*[^*]*\*)|(\.\*){3,}|\\1/;
|
|
24
|
+
/** Forbidden paths that could lead to prototype pollution */
|
|
25
|
+
static FORBIDDEN_PATHS = [
|
|
26
|
+
"__proto__",
|
|
27
|
+
"constructor",
|
|
28
|
+
"prototype"
|
|
29
|
+
];
|
|
30
|
+
constructor(config) {
|
|
31
|
+
this.tenantField = config.tenantField;
|
|
32
|
+
this.idField = config.idField;
|
|
33
|
+
this._adapterMatchesFilter = config.matchesFilter;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Build filter for single-item operations (get/update/delete)
|
|
37
|
+
* Combines ID filter with policy/org filters for proper security enforcement
|
|
38
|
+
*/
|
|
39
|
+
buildIdFilter(id, req) {
|
|
40
|
+
const filter = { [this.idField]: id };
|
|
41
|
+
const arcContext = this._meta(req);
|
|
42
|
+
const policyFilters = arcContext?._policyFilters;
|
|
43
|
+
if (policyFilters) Object.assign(filter, policyFilters);
|
|
44
|
+
const scope = arcContext?._scope;
|
|
45
|
+
const orgId = scope ? getOrgId(scope) : void 0;
|
|
46
|
+
if (this.tenantField && orgId && !policyFilters?.[this.tenantField]) filter[this.tenantField] = orgId;
|
|
47
|
+
return filter;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Check if item matches policy filters (for get/update/delete operations)
|
|
51
|
+
* Validates that fetched item satisfies all policy constraints
|
|
52
|
+
*
|
|
53
|
+
* Delegates to adapter-provided matchesFilter if available (for SQL, etc.),
|
|
54
|
+
* otherwise falls back to built-in MongoDB-style matching.
|
|
55
|
+
*/
|
|
56
|
+
checkPolicyFilters(item, req) {
|
|
57
|
+
const policyFilters = this._meta(req)?._policyFilters;
|
|
58
|
+
if (!policyFilters) return true;
|
|
59
|
+
if (this._adapterMatchesFilter) return this._adapterMatchesFilter(item, policyFilters);
|
|
60
|
+
return this.defaultMatchesPolicyFilters(item, policyFilters);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Check org/tenant scope for a document — uses configurable tenantField.
|
|
64
|
+
*
|
|
65
|
+
* SECURITY: When org scope is active (orgId present), documents that are
|
|
66
|
+
* missing the tenant field are DENIED by default. This prevents legacy or
|
|
67
|
+
* unscoped records from leaking across tenants.
|
|
68
|
+
*/
|
|
69
|
+
checkOrgScope(item, arcContext) {
|
|
70
|
+
if (!this.tenantField) return true;
|
|
71
|
+
const scope = arcContext?._scope;
|
|
72
|
+
const orgId = scope ? getOrgId(scope) : void 0;
|
|
73
|
+
if (!item || !orgId) return true;
|
|
74
|
+
if (scope && isElevated(scope) && !orgId) return true;
|
|
75
|
+
const itemOrgId = item[this.tenantField];
|
|
76
|
+
if (!itemOrgId) return false;
|
|
77
|
+
return String(itemOrgId) === String(orgId);
|
|
78
|
+
}
|
|
79
|
+
/** Check ownership for update/delete (ownedByUser preset) */
|
|
80
|
+
checkOwnership(item, req) {
|
|
81
|
+
const ownershipCheck = this._meta(req)?._ownershipCheck;
|
|
82
|
+
if (!item || !ownershipCheck) return true;
|
|
83
|
+
const { field, userId } = ownershipCheck;
|
|
84
|
+
const itemOwnerId = item[field];
|
|
85
|
+
if (!itemOwnerId) return true;
|
|
86
|
+
return String(itemOwnerId) === String(userId);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Fetch a single document with full access control enforcement.
|
|
90
|
+
* Combines compound DB filter (ID + org + policy) with post-hoc fallback.
|
|
91
|
+
*
|
|
92
|
+
* Takes repository as a parameter to avoid coupling.
|
|
93
|
+
*
|
|
94
|
+
* Replaces the duplicated pattern in get/update/delete:
|
|
95
|
+
* buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
|
|
96
|
+
*/
|
|
97
|
+
async fetchWithAccessControl(id, req, repository, queryOptions) {
|
|
98
|
+
const compoundFilter = this.buildIdFilter(id, req);
|
|
99
|
+
const hasCompoundFilters = Object.keys(compoundFilter).length > 1;
|
|
100
|
+
try {
|
|
101
|
+
if (hasCompoundFilters && typeof repository.getOne === "function") return await repository.getOne(compoundFilter, queryOptions);
|
|
102
|
+
const item = await repository.getById(id, queryOptions);
|
|
103
|
+
if (!item) return null;
|
|
104
|
+
const arcContext = this._meta(req);
|
|
105
|
+
if (!this.checkOrgScope(item, arcContext) || !this.checkPolicyFilters(item, req)) return null;
|
|
106
|
+
return item;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof Error && error.message?.includes("not found")) return null;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Post-fetch access control validation for items fetched by non-ID queries
|
|
114
|
+
* (e.g., getBySlug, restore). Applies org scope, policy filters, and
|
|
115
|
+
* ownership checks — the same guarantees as fetchWithAccessControl.
|
|
116
|
+
*/
|
|
117
|
+
validateItemAccess(item, req) {
|
|
118
|
+
if (!item) return false;
|
|
119
|
+
const arcContext = this._meta(req);
|
|
120
|
+
if (!this.checkOrgScope(item, arcContext)) return false;
|
|
121
|
+
if (!this.checkPolicyFilters(item, req)) return false;
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
/** Extract typed Arc internal metadata from request */
|
|
125
|
+
_meta(req) {
|
|
126
|
+
return req.metadata;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Check if a value matches a MongoDB query operator
|
|
130
|
+
*/
|
|
131
|
+
matchesOperator(itemValue, operator, filterValue) {
|
|
132
|
+
const equalsByValue = (a, b) => String(a) === String(b);
|
|
133
|
+
switch (operator) {
|
|
134
|
+
case "$eq": return equalsByValue(itemValue, filterValue);
|
|
135
|
+
case "$ne": return !equalsByValue(itemValue, filterValue);
|
|
136
|
+
case "$gt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue > filterValue;
|
|
137
|
+
case "$gte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue >= filterValue;
|
|
138
|
+
case "$lt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue < filterValue;
|
|
139
|
+
case "$lte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue <= filterValue;
|
|
140
|
+
case "$in":
|
|
141
|
+
if (!Array.isArray(filterValue)) return false;
|
|
142
|
+
if (Array.isArray(itemValue)) return itemValue.some((v) => filterValue.some((fv) => equalsByValue(v, fv)));
|
|
143
|
+
return filterValue.some((fv) => equalsByValue(itemValue, fv));
|
|
144
|
+
case "$nin":
|
|
145
|
+
if (!Array.isArray(filterValue)) return false;
|
|
146
|
+
if (Array.isArray(itemValue)) return itemValue.every((v) => filterValue.every((fv) => !equalsByValue(v, fv)));
|
|
147
|
+
return filterValue.every((fv) => !equalsByValue(itemValue, fv));
|
|
148
|
+
case "$exists": return filterValue ? itemValue !== void 0 : itemValue === void 0;
|
|
149
|
+
case "$regex":
|
|
150
|
+
if (typeof itemValue === "string" && (typeof filterValue === "string" || filterValue instanceof RegExp)) return (typeof filterValue === "string" ? AccessControl.safeRegex(filterValue) : filterValue)?.test(itemValue) ?? false;
|
|
151
|
+
return false;
|
|
152
|
+
default: return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Check if item matches a single filter condition
|
|
157
|
+
* Supports nested paths (e.g., "owner.id", "metadata.status")
|
|
158
|
+
*/
|
|
159
|
+
matchesFilter(item, key, filterValue) {
|
|
160
|
+
const itemValue = key.includes(".") ? this.getNestedValue(item, key) : item[key];
|
|
161
|
+
if (filterValue && typeof filterValue === "object" && !Array.isArray(filterValue)) {
|
|
162
|
+
if (Object.keys(filterValue).some((op) => op.startsWith("$"))) {
|
|
163
|
+
for (const [operator, opValue] of Object.entries(filterValue)) if (!this.matchesOperator(itemValue, operator, opValue)) return false;
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (Array.isArray(itemValue)) return itemValue.some((v) => String(v) === String(filterValue));
|
|
168
|
+
return String(itemValue) === String(filterValue);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Built-in MongoDB-style policy filter matching.
|
|
172
|
+
* Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
|
|
173
|
+
*/
|
|
174
|
+
defaultMatchesPolicyFilters(item, policyFilters) {
|
|
175
|
+
if (policyFilters.$and && Array.isArray(policyFilters.$and)) {
|
|
176
|
+
if (!policyFilters.$and.every((condition) => {
|
|
177
|
+
return Object.entries(condition).every(([key, value]) => {
|
|
178
|
+
return this.matchesFilter(item, key, value);
|
|
179
|
+
});
|
|
180
|
+
})) return false;
|
|
181
|
+
}
|
|
182
|
+
if (policyFilters.$or && Array.isArray(policyFilters.$or)) {
|
|
183
|
+
if (!policyFilters.$or.some((condition) => {
|
|
184
|
+
return Object.entries(condition).every(([key, value]) => {
|
|
185
|
+
return this.matchesFilter(item, key, value);
|
|
186
|
+
});
|
|
187
|
+
})) return false;
|
|
188
|
+
}
|
|
189
|
+
for (const [key, value] of Object.entries(policyFilters)) {
|
|
190
|
+
if (key.startsWith("$")) continue;
|
|
191
|
+
if (!this.matchesFilter(item, key, value)) return false;
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Get nested value from object using dot notation (e.g., "owner.id")
|
|
197
|
+
* Security: Validates path against forbidden patterns to prevent prototype pollution
|
|
198
|
+
*/
|
|
199
|
+
getNestedValue(obj, path) {
|
|
200
|
+
if (AccessControl.FORBIDDEN_PATHS.some((p) => path.toLowerCase().includes(p))) return;
|
|
201
|
+
const keys = path.split(".");
|
|
202
|
+
let value = obj;
|
|
203
|
+
for (const key of keys) {
|
|
204
|
+
if (value == null) return void 0;
|
|
205
|
+
if (AccessControl.FORBIDDEN_PATHS.includes(key.toLowerCase())) return;
|
|
206
|
+
value = value[key];
|
|
207
|
+
}
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Create a safe RegExp from a string, guarding against ReDoS.
|
|
212
|
+
* Returns null if the pattern is invalid or dangerous.
|
|
213
|
+
*/
|
|
214
|
+
static safeRegex(pattern) {
|
|
215
|
+
if (pattern.length > 200) return null;
|
|
216
|
+
if (AccessControl.DANGEROUS_REGEX.test(pattern)) return null;
|
|
217
|
+
try {
|
|
218
|
+
return new RegExp(pattern);
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/core/BodySanitizer.ts
|
|
226
|
+
/**
|
|
227
|
+
* BodySanitizer - Composable body sanitization logic extracted from BaseController.
|
|
228
|
+
*
|
|
229
|
+
* Strips readonly fields, system-managed fields, and applies field-level
|
|
230
|
+
* write permissions from request bodies before create/update operations.
|
|
231
|
+
*
|
|
232
|
+
* Designed to be used standalone or composed into controllers.
|
|
233
|
+
*/
|
|
234
|
+
var BodySanitizer = class {
|
|
235
|
+
schemaOptions;
|
|
236
|
+
constructor(config) {
|
|
237
|
+
this.schemaOptions = config.schemaOptions;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Strip readonly and system-managed fields from request body.
|
|
241
|
+
* Prevents clients from overwriting _id, timestamps, __v, etc.
|
|
242
|
+
*
|
|
243
|
+
* Also applies field-level write permissions when the request has
|
|
244
|
+
* field permission metadata.
|
|
245
|
+
*/
|
|
246
|
+
sanitize(body, _operation, req, meta) {
|
|
247
|
+
let sanitized = { ...body };
|
|
248
|
+
for (const field of SYSTEM_FIELDS) delete sanitized[field];
|
|
249
|
+
const fieldRules = this.schemaOptions.fieldRules ?? {};
|
|
250
|
+
for (const [field, rules] of Object.entries(fieldRules)) if (rules.systemManaged || rules.readonly) delete sanitized[field];
|
|
251
|
+
if (req) {
|
|
252
|
+
const arcContext = meta ?? req.metadata;
|
|
253
|
+
const scope = arcContext?._scope ?? PUBLIC_SCOPE;
|
|
254
|
+
if (!isElevated(scope)) {
|
|
255
|
+
const fieldPerms = arcContext?.arc?.fields;
|
|
256
|
+
if (fieldPerms) {
|
|
257
|
+
const effectiveRoles = resolveEffectiveRoles(getUserRoles(req.user), isMember(scope) ? scope.orgRoles : []);
|
|
258
|
+
sanitized = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return sanitized;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/core/QueryResolver.ts
|
|
267
|
+
/**
|
|
268
|
+
* QueryResolver - Composable query resolution logic extracted from BaseController.
|
|
269
|
+
*
|
|
270
|
+
* Resolves a request into parsed query options (pagination, filters, sorting,
|
|
271
|
+
* select, populate) in a single pass. Applies org/tenant scope and policy
|
|
272
|
+
* filters from the request metadata.
|
|
273
|
+
*
|
|
274
|
+
* Designed to be used standalone or composed into controllers.
|
|
275
|
+
*/
|
|
276
|
+
const defaultParser = new ArcQueryParser();
|
|
277
|
+
function getDefaultQueryParser() {
|
|
278
|
+
return defaultParser;
|
|
279
|
+
}
|
|
280
|
+
var QueryResolver = class {
|
|
281
|
+
queryParser;
|
|
282
|
+
maxLimit;
|
|
283
|
+
defaultLimit;
|
|
284
|
+
defaultSort;
|
|
285
|
+
schemaOptions;
|
|
286
|
+
tenantField;
|
|
287
|
+
constructor(config = {}) {
|
|
288
|
+
this.queryParser = config.queryParser ?? getDefaultQueryParser();
|
|
289
|
+
this.maxLimit = config.maxLimit ?? 100;
|
|
290
|
+
this.defaultLimit = config.defaultLimit ?? 20;
|
|
291
|
+
this.defaultSort = config.defaultSort ?? "-createdAt";
|
|
292
|
+
this.schemaOptions = config.schemaOptions ?? {};
|
|
293
|
+
this.tenantField = config.tenantField !== void 0 ? config.tenantField : DEFAULT_TENANT_FIELD;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Resolve a request into parsed query options -- ONE parse per request.
|
|
297
|
+
* Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
|
|
298
|
+
*/
|
|
299
|
+
resolve(req, meta) {
|
|
300
|
+
const parsed = this.queryParser.parse(req.query);
|
|
301
|
+
const arcContext = meta ?? req.metadata;
|
|
302
|
+
delete parsed.filters?._policyFilters;
|
|
303
|
+
const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
|
|
304
|
+
const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
|
|
305
|
+
const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.defaultSort;
|
|
306
|
+
const rawSelect = parsed.select ?? req.query?.select;
|
|
307
|
+
const filters = { ...parsed.filters };
|
|
308
|
+
const policyFilters = arcContext?._policyFilters;
|
|
309
|
+
if (policyFilters) Object.assign(filters, policyFilters);
|
|
310
|
+
const scope = arcContext?._scope;
|
|
311
|
+
const orgId = scope ? getOrgId(scope) : void 0;
|
|
312
|
+
if (this.tenantField && orgId && !policyFilters?.[this.tenantField]) filters[this.tenantField] = orgId;
|
|
313
|
+
return {
|
|
314
|
+
page,
|
|
315
|
+
limit,
|
|
316
|
+
sort: sortString,
|
|
317
|
+
select: this.sanitizeSelectAny(rawSelect, this.schemaOptions),
|
|
318
|
+
populate: this.sanitizePopulate(parsed.populate, this.schemaOptions),
|
|
319
|
+
populateOptions: this.sanitizePopulateOptions(parsed.populateOptions, this.schemaOptions),
|
|
320
|
+
lookups: this.sanitizeLookups(parsed.lookups, this.schemaOptions),
|
|
321
|
+
filters,
|
|
322
|
+
search: parsed.search,
|
|
323
|
+
after: parsed.after,
|
|
324
|
+
user: req.user,
|
|
325
|
+
context: arcContext
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Sanitize select — preserves the input format (string, array, or object).
|
|
330
|
+
* This is critical for db-agnostic support: MongoKit returns object projections,
|
|
331
|
+
* Mongoose uses space-separated strings, SQL adapters may use arrays.
|
|
332
|
+
*/
|
|
333
|
+
sanitizeSelectAny(select, schemaOptions) {
|
|
334
|
+
if (!select) return void 0;
|
|
335
|
+
const blockedFields = this.getBlockedFields(schemaOptions);
|
|
336
|
+
if (blockedFields.length === 0) return select;
|
|
337
|
+
if (typeof select === "object" && !Array.isArray(select)) {
|
|
338
|
+
const sanitized = {};
|
|
339
|
+
for (const [field, val] of Object.entries(select)) if (!blockedFields.includes(field)) sanitized[field] = val;
|
|
340
|
+
return Object.keys(sanitized).length > 0 ? sanitized : void 0;
|
|
341
|
+
}
|
|
342
|
+
if (Array.isArray(select)) {
|
|
343
|
+
const sanitized = select.filter((f) => {
|
|
344
|
+
const fieldName = f.replace(/^-/, "");
|
|
345
|
+
return !blockedFields.includes(fieldName);
|
|
346
|
+
});
|
|
347
|
+
return sanitized.length > 0 ? sanitized : void 0;
|
|
348
|
+
}
|
|
349
|
+
const sanitized = select.split(/[\s,]+/).filter(Boolean).filter((f) => {
|
|
350
|
+
const fieldName = f.replace(/^-/, "");
|
|
351
|
+
return !blockedFields.includes(fieldName);
|
|
352
|
+
});
|
|
353
|
+
return sanitized.length > 0 ? sanitized.join(" ") : void 0;
|
|
354
|
+
}
|
|
355
|
+
/** Sanitize populate fields */
|
|
356
|
+
sanitizePopulate(populate, schemaOptions) {
|
|
357
|
+
if (!populate) return void 0;
|
|
358
|
+
const allowedPopulate = schemaOptions.query?.allowedPopulate;
|
|
359
|
+
const requested = typeof populate === "string" ? populate.split(",").map((p) => p.trim()) : Array.isArray(populate) ? populate.map(String) : [];
|
|
360
|
+
if (requested.length === 0) return void 0;
|
|
361
|
+
if (!allowedPopulate) return requested;
|
|
362
|
+
const sanitized = requested.filter((p) => allowedPopulate.includes(p));
|
|
363
|
+
return sanitized.length > 0 ? sanitized : void 0;
|
|
364
|
+
}
|
|
365
|
+
/** Sanitize advanced populate options against allowedPopulate */
|
|
366
|
+
sanitizePopulateOptions(options, schemaOptions) {
|
|
367
|
+
if (!options || options.length === 0) return void 0;
|
|
368
|
+
const allowedPopulate = schemaOptions.query?.allowedPopulate;
|
|
369
|
+
if (!allowedPopulate) return options;
|
|
370
|
+
const sanitized = options.filter((opt) => allowedPopulate.includes(opt.path));
|
|
371
|
+
return sanitized.length > 0 ? sanitized : void 0;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Sanitize lookup/join options.
|
|
375
|
+
* If schemaOptions.query.allowedLookups is set, only those collections are allowed.
|
|
376
|
+
* Validates lookup structure to prevent injection.
|
|
377
|
+
*/
|
|
378
|
+
sanitizeLookups(lookups, schemaOptions) {
|
|
379
|
+
if (!lookups || lookups.length === 0) return void 0;
|
|
380
|
+
const allowedLookups = schemaOptions.query?.allowedLookups;
|
|
381
|
+
const validFieldName = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
|
|
382
|
+
const sanitized = lookups.filter((lookup) => {
|
|
383
|
+
if (!lookup.from || !lookup.localField || !lookup.foreignField) return false;
|
|
384
|
+
if (!validFieldName.test(lookup.from)) return false;
|
|
385
|
+
if (!validFieldName.test(lookup.localField)) return false;
|
|
386
|
+
if (!validFieldName.test(lookup.foreignField)) return false;
|
|
387
|
+
if (allowedLookups && !allowedLookups.includes(lookup.from)) return false;
|
|
388
|
+
return true;
|
|
389
|
+
});
|
|
390
|
+
return sanitized.length > 0 ? sanitized : void 0;
|
|
391
|
+
}
|
|
392
|
+
/** Get blocked fields from schema options */
|
|
393
|
+
getBlockedFields(schemaOptions) {
|
|
394
|
+
const fieldRules = schemaOptions.fieldRules ?? {};
|
|
395
|
+
return Object.entries(fieldRules).filter(([, rules]) => rules.systemManaged || rules.hidden).map(([field]) => field);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/core/BaseController.ts
|
|
400
|
+
/**
|
|
401
|
+
* Framework-agnostic base controller implementing IController.
|
|
402
|
+
*
|
|
403
|
+
* Composes AccessControl, BodySanitizer, and QueryResolver for clean
|
|
404
|
+
* separation of concerns. CRUD methods delegate directly to these
|
|
405
|
+
* composed classes — no intermediate wrapper methods.
|
|
406
|
+
*
|
|
407
|
+
* @template TDoc - The document type
|
|
408
|
+
* @template TRepository - The repository type (defaults to RepositoryLike)
|
|
409
|
+
*/
|
|
410
|
+
var BaseController = class {
|
|
411
|
+
repository;
|
|
412
|
+
schemaOptions;
|
|
413
|
+
queryParser;
|
|
414
|
+
maxLimit;
|
|
415
|
+
defaultLimit;
|
|
416
|
+
defaultSort;
|
|
417
|
+
resourceName;
|
|
418
|
+
tenantField;
|
|
419
|
+
idField = "_id";
|
|
420
|
+
/** Composable access control (ID filtering, policy checks, org scope, ownership) */
|
|
421
|
+
accessControl;
|
|
422
|
+
/** Composable body sanitization (field permissions, system fields) */
|
|
423
|
+
bodySanitizer;
|
|
424
|
+
/** Composable query resolution (parsing, pagination, sort, select/populate) */
|
|
425
|
+
queryResolver;
|
|
426
|
+
_matchesFilter;
|
|
427
|
+
_presetFields = {};
|
|
428
|
+
_cacheConfig;
|
|
429
|
+
constructor(repository, options = {}) {
|
|
430
|
+
this.repository = repository;
|
|
431
|
+
this.schemaOptions = options.schemaOptions ?? {};
|
|
432
|
+
this.queryParser = options.queryParser ?? getDefaultQueryParser();
|
|
433
|
+
this.maxLimit = options.maxLimit ?? 100;
|
|
434
|
+
this.defaultLimit = options.defaultLimit ?? 20;
|
|
435
|
+
this.defaultSort = options.defaultSort ?? "-createdAt";
|
|
436
|
+
this.resourceName = options.resourceName;
|
|
437
|
+
this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
|
|
438
|
+
this.idField = options.idField ?? "_id";
|
|
439
|
+
this._matchesFilter = options.matchesFilter;
|
|
440
|
+
if (options.cache) this._cacheConfig = options.cache;
|
|
441
|
+
if (options.presetFields) this._presetFields = options.presetFields;
|
|
442
|
+
this.accessControl = new AccessControl({
|
|
443
|
+
tenantField: this.tenantField,
|
|
444
|
+
idField: this.idField,
|
|
445
|
+
matchesFilter: this._matchesFilter
|
|
446
|
+
});
|
|
447
|
+
this.bodySanitizer = new BodySanitizer({ schemaOptions: this.schemaOptions });
|
|
448
|
+
this.queryResolver = new QueryResolver({
|
|
449
|
+
queryParser: this.queryParser,
|
|
450
|
+
maxLimit: this.maxLimit,
|
|
451
|
+
defaultLimit: this.defaultLimit,
|
|
452
|
+
defaultSort: this.defaultSort,
|
|
453
|
+
schemaOptions: this.schemaOptions,
|
|
454
|
+
tenantField: this.tenantField
|
|
455
|
+
});
|
|
456
|
+
this.list = this.list.bind(this);
|
|
457
|
+
this.get = this.get.bind(this);
|
|
458
|
+
this.create = this.create.bind(this);
|
|
459
|
+
this.update = this.update.bind(this);
|
|
460
|
+
this.delete = this.delete.bind(this);
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Get the tenant field name if multi-tenant scoping is enabled.
|
|
464
|
+
* Returns `undefined` when `tenantField` is `false` (platform-universal mode).
|
|
465
|
+
*
|
|
466
|
+
* Use this in subclass overrides instead of accessing `this.tenantField` directly
|
|
467
|
+
* to avoid TypeScript indexing errors with `string | false`.
|
|
468
|
+
*/
|
|
469
|
+
getTenantField() {
|
|
470
|
+
return this.tenantField || void 0;
|
|
471
|
+
}
|
|
472
|
+
/** Extract typed Arc internal metadata from request */
|
|
473
|
+
meta(req) {
|
|
474
|
+
return req.metadata;
|
|
475
|
+
}
|
|
476
|
+
/** Get hook system from request context (instance-scoped) */
|
|
477
|
+
getHooks(req) {
|
|
478
|
+
return this.meta(req)?.arc?.hooks ?? null;
|
|
479
|
+
}
|
|
480
|
+
/** Resolve cache config for a specific operation, merging per-op overrides */
|
|
481
|
+
resolveCacheConfig(operation) {
|
|
482
|
+
const cfg = this._cacheConfig;
|
|
483
|
+
if (!cfg || cfg.disabled) return null;
|
|
484
|
+
const opOverride = cfg[operation];
|
|
485
|
+
return {
|
|
486
|
+
staleTime: opOverride?.staleTime ?? cfg.staleTime ?? 0,
|
|
487
|
+
gcTime: opOverride?.gcTime ?? cfg.gcTime ?? 60,
|
|
488
|
+
tags: cfg.tags
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Extract user/org IDs from request for cache key scoping.
|
|
493
|
+
* Only includes orgId when this resource uses tenant-scoped data (tenantField is set).
|
|
494
|
+
* Universal resources (tenantField: false) get shared cache keys to avoid fragmentation.
|
|
495
|
+
*/
|
|
496
|
+
cacheScope(req) {
|
|
497
|
+
return {
|
|
498
|
+
userId: getUserId(req.user),
|
|
499
|
+
orgId: this.tenantField ? (() => {
|
|
500
|
+
const scope = this.meta(req)?._scope;
|
|
501
|
+
return scope ? getOrgId(scope) : void 0;
|
|
502
|
+
})() : void 0
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
async list(req) {
|
|
506
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
507
|
+
const cacheConfig = this.resolveCacheConfig("list");
|
|
508
|
+
const qc = req.server?.queryCache;
|
|
509
|
+
if (cacheConfig && qc) {
|
|
510
|
+
const version = await qc.getResourceVersion(this.resourceName);
|
|
511
|
+
const { userId, orgId } = this.cacheScope(req);
|
|
512
|
+
const key = buildQueryKey(this.resourceName, "list", version, options, userId, orgId);
|
|
513
|
+
const { data, status } = await qc.get(key);
|
|
514
|
+
if (status === "fresh") return {
|
|
515
|
+
success: true,
|
|
516
|
+
data,
|
|
517
|
+
status: 200,
|
|
518
|
+
headers: { "x-cache": "HIT" }
|
|
519
|
+
};
|
|
520
|
+
if (status === "stale") {
|
|
521
|
+
setImmediate(() => {
|
|
522
|
+
this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
|
|
523
|
+
});
|
|
524
|
+
return {
|
|
525
|
+
success: true,
|
|
526
|
+
data,
|
|
527
|
+
status: 200,
|
|
528
|
+
headers: { "x-cache": "STALE" }
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
const result = await this.executeListQuery(options, req);
|
|
532
|
+
await qc.set(key, result, cacheConfig);
|
|
533
|
+
return {
|
|
534
|
+
success: true,
|
|
535
|
+
data: result,
|
|
536
|
+
status: 200,
|
|
537
|
+
headers: { "x-cache": "MISS" }
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
success: true,
|
|
542
|
+
data: await this.executeListQuery(options, req),
|
|
543
|
+
status: 200
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
/** Execute list query through hooks (extracted for cache revalidation) */
|
|
547
|
+
async executeListQuery(options, req) {
|
|
548
|
+
const hooks = this.getHooks(req);
|
|
549
|
+
const repoGetAll = async () => this.repository.getAll(options);
|
|
550
|
+
const result = hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "list", options, repoGetAll, {
|
|
551
|
+
user: req.user,
|
|
552
|
+
context: this.meta(req)
|
|
553
|
+
}) : await repoGetAll();
|
|
554
|
+
if (Array.isArray(result)) return {
|
|
555
|
+
docs: result,
|
|
556
|
+
page: 1,
|
|
557
|
+
limit: result.length,
|
|
558
|
+
total: result.length,
|
|
559
|
+
pages: 1,
|
|
560
|
+
hasNext: false,
|
|
561
|
+
hasPrev: false
|
|
562
|
+
};
|
|
563
|
+
return result;
|
|
564
|
+
}
|
|
565
|
+
async get(req) {
|
|
566
|
+
const id = req.params.id;
|
|
567
|
+
if (!id) return {
|
|
568
|
+
success: false,
|
|
569
|
+
error: "ID parameter is required",
|
|
570
|
+
status: 400
|
|
571
|
+
};
|
|
572
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
573
|
+
const cacheConfig = this.resolveCacheConfig("byId");
|
|
574
|
+
const qc = req.server?.queryCache;
|
|
575
|
+
if (cacheConfig && qc) {
|
|
576
|
+
const version = await qc.getResourceVersion(this.resourceName);
|
|
577
|
+
const { userId, orgId } = this.cacheScope(req);
|
|
578
|
+
const key = buildQueryKey(this.resourceName, "get", version, {
|
|
579
|
+
id,
|
|
580
|
+
...options
|
|
581
|
+
}, userId, orgId);
|
|
582
|
+
const { data, status } = await qc.get(key);
|
|
583
|
+
if (status === "fresh") return {
|
|
584
|
+
success: true,
|
|
585
|
+
data,
|
|
586
|
+
status: 200,
|
|
587
|
+
headers: { "x-cache": "HIT" }
|
|
588
|
+
};
|
|
589
|
+
if (status === "stale") {
|
|
590
|
+
setImmediate(() => {
|
|
591
|
+
this.executeGetQuery(id, options, req).then((fresh) => {
|
|
592
|
+
if (fresh) qc.set(key, fresh, cacheConfig);
|
|
593
|
+
}).catch(() => {});
|
|
594
|
+
});
|
|
595
|
+
return {
|
|
596
|
+
success: true,
|
|
597
|
+
data,
|
|
598
|
+
status: 200,
|
|
599
|
+
headers: { "x-cache": "STALE" }
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
const item = await this.executeGetQuery(id, options, req);
|
|
603
|
+
if (!item) return {
|
|
604
|
+
success: false,
|
|
605
|
+
error: "Resource not found",
|
|
606
|
+
status: 404
|
|
607
|
+
};
|
|
608
|
+
await qc.set(key, item, cacheConfig);
|
|
609
|
+
return {
|
|
610
|
+
success: true,
|
|
611
|
+
data: item,
|
|
612
|
+
status: 200,
|
|
613
|
+
headers: { "x-cache": "MISS" }
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
const item = await this.executeGetQuery(id, options, req);
|
|
618
|
+
if (!item) return {
|
|
619
|
+
success: false,
|
|
620
|
+
error: "Resource not found",
|
|
621
|
+
status: 404
|
|
622
|
+
};
|
|
623
|
+
return {
|
|
624
|
+
success: true,
|
|
625
|
+
data: item,
|
|
626
|
+
status: 200
|
|
627
|
+
};
|
|
628
|
+
} catch (error) {
|
|
629
|
+
if (error instanceof Error && error.message?.includes("not found")) return {
|
|
630
|
+
success: false,
|
|
631
|
+
error: "Resource not found",
|
|
632
|
+
status: 404
|
|
633
|
+
};
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/** Execute get query through hooks (extracted for cache revalidation) */
|
|
638
|
+
async executeGetQuery(id, options, req) {
|
|
639
|
+
const hooks = this.getHooks(req);
|
|
640
|
+
const fetchItem = async () => this.accessControl.fetchWithAccessControl(id, req, this.repository, options);
|
|
641
|
+
return (hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "read", null, fetchItem, {
|
|
642
|
+
user: req.user,
|
|
643
|
+
context: this.meta(req)
|
|
644
|
+
}) : await fetchItem()) ?? null;
|
|
645
|
+
}
|
|
646
|
+
async create(req) {
|
|
647
|
+
const arcContext = this.meta(req);
|
|
648
|
+
const data = this.bodySanitizer.sanitize(req.body ?? {}, "create", req, arcContext);
|
|
649
|
+
const scope = arcContext?._scope;
|
|
650
|
+
const createOrgId = scope ? getOrgId(scope) : void 0;
|
|
651
|
+
if (this.tenantField && createOrgId) data[this.tenantField] = createOrgId;
|
|
652
|
+
const userId = getUserId(req.user);
|
|
653
|
+
if (userId) data.createdBy = userId;
|
|
654
|
+
const hooks = this.getHooks(req);
|
|
655
|
+
const user = req.user;
|
|
656
|
+
let processedData = data;
|
|
657
|
+
if (hooks && this.resourceName) try {
|
|
658
|
+
processedData = await hooks.executeBefore(this.resourceName, "create", data, {
|
|
659
|
+
user,
|
|
660
|
+
context: arcContext
|
|
661
|
+
});
|
|
662
|
+
} catch (err) {
|
|
663
|
+
return {
|
|
664
|
+
success: false,
|
|
665
|
+
error: "Hook execution failed",
|
|
666
|
+
details: {
|
|
667
|
+
code: "BEFORE_CREATE_HOOK_ERROR",
|
|
668
|
+
message: err.message
|
|
669
|
+
},
|
|
670
|
+
status: 400
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
const repoCreate = async () => this.repository.create(processedData, {
|
|
674
|
+
user,
|
|
675
|
+
context: arcContext
|
|
676
|
+
});
|
|
677
|
+
let item;
|
|
678
|
+
if (hooks && this.resourceName) {
|
|
679
|
+
item = await hooks.executeAround(this.resourceName, "create", processedData, repoCreate, {
|
|
680
|
+
user,
|
|
681
|
+
context: arcContext
|
|
682
|
+
});
|
|
683
|
+
await hooks.executeAfter(this.resourceName, "create", item, {
|
|
684
|
+
user,
|
|
685
|
+
context: arcContext
|
|
686
|
+
});
|
|
687
|
+
} else item = await repoCreate();
|
|
688
|
+
return {
|
|
689
|
+
success: true,
|
|
690
|
+
data: item,
|
|
691
|
+
status: 201,
|
|
692
|
+
meta: { message: "Created successfully" }
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
async update(req) {
|
|
696
|
+
const id = req.params.id;
|
|
697
|
+
if (!id) return {
|
|
698
|
+
success: false,
|
|
699
|
+
error: "ID parameter is required",
|
|
700
|
+
status: 400
|
|
701
|
+
};
|
|
702
|
+
const arcContext = this.meta(req);
|
|
703
|
+
const data = this.bodySanitizer.sanitize(req.body ?? {}, "update", req, arcContext);
|
|
704
|
+
const user = req.user;
|
|
705
|
+
const userId = getUserId(user);
|
|
706
|
+
if (userId) data.updatedBy = userId;
|
|
707
|
+
const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
|
|
708
|
+
if (!existing) return {
|
|
709
|
+
success: false,
|
|
710
|
+
error: "Resource not found",
|
|
711
|
+
status: 404
|
|
712
|
+
};
|
|
713
|
+
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
714
|
+
success: false,
|
|
715
|
+
error: "You do not have permission to modify this resource",
|
|
716
|
+
details: { code: "OWNERSHIP_DENIED" },
|
|
717
|
+
status: 403
|
|
718
|
+
};
|
|
719
|
+
const hooks = this.getHooks(req);
|
|
720
|
+
let processedData = data;
|
|
721
|
+
if (hooks && this.resourceName) try {
|
|
722
|
+
processedData = await hooks.executeBefore(this.resourceName, "update", data, {
|
|
723
|
+
user,
|
|
724
|
+
context: arcContext,
|
|
725
|
+
meta: {
|
|
726
|
+
id,
|
|
727
|
+
existing
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
} catch (err) {
|
|
731
|
+
return {
|
|
732
|
+
success: false,
|
|
733
|
+
error: "Hook execution failed",
|
|
734
|
+
details: {
|
|
735
|
+
code: "BEFORE_UPDATE_HOOK_ERROR",
|
|
736
|
+
message: err.message
|
|
737
|
+
},
|
|
738
|
+
status: 400
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
const repoUpdate = async () => this.repository.update(id, processedData, {
|
|
742
|
+
user,
|
|
743
|
+
context: arcContext
|
|
744
|
+
});
|
|
745
|
+
let item;
|
|
746
|
+
if (hooks && this.resourceName) {
|
|
747
|
+
item = await hooks.executeAround(this.resourceName, "update", processedData, repoUpdate, {
|
|
748
|
+
user,
|
|
749
|
+
context: arcContext,
|
|
750
|
+
meta: {
|
|
751
|
+
id,
|
|
752
|
+
existing
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
if (item) await hooks.executeAfter(this.resourceName, "update", item, {
|
|
756
|
+
user,
|
|
757
|
+
context: arcContext,
|
|
758
|
+
meta: {
|
|
759
|
+
id,
|
|
760
|
+
existing
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
} else item = await repoUpdate();
|
|
764
|
+
if (!item) return {
|
|
765
|
+
success: false,
|
|
766
|
+
error: "Resource not found",
|
|
767
|
+
status: 404
|
|
768
|
+
};
|
|
769
|
+
return {
|
|
770
|
+
success: true,
|
|
771
|
+
data: item,
|
|
772
|
+
status: 200,
|
|
773
|
+
meta: { message: "Updated successfully" }
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
async delete(req) {
|
|
777
|
+
const id = req.params.id;
|
|
778
|
+
if (!id) return {
|
|
779
|
+
success: false,
|
|
780
|
+
error: "ID parameter is required",
|
|
781
|
+
status: 400
|
|
782
|
+
};
|
|
783
|
+
const arcContext = this.meta(req);
|
|
784
|
+
const user = req.user;
|
|
785
|
+
const existing = await this.accessControl.fetchWithAccessControl(id, req, this.repository);
|
|
786
|
+
if (!existing) return {
|
|
787
|
+
success: false,
|
|
788
|
+
error: "Resource not found",
|
|
789
|
+
status: 404
|
|
790
|
+
};
|
|
791
|
+
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
792
|
+
success: false,
|
|
793
|
+
error: "You do not have permission to delete this resource",
|
|
794
|
+
details: { code: "OWNERSHIP_DENIED" },
|
|
795
|
+
status: 403
|
|
796
|
+
};
|
|
797
|
+
const hooks = this.getHooks(req);
|
|
798
|
+
if (hooks && this.resourceName) try {
|
|
799
|
+
await hooks.executeBefore(this.resourceName, "delete", existing, {
|
|
800
|
+
user,
|
|
801
|
+
context: arcContext,
|
|
802
|
+
meta: { id }
|
|
803
|
+
});
|
|
804
|
+
} catch (err) {
|
|
805
|
+
return {
|
|
806
|
+
success: false,
|
|
807
|
+
error: "Hook execution failed",
|
|
808
|
+
details: {
|
|
809
|
+
code: "BEFORE_DELETE_HOOK_ERROR",
|
|
810
|
+
message: err.message
|
|
811
|
+
},
|
|
812
|
+
status: 400
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
const repoDelete = async () => this.repository.delete(id, {
|
|
816
|
+
user,
|
|
817
|
+
context: arcContext
|
|
818
|
+
});
|
|
819
|
+
let result;
|
|
820
|
+
if (hooks && this.resourceName) result = await hooks.executeAround(this.resourceName, "delete", existing, repoDelete, {
|
|
821
|
+
user,
|
|
822
|
+
context: arcContext,
|
|
823
|
+
meta: { id }
|
|
824
|
+
});
|
|
825
|
+
else result = await repoDelete();
|
|
826
|
+
if (!(typeof result === "object" && result !== null ? result.success : result)) return {
|
|
827
|
+
success: false,
|
|
828
|
+
error: "Resource not found",
|
|
829
|
+
status: 404
|
|
830
|
+
};
|
|
831
|
+
if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "delete", existing, {
|
|
832
|
+
user,
|
|
833
|
+
context: arcContext,
|
|
834
|
+
meta: { id }
|
|
835
|
+
});
|
|
836
|
+
const deleteResult = typeof result === "object" && result !== null ? result : {};
|
|
837
|
+
return {
|
|
838
|
+
success: true,
|
|
839
|
+
data: {
|
|
840
|
+
message: deleteResult.message || "Deleted successfully",
|
|
841
|
+
...id ? { id } : {},
|
|
842
|
+
...deleteResult.soft ? { soft: true } : {}
|
|
843
|
+
},
|
|
844
|
+
status: 200
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
async getBySlug(req) {
|
|
848
|
+
const repo = this.repository;
|
|
849
|
+
if (!repo.getBySlug) return {
|
|
850
|
+
success: false,
|
|
851
|
+
error: "Slug lookup not implemented",
|
|
852
|
+
status: 501
|
|
853
|
+
};
|
|
854
|
+
const slugField = this._presetFields.slugField ?? "slug";
|
|
855
|
+
const slug = req.params[slugField] ?? req.params.slug;
|
|
856
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
857
|
+
const item = await repo.getBySlug(slug, options);
|
|
858
|
+
if (!this.accessControl.validateItemAccess(item, req)) return {
|
|
859
|
+
success: false,
|
|
860
|
+
error: "Resource not found",
|
|
861
|
+
status: 404
|
|
862
|
+
};
|
|
863
|
+
return {
|
|
864
|
+
success: true,
|
|
865
|
+
data: item,
|
|
866
|
+
status: 200
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
async getDeleted(req) {
|
|
870
|
+
const repo = this.repository;
|
|
871
|
+
if (!repo.getDeleted) return {
|
|
872
|
+
success: false,
|
|
873
|
+
error: "Soft delete not implemented",
|
|
874
|
+
status: 501
|
|
875
|
+
};
|
|
876
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
877
|
+
const result = await repo.getDeleted(options);
|
|
878
|
+
if (Array.isArray(result)) return {
|
|
879
|
+
success: true,
|
|
880
|
+
data: {
|
|
881
|
+
docs: result,
|
|
882
|
+
page: 1,
|
|
883
|
+
limit: result.length,
|
|
884
|
+
total: result.length,
|
|
885
|
+
pages: 1,
|
|
886
|
+
hasNext: false,
|
|
887
|
+
hasPrev: false
|
|
888
|
+
},
|
|
889
|
+
status: 200
|
|
890
|
+
};
|
|
891
|
+
return {
|
|
892
|
+
success: true,
|
|
893
|
+
data: result,
|
|
894
|
+
status: 200
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
async restore(req) {
|
|
898
|
+
const repo = this.repository;
|
|
899
|
+
if (!repo.restore) return {
|
|
900
|
+
success: false,
|
|
901
|
+
error: "Restore not implemented",
|
|
902
|
+
status: 501
|
|
903
|
+
};
|
|
904
|
+
const id = req.params.id;
|
|
905
|
+
if (!id) return {
|
|
906
|
+
success: false,
|
|
907
|
+
error: "ID parameter is required",
|
|
908
|
+
status: 400
|
|
909
|
+
};
|
|
910
|
+
const existing = await this.accessControl.fetchWithAccessControl(id, req, repo);
|
|
911
|
+
if (!existing) return {
|
|
912
|
+
success: false,
|
|
913
|
+
error: "Resource not found",
|
|
914
|
+
status: 404
|
|
915
|
+
};
|
|
916
|
+
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
917
|
+
success: false,
|
|
918
|
+
error: "You do not have permission to restore this resource",
|
|
919
|
+
details: { code: "OWNERSHIP_DENIED" },
|
|
920
|
+
status: 403
|
|
921
|
+
};
|
|
922
|
+
const item = await repo.restore(id);
|
|
923
|
+
if (!item) return {
|
|
924
|
+
success: false,
|
|
925
|
+
error: "Resource not found",
|
|
926
|
+
status: 404
|
|
927
|
+
};
|
|
928
|
+
return {
|
|
929
|
+
success: true,
|
|
930
|
+
data: item,
|
|
931
|
+
status: 200,
|
|
932
|
+
meta: { message: "Restored successfully" }
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
async getTree(req) {
|
|
936
|
+
const repo = this.repository;
|
|
937
|
+
if (!repo.getTree) return {
|
|
938
|
+
success: false,
|
|
939
|
+
error: "Tree structure not implemented",
|
|
940
|
+
status: 501
|
|
941
|
+
};
|
|
942
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
943
|
+
return {
|
|
944
|
+
success: true,
|
|
945
|
+
data: await repo.getTree(options),
|
|
946
|
+
status: 200
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
async getChildren(req) {
|
|
950
|
+
const repo = this.repository;
|
|
951
|
+
if (!repo.getChildren) return {
|
|
952
|
+
success: false,
|
|
953
|
+
error: "Tree structure not implemented",
|
|
954
|
+
status: 501
|
|
955
|
+
};
|
|
956
|
+
const parentField = this._presetFields.parentField ?? "parent";
|
|
957
|
+
const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
|
|
958
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
959
|
+
return {
|
|
960
|
+
success: true,
|
|
961
|
+
data: await repo.getChildren(parentId, options),
|
|
962
|
+
status: 200
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
async bulkCreate(req) {
|
|
966
|
+
const repo = this.repository;
|
|
967
|
+
if (!repo.createMany) return {
|
|
968
|
+
success: false,
|
|
969
|
+
error: "Repository does not support createMany",
|
|
970
|
+
status: 501
|
|
971
|
+
};
|
|
972
|
+
const items = req.body?.items;
|
|
973
|
+
if (!items || items.length === 0) return {
|
|
974
|
+
success: false,
|
|
975
|
+
error: "Bulk create requires a non-empty items array",
|
|
976
|
+
status: 400
|
|
977
|
+
};
|
|
978
|
+
const created = await repo.createMany(items);
|
|
979
|
+
return {
|
|
980
|
+
success: true,
|
|
981
|
+
data: created,
|
|
982
|
+
status: 201,
|
|
983
|
+
meta: { count: created.length }
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
async bulkUpdate(req) {
|
|
987
|
+
const repo = this.repository;
|
|
988
|
+
if (!repo.updateMany) return {
|
|
989
|
+
success: false,
|
|
990
|
+
error: "Repository does not support updateMany",
|
|
991
|
+
status: 501
|
|
992
|
+
};
|
|
993
|
+
const body = req.body;
|
|
994
|
+
if (!body.filter || Object.keys(body.filter).length === 0) return {
|
|
995
|
+
success: false,
|
|
996
|
+
error: "Bulk update requires a non-empty filter",
|
|
997
|
+
status: 400
|
|
998
|
+
};
|
|
999
|
+
if (!body.data || Object.keys(body.data).length === 0) return {
|
|
1000
|
+
success: false,
|
|
1001
|
+
error: "Bulk update requires non-empty data",
|
|
1002
|
+
status: 400
|
|
1003
|
+
};
|
|
1004
|
+
return {
|
|
1005
|
+
success: true,
|
|
1006
|
+
data: await repo.updateMany(body.filter, body.data),
|
|
1007
|
+
status: 200
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
async bulkDelete(req) {
|
|
1011
|
+
const repo = this.repository;
|
|
1012
|
+
if (!repo.deleteMany) return {
|
|
1013
|
+
success: false,
|
|
1014
|
+
error: "Repository does not support deleteMany",
|
|
1015
|
+
status: 501
|
|
1016
|
+
};
|
|
1017
|
+
const body = req.body;
|
|
1018
|
+
if (!body.filter || Object.keys(body.filter).length === 0) return {
|
|
1019
|
+
success: false,
|
|
1020
|
+
error: "Bulk delete requires a non-empty filter",
|
|
1021
|
+
status: 400
|
|
1022
|
+
};
|
|
1023
|
+
return {
|
|
1024
|
+
success: true,
|
|
1025
|
+
data: await repo.deleteMany(body.filter),
|
|
1026
|
+
status: 200
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
//#endregion
|
|
1031
|
+
export { AccessControl as i, QueryResolver as n, BodySanitizer as r, BaseController as t };
|