@classytic/arc 2.10.3 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/{BaseController-CbKKIflT.mjs → BaseController-JNV08qOT.mjs} +595 -537
- package/dist/{queryCachePlugin-BKbWjgDG.d.mts → QueryCache-DOBNHBE0.d.mts} +2 -32
- package/dist/actionPermissions-C8YYU92K.mjs +22 -0
- package/dist/adapters/index.d.mts +2 -2
- package/dist/adapters/index.mjs +1 -1
- package/dist/{adapters-BXY4i-hw.mjs → adapters-D0tT2Tyo.mjs} +54 -0
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +15 -17
- package/dist/auth/index.d.mts +4 -4
- package/dist/auth/index.mjs +3 -3
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi-BBRVhjQN.mjs → betterAuthOpenApi-DwxtK3uG.mjs} +1 -1
- package/dist/cache/index.d.mts +3 -2
- package/dist/cache/index.mjs +3 -3
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +37 -27
- package/dist/cli/commands/init.mjs +47 -34
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/context/index.d.mts +58 -0
- package/dist/context/index.mjs +2 -0
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -3
- package/dist/core-DXdSSFW-.mjs +1037 -0
- package/dist/createActionRouter-BwaSM0No.mjs +166 -0
- package/dist/{createApp-BuvPma24.mjs → createApp-DvNYEhpb.mjs} +118 -36
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +1 -1
- package/dist/{elevation-C7hgL_aI.mjs → elevation-DOFoxoDs.mjs} +1 -1
- package/dist/errorHandler-Co3lnVmJ.d.mts +114 -0
- package/dist/{eventPlugin-DCUjuiQT.mjs → eventPlugin--5HIkdPU.mjs} +1 -1
- package/dist/{eventPlugin-CxWgpd6K.d.mts → eventPlugin-CUNjYYRY.d.mts} +1 -1
- package/dist/events/index.d.mts +4 -4
- package/dist/events/index.mjs +69 -51
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +2 -2
- package/dist/{fields-Lo1VUDpt.d.mts → fields-C8Y0XLAu.d.mts} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +3 -3
- package/dist/idempotency/index.mjs +38 -27
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-ChIw3776.d.mts → index-BYCqHCVu.d.mts} +4 -4
- package/dist/{index-Cl0uoKd5.d.mts → index-Cm0vUrr_.d.mts} +2100 -1688
- package/dist/{index-DStwgFUK.d.mts → index-DAushRTt.d.mts} +29 -10
- package/dist/index-DsJ1MNfC.d.mts +1179 -0
- package/dist/{index-8qw4y6ff.d.mts → index-t8pLpPFW.d.mts} +13 -10
- package/dist/index.d.mts +7 -251
- package/dist/index.mjs +8 -128
- package/dist/integrations/event-gateway.d.mts +2 -2
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/streamline.d.mts +46 -5
- package/dist/integrations/streamline.mjs +50 -21
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +2 -154
- package/dist/integrations/websocket.mjs +292 -224
- package/dist/{keys-qcD-TVJl.mjs → keys-CARyUjiR.mjs} +2 -0
- package/dist/{loadResources-BAzJItAJ.mjs → loadResources-YNwKHvRA.mjs} +3 -1
- package/dist/logger/index.d.mts +81 -0
- package/dist/{logger-DLg8-Ueg.mjs → logger/index.mjs} +1 -6
- package/dist/middleware/index.d.mts +109 -0
- package/dist/middleware/index.mjs +70 -0
- package/dist/multipartBody-CvTR1Un6.mjs +123 -0
- package/dist/{openapi-B5F8AddX.mjs → openapi-C0L9ar7m.mjs} +9 -7
- package/dist/org/index.d.mts +2 -2
- package/dist/permissions/index.d.mts +2 -2
- package/dist/permissions/index.mjs +1 -3
- package/dist/{permissions-Dk6mshja.mjs → permissions-B4vU9L0Q.mjs} +220 -2
- package/dist/pipe-DVoIheVC.mjs +62 -0
- package/dist/pipeline/index.d.mts +62 -0
- package/dist/pipeline/index.mjs +53 -0
- package/dist/plugins/index.d.mts +25 -5
- package/dist/plugins/index.mjs +10 -10
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +42 -24
- package/dist/presets/filesUpload.d.mts +4 -4
- package/dist/presets/filesUpload.mjs +255 -1
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +2 -2
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +48 -8
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-fLJVXdVn.mjs → presets-k604Lj99.mjs} +1 -1
- package/dist/queryCachePlugin-BUXBSm4F.d.mts +34 -0
- package/dist/{queryCachePlugin-DQCEfJis.mjs → queryCachePlugin-Bq6bO6vc.mjs} +3 -3
- package/dist/{redis-DqyeggCa.d.mts → redis-Cm1gnRDf.d.mts} +1 -1
- package/dist/{redis-stream-CakIQmwR.d.mts → redis-stream-CM8TXTix.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{requestContext-xHIKedG6.mjs → requestContext-CfRkaxwf.mjs} +1 -1
- package/dist/{resourceToTools-BElv3xPT.mjs → resourceToTools--okX6QBr.mjs} +534 -415
- package/dist/routerShared-DeESFp4a.mjs +515 -0
- package/dist/schemaIR-BlG9bY7v.mjs +137 -0
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +1 -1
- package/dist/{sse-yBCgOLGu.mjs → sse-V7aXc3bW.mjs} +1 -1
- package/dist/{store-helpers-ZCSMJJAX.mjs → store-helpers-BhrzxvyQ.mjs} +4 -0
- package/dist/testing/index.d.mts +367 -711
- package/dist/testing/index.mjs +646 -1434
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/{tracing-65B51Dw3.d.mts → tracing-DokiEsuz.d.mts} +9 -4
- package/dist/types/index.d.mts +5 -5
- package/dist/types/index.mjs +1 -3
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-Co8k3NyS.d.mts → types-CgikqKAj.d.mts} +133 -21
- package/dist/{types-Btdda02s.d.mts → types-D9NqiYIw.d.mts} +1 -1
- package/dist/utils/index.d.mts +2 -898
- package/dist/utils/index.mjs +4 -5
- package/dist/utils-D3Yxnrwr.mjs +1639 -0
- package/dist/versioning-M9lNLhO8.d.mts +117 -0
- package/dist/websocket-CyJ1VIFI.d.mts +186 -0
- package/package.json +26 -8
- package/skills/arc/SKILL.md +124 -39
- package/skills/arc/references/testing.md +212 -183
- package/dist/applyPermissionResult-QhV1Pa-g.mjs +0 -37
- package/dist/core-CcR01lup.mjs +0 -1411
- package/dist/createActionRouter-Bp_5c_2b.mjs +0 -249
- package/dist/errorHandler-DRQ3EqfL.d.mts +0 -218
- package/dist/errors-CCSsMpXE.d.mts +0 -140
- package/dist/fields-bxkeltzz.mjs +0 -126
- package/dist/filesUpload-t21LS-py.mjs +0 -377
- package/dist/queryParser-DBqBB6AC.mjs +0 -352
- package/dist/types-Csi3FLfq.mjs +0 -27
- package/dist/utils-B2fNOD_i.mjs +0 -929
- /package/dist/{EventTransport-CUw5NNWe.d.mts → EventTransport-CfVEGaEl.d.mts} +0 -0
- /package/dist/{HookSystem-BNYKnrXF.mjs → HookSystem-CGsMd6oK.mjs} +0 -0
- /package/dist/{ResourceRegistry-BPd6NQDm.mjs → ResourceRegistry-DkAeAuTX.mjs} +0 -0
- /package/dist/{caching-CBpK_SCM.mjs → caching-CheW3m-S.mjs} +0 -0
- /package/dist/{elevation-C5SwtkAn.d.mts → elevation-s5ykdNHr.d.mts} +0 -0
- /package/dist/{errorHandler-Bb49BvPD.mjs → errorHandler-BQm8ZxTK.mjs} +0 -0
- /package/dist/{externalPaths-BQ8QijNH.d.mts → externalPaths-Bapitwvd.d.mts} +0 -0
- /package/dist/{interface-CSbZdv_3.d.mts → interface-CkkWm5uR.d.mts} +0 -0
- /package/dist/{interface-D218ikEo.d.mts → interface-Da0r7Lna.d.mts} +0 -0
- /package/dist/{memory-B5Amv9A1.mjs → memory-DikHSvWa.mjs} +0 -0
- /package/dist/{metrics-DuhiSEZI.mjs → metrics-Csh4nsvv.mjs} +0 -0
- /package/dist/{pluralize-A0tWEl1K.mjs → pluralize-BneOJkpi.mjs} +0 -0
- /package/dist/{registry-B3lRFBWo.mjs → registry-D63ee7fl.mjs} +0 -0
- /package/dist/{replyHelpers-CXtJDAZ0.mjs → replyHelpers-ByllIXXV.mjs} +0 -0
- /package/dist/{schemaConverter-BxFDdtXu.mjs → schemaConverter-B0oKLuqI.mjs} +0 -0
- /package/dist/{sessionManager-BkzVU8h2.d.mts → sessionManager-D-oNWHz3.d.mts} +0 -0
- /package/dist/{storage-CVk_SEn2.d.mts → storage-BwGQXUpd.d.mts} +0 -0
- /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
- /package/dist/{types-BD85MlEK.d.mts → types-tgR4Pt8F.d.mts} +0 -0
- /package/dist/{versioning-C2U_bLY0.mjs → versioning-CGPjkqAg.mjs} +0 -0
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-BhY1OHoH.mjs";
|
|
2
|
+
import { arcLog } from "./logger/index.mjs";
|
|
2
3
|
import { _ as isElevated, n as PUBLIC_SCOPE, o as getOrgId, v as isMember } from "./types-AOD8fxIw.mjs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { M as simpleEqualityMatcher, j as getUserId, k as ArcQueryParser } from "./utils-D3Yxnrwr.mjs";
|
|
5
|
+
import { t as buildQueryKey } from "./keys-CARyUjiR.mjs";
|
|
6
|
+
import { M as applyFieldWritePermissions, P as resolveEffectiveRoles } from "./permissions-B4vU9L0Q.mjs";
|
|
6
7
|
import { t as getUserRoles } from "./types-DV9WDfeg.mjs";
|
|
7
8
|
import { r as ForbiddenError } from "./errors-D5c-5BJL.mjs";
|
|
8
|
-
import { t as ArcQueryParser } from "./queryParser-DBqBB6AC.mjs";
|
|
9
9
|
//#region src/core/AccessControl.ts
|
|
10
|
-
|
|
10
|
+
const log = arcLog("access-control");
|
|
11
|
+
var AccessControl = class {
|
|
11
12
|
tenantField;
|
|
12
13
|
idField;
|
|
13
14
|
_adapterMatchesFilter;
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
];
|
|
15
|
+
/**
|
|
16
|
+
* One-shot latch for the "adapter didn't supply matchesFilter, in-memory
|
|
17
|
+
* policy-filter re-check is skipped" warning. The primary fetch path
|
|
18
|
+
* (`getOne(compoundFilter)`) already applied filters at the DB layer;
|
|
19
|
+
* this warn only fires when `validateItemAccess` runs and the adapter
|
|
20
|
+
* hasn't provided a native matcher for the post-hoc re-check.
|
|
21
|
+
*/
|
|
22
|
+
_warnedNoMatcher = false;
|
|
23
23
|
constructor(config) {
|
|
24
24
|
this.tenantField = config.tenantField;
|
|
25
25
|
this.idField = config.idField;
|
|
@@ -40,17 +40,54 @@ var AccessControl = class AccessControl {
|
|
|
40
40
|
return filter;
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
-
* Check if item matches
|
|
44
|
-
*
|
|
43
|
+
* Check if a post-fetch item matches the request's `_policyFilters`.
|
|
44
|
+
*
|
|
45
|
+
* **When this runs:** only on paths where the primary fetch path did NOT
|
|
46
|
+
* apply policy filters at the DB layer — notably `validateItemAccess`
|
|
47
|
+
* (used by `getBySlug` and cache revalidation). The main `fetchDetailed`
|
|
48
|
+
* path builds a compound filter (`buildIdFilter`) and passes it to
|
|
49
|
+
* `repository.getOne(compoundFilter)`, so the DB has already enforced
|
|
50
|
+
* the filter and an in-memory re-check would be redundant.
|
|
45
51
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
52
|
+
* **Evaluation order (fail-closed):**
|
|
53
|
+
* 1. No `_policyFilters` set → `true` (nothing to enforce).
|
|
54
|
+
* 2. Adapter supplied `matchesFilter` → delegate to it verbatim. Adapters
|
|
55
|
+
* are expected to handle every filter shape the host emits
|
|
56
|
+
* (mongokit/sqlitekit evaluate at the DB layer; Prisma/custom engines
|
|
57
|
+
* can wrap their own predicate engine).
|
|
58
|
+
* 3. No adapter matcher → fall back to `simpleEqualityMatcher` — arc's
|
|
59
|
+
* built-in flat-key equality helper. This is defense-in-depth for the
|
|
60
|
+
* common case: arc's own permission helpers emit flat filters
|
|
61
|
+
* (`{userId: …}`, `{organizationId: …}`), which this matcher evaluates
|
|
62
|
+
* correctly. Operator-shaped filters (`$in`, `$ne`, `$regex`, `$and`,
|
|
63
|
+
* `$or`) are **rejected** (the matcher returns `false`) — fail-closed
|
|
64
|
+
* rather than fail-open. A one-shot warn flags the gap so adapter
|
|
65
|
+
* authors can wire a richer matcher.
|
|
66
|
+
*
|
|
67
|
+
* Arc deliberately does NOT ship a full MongoDB-syntax matcher:
|
|
68
|
+
* re-implementing Mongo in JS was dead code for mongokit users (the DB
|
|
69
|
+
* did it) and silently wrong for non-Mongo adapters. The flat-equality
|
|
70
|
+
* fallback is small (~20 LOC), correct in both dialects, and closes the
|
|
71
|
+
* previous `getBySlug`-style policy-bypass path.
|
|
48
72
|
*/
|
|
49
73
|
checkPolicyFilters(item, req) {
|
|
50
74
|
const policyFilters = this._meta(req)?._policyFilters;
|
|
51
|
-
if (!policyFilters) return true;
|
|
75
|
+
if (!policyFilters || Object.keys(policyFilters).length === 0) return true;
|
|
52
76
|
if (this._adapterMatchesFilter) return this._adapterMatchesFilter(item, policyFilters);
|
|
53
|
-
|
|
77
|
+
if (Object.values(policyFilters).some((v) => v !== null && typeof v === "object" && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype && Object.keys(v).some((k) => k.startsWith("$")))) this._warnNoMatcher(policyFilters);
|
|
78
|
+
return simpleEqualityMatcher(item, policyFilters);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Emit a one-shot warn when policy filters contain operators (`$in`,
|
|
82
|
+
* `$ne`, `$regex`, etc.) and no `DataAdapter.matchesFilter` is wired —
|
|
83
|
+
* arc's flat-equality fallback fail-closes on operators, so the host
|
|
84
|
+
* sees 404s on docs that should match. Latched on `_warnedNoMatcher`
|
|
85
|
+
* so subsequent requests stay quiet.
|
|
86
|
+
*/
|
|
87
|
+
_warnNoMatcher(policyFilters) {
|
|
88
|
+
if (this._warnedNoMatcher) return;
|
|
89
|
+
this._warnedNoMatcher = true;
|
|
90
|
+
log.warn("`_policyFilters` contains operator-shaped entries (e.g. `$in`, `$ne`, `$regex`) but `DataAdapter.matchesFilter` is not set. Arc's flat-equality fallback cannot evaluate operators and will reject these items on non-compound fetches (`validateItemAccess`, `getBySlug`, cache revalidation). Wire up `matchesFilter` on your adapter — use `matchFilter` from `@classytic/repo-core/filter` for IR-based adapters, or your DB's native predicate engine.", { policyFilterKeys: Object.keys(policyFilters) });
|
|
54
91
|
}
|
|
55
92
|
/**
|
|
56
93
|
* Check org/tenant scope for a document — uses configurable tenantField.
|
|
@@ -64,7 +101,6 @@ var AccessControl = class AccessControl {
|
|
|
64
101
|
const scope = arcContext?._scope;
|
|
65
102
|
const orgId = scope ? getOrgId(scope) : void 0;
|
|
66
103
|
if (!item || !orgId) return true;
|
|
67
|
-
if (scope && isElevated(scope) && !orgId) return true;
|
|
68
104
|
const itemOrgId = item[this.tenantField];
|
|
69
105
|
if (!itemOrgId) return false;
|
|
70
106
|
return String(itemOrgId) === String(orgId);
|
|
@@ -122,7 +158,25 @@ var AccessControl = class AccessControl {
|
|
|
122
158
|
};
|
|
123
159
|
if (hasCompoundFilters) {
|
|
124
160
|
const idOnly = { [this.idField]: id };
|
|
125
|
-
const
|
|
161
|
+
const rawGetOne = repository.getOne.bind(repository);
|
|
162
|
+
let rawDoc = null;
|
|
163
|
+
try {
|
|
164
|
+
rawDoc = await rawGetOne(idOnly);
|
|
165
|
+
} catch (unscopedErr) {
|
|
166
|
+
if (translateStatus404(unscopedErr)) return {
|
|
167
|
+
doc: null,
|
|
168
|
+
reason: "NOT_FOUND"
|
|
169
|
+
};
|
|
170
|
+
try {
|
|
171
|
+
rawDoc = await rawGetOne(idOnly, queryOptions);
|
|
172
|
+
} catch (scopedErr) {
|
|
173
|
+
if (translateStatus404(scopedErr)) return {
|
|
174
|
+
doc: null,
|
|
175
|
+
reason: "NOT_FOUND"
|
|
176
|
+
};
|
|
177
|
+
throw scopedErr;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
126
180
|
if (rawDoc) {
|
|
127
181
|
const arcContext = this._meta(req);
|
|
128
182
|
if (!this.checkOrgScope(rawDoc, arcContext)) return {
|
|
@@ -183,101 +237,6 @@ var AccessControl = class AccessControl {
|
|
|
183
237
|
_meta(req) {
|
|
184
238
|
return req.metadata;
|
|
185
239
|
}
|
|
186
|
-
/**
|
|
187
|
-
* Check if a value matches a MongoDB query operator
|
|
188
|
-
*/
|
|
189
|
-
matchesOperator(itemValue, operator, filterValue) {
|
|
190
|
-
const equalsByValue = (a, b) => String(a) === String(b);
|
|
191
|
-
switch (operator) {
|
|
192
|
-
case "$eq": return equalsByValue(itemValue, filterValue);
|
|
193
|
-
case "$ne": return !equalsByValue(itemValue, filterValue);
|
|
194
|
-
case "$gt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue > filterValue;
|
|
195
|
-
case "$gte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue >= filterValue;
|
|
196
|
-
case "$lt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue < filterValue;
|
|
197
|
-
case "$lte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue <= filterValue;
|
|
198
|
-
case "$in":
|
|
199
|
-
if (!Array.isArray(filterValue)) return false;
|
|
200
|
-
if (Array.isArray(itemValue)) return itemValue.some((v) => filterValue.some((fv) => equalsByValue(v, fv)));
|
|
201
|
-
return filterValue.some((fv) => equalsByValue(itemValue, fv));
|
|
202
|
-
case "$nin":
|
|
203
|
-
if (!Array.isArray(filterValue)) return false;
|
|
204
|
-
if (Array.isArray(itemValue)) return itemValue.every((v) => filterValue.every((fv) => !equalsByValue(v, fv)));
|
|
205
|
-
return filterValue.every((fv) => !equalsByValue(itemValue, fv));
|
|
206
|
-
case "$exists": return filterValue ? itemValue !== void 0 : itemValue === void 0;
|
|
207
|
-
case "$regex":
|
|
208
|
-
if (typeof itemValue === "string" && (typeof filterValue === "string" || filterValue instanceof RegExp)) return (typeof filterValue === "string" ? AccessControl.safeRegex(filterValue) : filterValue)?.test(itemValue) ?? false;
|
|
209
|
-
return false;
|
|
210
|
-
default: return false;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
/**
|
|
214
|
-
* Check if item matches a single filter condition
|
|
215
|
-
* Supports nested paths (e.g., "owner.id", "metadata.status")
|
|
216
|
-
*/
|
|
217
|
-
matchesFilter(item, key, filterValue) {
|
|
218
|
-
const itemValue = key.includes(".") ? this.getNestedValue(item, key) : item[key];
|
|
219
|
-
if (filterValue && typeof filterValue === "object" && !Array.isArray(filterValue)) {
|
|
220
|
-
if (Object.keys(filterValue).some((op) => op.startsWith("$"))) {
|
|
221
|
-
for (const [operator, opValue] of Object.entries(filterValue)) if (!this.matchesOperator(itemValue, operator, opValue)) return false;
|
|
222
|
-
return true;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
if (Array.isArray(itemValue)) return itemValue.some((v) => String(v) === String(filterValue));
|
|
226
|
-
return String(itemValue) === String(filterValue);
|
|
227
|
-
}
|
|
228
|
-
/**
|
|
229
|
-
* Built-in MongoDB-style policy filter matching.
|
|
230
|
-
* Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
|
|
231
|
-
*/
|
|
232
|
-
defaultMatchesPolicyFilters(item, policyFilters) {
|
|
233
|
-
if (policyFilters.$and && Array.isArray(policyFilters.$and)) {
|
|
234
|
-
if (!policyFilters.$and.every((condition) => {
|
|
235
|
-
return Object.entries(condition).every(([key, value]) => {
|
|
236
|
-
return this.matchesFilter(item, key, value);
|
|
237
|
-
});
|
|
238
|
-
})) return false;
|
|
239
|
-
}
|
|
240
|
-
if (policyFilters.$or && Array.isArray(policyFilters.$or)) {
|
|
241
|
-
if (!policyFilters.$or.some((condition) => {
|
|
242
|
-
return Object.entries(condition).every(([key, value]) => {
|
|
243
|
-
return this.matchesFilter(item, key, value);
|
|
244
|
-
});
|
|
245
|
-
})) return false;
|
|
246
|
-
}
|
|
247
|
-
for (const [key, value] of Object.entries(policyFilters)) {
|
|
248
|
-
if (key.startsWith("$")) continue;
|
|
249
|
-
if (!this.matchesFilter(item, key, value)) return false;
|
|
250
|
-
}
|
|
251
|
-
return true;
|
|
252
|
-
}
|
|
253
|
-
/**
|
|
254
|
-
* Get nested value from object using dot notation (e.g., "owner.id")
|
|
255
|
-
* Security: Validates path against forbidden patterns to prevent prototype pollution
|
|
256
|
-
*/
|
|
257
|
-
getNestedValue(obj, path) {
|
|
258
|
-
if (AccessControl.FORBIDDEN_PATHS.some((p) => path.toLowerCase().includes(p))) return;
|
|
259
|
-
const keys = path.split(".");
|
|
260
|
-
let value = obj;
|
|
261
|
-
for (const key of keys) {
|
|
262
|
-
if (value == null) return void 0;
|
|
263
|
-
if (AccessControl.FORBIDDEN_PATHS.includes(key.toLowerCase())) return;
|
|
264
|
-
value = value[key];
|
|
265
|
-
}
|
|
266
|
-
return value;
|
|
267
|
-
}
|
|
268
|
-
/**
|
|
269
|
-
* Create a safe RegExp from a string, guarding against ReDoS.
|
|
270
|
-
* Returns null if the pattern is invalid or dangerous.
|
|
271
|
-
*/
|
|
272
|
-
static safeRegex(pattern) {
|
|
273
|
-
if (pattern.length > 200) return null;
|
|
274
|
-
if (AccessControl.DANGEROUS_REGEX.test(pattern)) return null;
|
|
275
|
-
try {
|
|
276
|
-
return new RegExp(pattern);
|
|
277
|
-
} catch {
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
240
|
};
|
|
282
241
|
var BodySanitizer = class {
|
|
283
242
|
schemaOptions;
|
|
@@ -296,10 +255,13 @@ var BodySanitizer = class {
|
|
|
296
255
|
sanitize(body, _operation, req, meta) {
|
|
297
256
|
let sanitized = { ...body };
|
|
298
257
|
for (const field of SYSTEM_FIELDS) delete sanitized[field];
|
|
258
|
+
const scopeForRules = req ? (meta ?? req.metadata)?._scope ?? PUBLIC_SCOPE : void 0;
|
|
259
|
+
const scopeIsElevated = scopeForRules ? isElevated(scopeForRules) : false;
|
|
299
260
|
const fieldRules = this.schemaOptions.fieldRules ?? {};
|
|
300
261
|
for (const [field, rules] of Object.entries(fieldRules)) {
|
|
301
|
-
|
|
302
|
-
if (
|
|
262
|
+
const bypass = Boolean(rules.preserveForElevated) && scopeIsElevated;
|
|
263
|
+
if ((rules.systemManaged || rules.readonly) && !bypass) delete sanitized[field];
|
|
264
|
+
if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate) && !bypass) delete sanitized[field];
|
|
303
265
|
}
|
|
304
266
|
if (req) {
|
|
305
267
|
const arcContext = meta ?? req.metadata;
|
|
@@ -336,6 +298,7 @@ var QueryResolver = class {
|
|
|
336
298
|
queryParser;
|
|
337
299
|
maxLimit;
|
|
338
300
|
defaultLimit;
|
|
301
|
+
/** `undefined` means "no default sort" (caller passed `false`). */
|
|
339
302
|
defaultSort;
|
|
340
303
|
schemaOptions;
|
|
341
304
|
tenantField;
|
|
@@ -343,7 +306,7 @@ var QueryResolver = class {
|
|
|
343
306
|
this.queryParser = config.queryParser ?? getDefaultQueryParser();
|
|
344
307
|
this.maxLimit = config.maxLimit ?? 100;
|
|
345
308
|
this.defaultLimit = config.defaultLimit ?? 20;
|
|
346
|
-
this.defaultSort = config.defaultSort ?? "-createdAt";
|
|
309
|
+
this.defaultSort = config.defaultSort === false ? void 0 : config.defaultSort ?? "-createdAt";
|
|
347
310
|
this.schemaOptions = config.schemaOptions ?? {};
|
|
348
311
|
this.tenantField = config.tenantField !== void 0 ? config.tenantField : DEFAULT_TENANT_FIELD;
|
|
349
312
|
}
|
|
@@ -451,29 +414,29 @@ var QueryResolver = class {
|
|
|
451
414
|
}
|
|
452
415
|
};
|
|
453
416
|
//#endregion
|
|
454
|
-
//#region src/core/
|
|
417
|
+
//#region src/core/BaseCrudController.ts
|
|
455
418
|
/**
|
|
456
419
|
* Portable "run on next tick" scheduler. `setImmediate` is Node-only — not
|
|
457
|
-
* available in Bun workers, Deno, Cloudflare Workers, or edge runtimes.
|
|
458
|
-
* back to queueMicrotask (universal) when setImmediate is absent.
|
|
420
|
+
* available in Bun workers, Deno, Cloudflare Workers, or edge runtimes.
|
|
459
421
|
*/
|
|
460
422
|
const scheduleBackground = typeof setImmediate === "function" ? (cb) => void setImmediate(cb) : (cb) => queueMicrotask(cb);
|
|
461
423
|
/**
|
|
462
|
-
* Framework-agnostic
|
|
424
|
+
* Framework-agnostic CRUD controller implementing IController.
|
|
463
425
|
*
|
|
464
|
-
* Composes AccessControl, BodySanitizer, and QueryResolver
|
|
465
|
-
*
|
|
466
|
-
*
|
|
426
|
+
* Composes AccessControl, BodySanitizer, and QueryResolver. All shared
|
|
427
|
+
* state and helpers are `protected` so the preset mixins (SoftDelete,
|
|
428
|
+
* Tree, Slug, Bulk) can extend cleanly.
|
|
467
429
|
*
|
|
468
|
-
* @template TDoc - The document type
|
|
469
|
-
* @template TRepository - The repository type (defaults to RepositoryLike)
|
|
430
|
+
* @template TDoc - The document type.
|
|
431
|
+
* @template TRepository - The repository type (defaults to RepositoryLike).
|
|
470
432
|
*/
|
|
471
|
-
var
|
|
433
|
+
var BaseCrudController = class {
|
|
472
434
|
repository;
|
|
473
435
|
schemaOptions;
|
|
474
436
|
queryParser;
|
|
475
437
|
maxLimit;
|
|
476
438
|
defaultLimit;
|
|
439
|
+
/** `undefined` means "no default sort" (caller passed `false`). */
|
|
477
440
|
defaultSort;
|
|
478
441
|
resourceName;
|
|
479
442
|
tenantField;
|
|
@@ -482,7 +445,14 @@ var BaseController = class {
|
|
|
482
445
|
accessControl;
|
|
483
446
|
/** Composable body sanitization (field permissions, system fields) */
|
|
484
447
|
bodySanitizer;
|
|
485
|
-
/**
|
|
448
|
+
/**
|
|
449
|
+
* Composable query resolution (parsing, pagination, sort, select/populate).
|
|
450
|
+
*
|
|
451
|
+
* Not `readonly` — `setQueryParser()` rebuilds this resolver to swap in a
|
|
452
|
+
* different parser (e.g. mongokit's `QueryParser`). `defineResource` calls
|
|
453
|
+
* it automatically when a resource supplies both `controller` and
|
|
454
|
+
* `queryParser`.
|
|
455
|
+
*/
|
|
486
456
|
queryResolver;
|
|
487
457
|
_matchesFilter;
|
|
488
458
|
_presetFields = {};
|
|
@@ -493,7 +463,7 @@ var BaseController = class {
|
|
|
493
463
|
this.queryParser = options.queryParser ?? getDefaultQueryParser();
|
|
494
464
|
this.maxLimit = options.maxLimit ?? 100;
|
|
495
465
|
this.defaultLimit = options.defaultLimit ?? 20;
|
|
496
|
-
this.defaultSort = options.defaultSort ?? "-createdAt";
|
|
466
|
+
this.defaultSort = options.defaultSort === false ? void 0 : options.defaultSort ?? "-createdAt";
|
|
497
467
|
this.resourceName = options.resourceName;
|
|
498
468
|
this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
|
|
499
469
|
this.idField = options.idField ?? repository?.idField ?? "_id";
|
|
@@ -513,7 +483,7 @@ var BaseController = class {
|
|
|
513
483
|
queryParser: this.queryParser,
|
|
514
484
|
maxLimit: this.maxLimit,
|
|
515
485
|
defaultLimit: this.defaultLimit,
|
|
516
|
-
defaultSort:
|
|
486
|
+
defaultSort: options.defaultSort,
|
|
517
487
|
schemaOptions: this.schemaOptions,
|
|
518
488
|
tenantField: this.tenantField
|
|
519
489
|
});
|
|
@@ -524,15 +494,63 @@ var BaseController = class {
|
|
|
524
494
|
this.delete = this.delete.bind(this);
|
|
525
495
|
}
|
|
526
496
|
/**
|
|
527
|
-
*
|
|
528
|
-
*
|
|
497
|
+
* Swap the controller's query parser. Rebuilds the internal `QueryResolver`
|
|
498
|
+
* with the new parser while preserving every other config.
|
|
529
499
|
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
500
|
+
* Closes the v2.10.9 gap where `defineResource({ controller, queryParser })`
|
|
501
|
+
* forwarded the parser only to auto-constructed controllers; user-supplied
|
|
502
|
+
* controllers kept their default `ArcQueryParser`. `defineResource` calls
|
|
503
|
+
* this via duck-typing when both `controller` and `queryParser` are
|
|
504
|
+
* supplied — controllers that don't implement `setQueryParser` are left
|
|
505
|
+
* untouched.
|
|
506
|
+
*
|
|
507
|
+
* Idempotent + safe to call repeatedly. Does NOT touch `maxLimit` or
|
|
508
|
+
* `defaultLimit` — those are construction-time decisions.
|
|
509
|
+
*/
|
|
510
|
+
setQueryParser(queryParser) {
|
|
511
|
+
this.queryParser = queryParser;
|
|
512
|
+
this.queryResolver = new QueryResolver({
|
|
513
|
+
queryParser: this.queryParser,
|
|
514
|
+
maxLimit: this.maxLimit,
|
|
515
|
+
defaultLimit: this.defaultLimit,
|
|
516
|
+
defaultSort: this.defaultSort,
|
|
517
|
+
schemaOptions: this.schemaOptions,
|
|
518
|
+
tenantField: this.tenantField
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Get the tenant field name if multi-tenant scoping is enabled.
|
|
523
|
+
* Returns `undefined` when `tenantField` is `false`.
|
|
532
524
|
*/
|
|
533
525
|
getTenantField() {
|
|
534
526
|
return this.tenantField || void 0;
|
|
535
527
|
}
|
|
528
|
+
/**
|
|
529
|
+
* Build top-level tenant options to thread into the repository call.
|
|
530
|
+
*
|
|
531
|
+
* Plugin-scoped repos (mongokit's `multiTenantPlugin`) read tenant scope
|
|
532
|
+
* from the TOP of the operation context — `context.organizationId`, not
|
|
533
|
+
* `context.data.organizationId`. Without this stamping, a tenant-scoped
|
|
534
|
+
* repo throws "Missing 'organizationId' in context" even when arc has
|
|
535
|
+
* injected the tenant into the request body.
|
|
536
|
+
*
|
|
537
|
+
* Returns `{ [tenantField]: orgId }` for tenant-scoped + org-carrying
|
|
538
|
+
* requests, `{}` otherwise. Merges multi-field tenancy from
|
|
539
|
+
* `_tenantFields` (populated by `multiTenantPreset`).
|
|
540
|
+
*/
|
|
541
|
+
tenantRepoOptions(req) {
|
|
542
|
+
const out = {};
|
|
543
|
+
if (this.tenantField) {
|
|
544
|
+
const scope = this.meta(req)?._scope;
|
|
545
|
+
const orgId = scope ? getOrgId(scope) : void 0;
|
|
546
|
+
if (orgId) out[this.tenantField] = orgId;
|
|
547
|
+
}
|
|
548
|
+
const presetFields = req._tenantFields;
|
|
549
|
+
if (presetFields && typeof presetFields === "object") {
|
|
550
|
+
for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
|
|
551
|
+
}
|
|
552
|
+
return out;
|
|
553
|
+
}
|
|
536
554
|
/** Extract typed Arc internal metadata from request */
|
|
537
555
|
meta(req) {
|
|
538
556
|
return req.metadata;
|
|
@@ -542,20 +560,15 @@ var BaseController = class {
|
|
|
542
560
|
return this.meta(req)?.arc?.hooks ?? null;
|
|
543
561
|
}
|
|
544
562
|
/**
|
|
545
|
-
* Resolve the repository primary key for mutation calls
|
|
546
|
-
*
|
|
547
|
-
* When the resource declares a custom `idField` (e.g. `slug`, `jobId`, UUID),
|
|
548
|
-
* the default behavior is to translate the route id → the fetched doc's `_id`
|
|
549
|
-
* because most Mongo repositories key their mutation methods off `_id`.
|
|
563
|
+
* Resolve the repository primary key for mutation calls.
|
|
550
564
|
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
* route id through unchanged and skip the translation.
|
|
565
|
+
* When the resource declares a custom `idField` (slug, jobId, UUID), the
|
|
566
|
+
* default behavior is to translate the route id → the fetched doc's `_id`
|
|
567
|
+
* because most Mongo repositories key mutation methods off `_id`.
|
|
555
568
|
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
569
|
+
* Exception: if the repo exposes a matching `idField` property (e.g.
|
|
570
|
+
* MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
|
|
571
|
+
* repo handles lookup itself — pass the route id through unchanged.
|
|
559
572
|
*/
|
|
560
573
|
resolveRepoId(id, existing) {
|
|
561
574
|
if (this.idField === "_id") return id;
|
|
@@ -567,11 +580,8 @@ var BaseController = class {
|
|
|
567
580
|
/**
|
|
568
581
|
* Centralized 404 response builder. Maps the denial reason from
|
|
569
582
|
* `fetchDetailed()` into a structured `details.code` so consumers can
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
* Error messages are intentionally vague in the `error` field (don't
|
|
574
|
-
* leak whether the doc exists) — the detail is in `details.code` only.
|
|
583
|
+
* distinguish "doc doesn't exist" from "doc filtered by policy/org scope"
|
|
584
|
+
* without parsing error strings.
|
|
575
585
|
*/
|
|
576
586
|
notFoundResponse(reason = "NOT_FOUND") {
|
|
577
587
|
const code = reason ?? "NOT_FOUND";
|
|
@@ -599,8 +609,8 @@ var BaseController = class {
|
|
|
599
609
|
}
|
|
600
610
|
/**
|
|
601
611
|
* Extract user/org IDs from request for cache key scoping.
|
|
602
|
-
* Only includes orgId when
|
|
603
|
-
* Universal resources (tenantField: false) get shared cache keys
|
|
612
|
+
* Only includes orgId when the resource uses tenant-scoped data (tenantField is set).
|
|
613
|
+
* Universal resources (tenantField: false) get shared cache keys.
|
|
604
614
|
*/
|
|
605
615
|
cacheScope(req) {
|
|
606
616
|
return {
|
|
@@ -655,7 +665,11 @@ var BaseController = class {
|
|
|
655
665
|
/** Execute list query through hooks (extracted for cache revalidation) */
|
|
656
666
|
async executeListQuery(options, req) {
|
|
657
667
|
const hooks = this.getHooks(req);
|
|
658
|
-
const
|
|
668
|
+
const getAllParams = {
|
|
669
|
+
...options,
|
|
670
|
+
...this.tenantRepoOptions(req)
|
|
671
|
+
};
|
|
672
|
+
const repoGetAll = async () => this.repository.getAll(getAllParams);
|
|
659
673
|
return hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "list", options, repoGetAll, {
|
|
660
674
|
user: req.user,
|
|
661
675
|
context: this.meta(req)
|
|
@@ -668,7 +682,10 @@ var BaseController = class {
|
|
|
668
682
|
error: "ID parameter is required",
|
|
669
683
|
status: 400
|
|
670
684
|
};
|
|
671
|
-
const options =
|
|
685
|
+
const options = {
|
|
686
|
+
...this.queryResolver.resolve(req, this.meta(req)),
|
|
687
|
+
...this.tenantRepoOptions(req)
|
|
688
|
+
};
|
|
672
689
|
const cacheConfig = this.resolveCacheConfig("byId");
|
|
673
690
|
const qc = req.server?.queryCache;
|
|
674
691
|
if (cacheConfig && qc) {
|
|
@@ -764,7 +781,8 @@ var BaseController = class {
|
|
|
764
781
|
}
|
|
765
782
|
const repoCreate = async () => this.repository.create(processedData, {
|
|
766
783
|
user,
|
|
767
|
-
context: arcContext
|
|
784
|
+
context: arcContext,
|
|
785
|
+
...this.tenantRepoOptions(req)
|
|
768
786
|
});
|
|
769
787
|
let item;
|
|
770
788
|
if (hooks && this.resourceName) {
|
|
@@ -796,7 +814,7 @@ var BaseController = class {
|
|
|
796
814
|
const user = req.user;
|
|
797
815
|
const userId = getUserId(user);
|
|
798
816
|
if (userId) data.updatedBy = userId;
|
|
799
|
-
const { doc: existing, reason: updateReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
|
|
817
|
+
const { doc: existing, reason: updateReason } = await this.accessControl.fetchDetailed(id, req, this.repository, this.tenantRepoOptions(req));
|
|
800
818
|
if (!existing) return this.notFoundResponse(updateReason);
|
|
801
819
|
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
802
820
|
success: false,
|
|
@@ -829,7 +847,8 @@ var BaseController = class {
|
|
|
829
847
|
}
|
|
830
848
|
const repoUpdate = async () => this.repository.update(repoId, processedData, {
|
|
831
849
|
user,
|
|
832
|
-
context: arcContext
|
|
850
|
+
context: arcContext,
|
|
851
|
+
...this.tenantRepoOptions(req)
|
|
833
852
|
});
|
|
834
853
|
let item;
|
|
835
854
|
if (hooks && this.resourceName) {
|
|
@@ -867,7 +886,7 @@ var BaseController = class {
|
|
|
867
886
|
};
|
|
868
887
|
const arcContext = this.meta(req);
|
|
869
888
|
const user = req.user;
|
|
870
|
-
const { doc: existing, reason: deleteReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
|
|
889
|
+
const { doc: existing, reason: deleteReason } = await this.accessControl.fetchDetailed(id, req, this.repository, this.tenantRepoOptions(req));
|
|
871
890
|
if (!existing) return this.notFoundResponse(deleteReason);
|
|
872
891
|
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
873
892
|
success: false,
|
|
@@ -898,6 +917,7 @@ var BaseController = class {
|
|
|
898
917
|
const repoDelete = async () => this.repository.delete(repoId, {
|
|
899
918
|
user,
|
|
900
919
|
context: arcContext,
|
|
920
|
+
...this.tenantRepoOptions(req),
|
|
901
921
|
...deleteMode ? { mode: deleteMode } : {}
|
|
902
922
|
});
|
|
903
923
|
let result;
|
|
@@ -930,404 +950,442 @@ var BaseController = class {
|
|
|
930
950
|
status: 200
|
|
931
951
|
};
|
|
932
952
|
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
953
|
+
};
|
|
954
|
+
//#endregion
|
|
955
|
+
//#region src/core/mixins/bulk.ts
|
|
956
|
+
/**
|
|
957
|
+
* BulkMixin — `bulkCreate` / `bulkUpdate` / `bulkDelete` endpoints.
|
|
958
|
+
*
|
|
959
|
+
* Security-critical: every bulk operation routes through the same write
|
|
960
|
+
* permissions, tenant scope, and policy filters as the single-doc paths.
|
|
961
|
+
* Cross-tenant writes are blocked at the controller layer regardless of
|
|
962
|
+
* what middleware the host has wired up.
|
|
963
|
+
*
|
|
964
|
+
* Per-doc lifecycle hooks (`before:create` / `after:create` / etc.) do
|
|
965
|
+
* NOT fire for bulk operations — use the single-doc path if you need
|
|
966
|
+
* them, or subscribe to the bulk lifecycle event from the events plugin.
|
|
967
|
+
*
|
|
968
|
+
* @example
|
|
969
|
+
* ```ts
|
|
970
|
+
* class OrderController extends BulkMixin(BaseCrudController<Order>) {}
|
|
971
|
+
* ```
|
|
972
|
+
*/
|
|
973
|
+
function BulkMixin(Base) {
|
|
974
|
+
return class BulkController extends Base {
|
|
975
|
+
async bulkCreate(req) {
|
|
976
|
+
const repo = this.repository;
|
|
977
|
+
if (!repo.createMany) return {
|
|
978
|
+
success: false,
|
|
979
|
+
error: "Repository does not support createMany",
|
|
980
|
+
status: 501
|
|
944
981
|
};
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
success: false,
|
|
948
|
-
error: "Slug lookup not implemented — repository needs getBySlug() or getOne()",
|
|
949
|
-
status: 501
|
|
950
|
-
};
|
|
951
|
-
if (!this.accessControl.validateItemAccess(item, req)) return this.notFoundResponse(item ? "POLICY_FILTERED" : "NOT_FOUND");
|
|
952
|
-
return {
|
|
953
|
-
success: true,
|
|
954
|
-
data: item,
|
|
955
|
-
status: 200
|
|
956
|
-
};
|
|
957
|
-
}
|
|
958
|
-
async getDeleted(req) {
|
|
959
|
-
const repo = this.repository;
|
|
960
|
-
if (!repo.getDeleted) return {
|
|
961
|
-
success: false,
|
|
962
|
-
error: "Soft delete not implemented",
|
|
963
|
-
status: 501
|
|
964
|
-
};
|
|
965
|
-
const parsed = this.queryResolver.resolve(req, this.meta(req));
|
|
966
|
-
return {
|
|
967
|
-
success: true,
|
|
968
|
-
data: await repo.getDeleted(parsed, parsed),
|
|
969
|
-
status: 200
|
|
970
|
-
};
|
|
971
|
-
}
|
|
972
|
-
async restore(req) {
|
|
973
|
-
const repo = this.repository;
|
|
974
|
-
if (!repo.restore) return {
|
|
975
|
-
success: false,
|
|
976
|
-
error: "Restore not implemented",
|
|
977
|
-
status: 501
|
|
978
|
-
};
|
|
979
|
-
const id = req.params.id;
|
|
980
|
-
if (!id) return {
|
|
981
|
-
success: false,
|
|
982
|
-
error: "ID parameter is required",
|
|
983
|
-
status: 400
|
|
984
|
-
};
|
|
985
|
-
const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
|
|
986
|
-
if (!existing) return this.notFoundResponse("NOT_FOUND");
|
|
987
|
-
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
988
|
-
success: false,
|
|
989
|
-
error: "You do not have permission to restore this resource",
|
|
990
|
-
details: { code: "OWNERSHIP_DENIED" },
|
|
991
|
-
status: 403
|
|
992
|
-
};
|
|
993
|
-
const arcContext = this.meta(req);
|
|
994
|
-
const user = req.user;
|
|
995
|
-
const repoId = this.resolveRepoId(id, existing);
|
|
996
|
-
const hooks = this.getHooks(req);
|
|
997
|
-
if (hooks && this.resourceName) try {
|
|
998
|
-
await hooks.executeBefore(this.resourceName, "restore", existing, {
|
|
999
|
-
user,
|
|
1000
|
-
context: arcContext,
|
|
1001
|
-
meta: { id }
|
|
1002
|
-
});
|
|
1003
|
-
} catch (err) {
|
|
1004
|
-
return {
|
|
982
|
+
const rawItems = req.body?.items;
|
|
983
|
+
if (!Array.isArray(rawItems) || rawItems.length === 0) return {
|
|
1005
984
|
success: false,
|
|
1006
|
-
error: "
|
|
1007
|
-
details: {
|
|
1008
|
-
code: "BEFORE_RESTORE_HOOK_ERROR",
|
|
1009
|
-
message: err.message
|
|
1010
|
-
},
|
|
985
|
+
error: "Bulk create requires a non-empty items array",
|
|
1011
986
|
status: 400
|
|
1012
987
|
};
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
if (!item) return this.notFoundResponse("NOT_FOUND");
|
|
1023
|
-
if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "restore", item, {
|
|
1024
|
-
user,
|
|
1025
|
-
context: arcContext,
|
|
1026
|
-
meta: { id }
|
|
1027
|
-
});
|
|
1028
|
-
return {
|
|
1029
|
-
success: true,
|
|
1030
|
-
data: item,
|
|
1031
|
-
status: 200,
|
|
1032
|
-
meta: { message: "Restored successfully" }
|
|
1033
|
-
};
|
|
1034
|
-
}
|
|
1035
|
-
async getTree(req) {
|
|
1036
|
-
const repo = this.repository;
|
|
1037
|
-
if (!repo.getTree) return {
|
|
1038
|
-
success: false,
|
|
1039
|
-
error: "Tree structure not implemented",
|
|
1040
|
-
status: 501
|
|
1041
|
-
};
|
|
1042
|
-
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
1043
|
-
return {
|
|
1044
|
-
success: true,
|
|
1045
|
-
data: await repo.getTree(options),
|
|
1046
|
-
status: 200
|
|
1047
|
-
};
|
|
1048
|
-
}
|
|
1049
|
-
async getChildren(req) {
|
|
1050
|
-
const repo = this.repository;
|
|
1051
|
-
if (!repo.getChildren) return {
|
|
1052
|
-
success: false,
|
|
1053
|
-
error: "Tree structure not implemented",
|
|
1054
|
-
status: 501
|
|
1055
|
-
};
|
|
1056
|
-
const parentField = this._presetFields.parentField ?? "parent";
|
|
1057
|
-
const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
|
|
1058
|
-
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
1059
|
-
return {
|
|
1060
|
-
success: true,
|
|
1061
|
-
data: await repo.getChildren(parentId, options),
|
|
1062
|
-
status: 200
|
|
1063
|
-
};
|
|
1064
|
-
}
|
|
1065
|
-
async bulkCreate(req) {
|
|
1066
|
-
const repo = this.repository;
|
|
1067
|
-
if (!repo.createMany) return {
|
|
1068
|
-
success: false,
|
|
1069
|
-
error: "Repository does not support createMany",
|
|
1070
|
-
status: 501
|
|
1071
|
-
};
|
|
1072
|
-
const rawItems = req.body?.items;
|
|
1073
|
-
if (!Array.isArray(rawItems) || rawItems.length === 0) return {
|
|
1074
|
-
success: false,
|
|
1075
|
-
error: "Bulk create requires a non-empty items array",
|
|
1076
|
-
status: 400
|
|
1077
|
-
};
|
|
1078
|
-
const items = rawItems;
|
|
1079
|
-
const arcContext = this.meta(req);
|
|
1080
|
-
const user = req.user;
|
|
1081
|
-
const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
|
|
1082
|
-
let scopedItems = sanitizedItems;
|
|
1083
|
-
if (this.tenantField) {
|
|
1084
|
-
const scope = arcContext?._scope;
|
|
1085
|
-
if (scope) {
|
|
1086
|
-
if (scope.kind === "public") return {
|
|
1087
|
-
success: false,
|
|
1088
|
-
error: "Organization context required to bulk-create resources",
|
|
1089
|
-
details: { code: "ORG_CONTEXT_REQUIRED" },
|
|
1090
|
-
status: 403
|
|
1091
|
-
};
|
|
1092
|
-
if (!isElevated(scope)) {
|
|
1093
|
-
const orgId = getOrgId(scope);
|
|
1094
|
-
if (!orgId) return {
|
|
988
|
+
const items = rawItems;
|
|
989
|
+
const arcContext = this.meta(req);
|
|
990
|
+
const user = req.user;
|
|
991
|
+
const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
|
|
992
|
+
let scopedItems = sanitizedItems;
|
|
993
|
+
if (this.tenantField) {
|
|
994
|
+
const scope = arcContext?._scope;
|
|
995
|
+
if (scope) {
|
|
996
|
+
if (scope.kind === "public") return {
|
|
1095
997
|
success: false,
|
|
1096
998
|
error: "Organization context required to bulk-create resources",
|
|
1097
999
|
details: { code: "ORG_CONTEXT_REQUIRED" },
|
|
1098
1000
|
status: 403
|
|
1099
1001
|
};
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1002
|
+
if (!isElevated(scope)) {
|
|
1003
|
+
const orgId = getOrgId(scope);
|
|
1004
|
+
if (!orgId) return {
|
|
1005
|
+
success: false,
|
|
1006
|
+
error: "Organization context required to bulk-create resources",
|
|
1007
|
+
details: { code: "ORG_CONTEXT_REQUIRED" },
|
|
1008
|
+
status: 403
|
|
1009
|
+
};
|
|
1010
|
+
const tenantField = this.tenantField;
|
|
1011
|
+
scopedItems = sanitizedItems.map((item) => ({
|
|
1012
|
+
...item,
|
|
1013
|
+
[tenantField]: orgId
|
|
1014
|
+
}));
|
|
1015
|
+
}
|
|
1105
1016
|
}
|
|
1106
1017
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1018
|
+
const created = await repo.createMany(scopedItems, {
|
|
1019
|
+
user,
|
|
1020
|
+
context: arcContext
|
|
1021
|
+
});
|
|
1022
|
+
const requested = items.length;
|
|
1023
|
+
const inserted = created.length;
|
|
1024
|
+
const skipped = requested - inserted;
|
|
1025
|
+
return {
|
|
1026
|
+
success: true,
|
|
1027
|
+
data: created,
|
|
1028
|
+
status: skipped === 0 ? 201 : inserted === 0 ? 422 : 207,
|
|
1029
|
+
meta: {
|
|
1030
|
+
count: inserted,
|
|
1031
|
+
requested,
|
|
1032
|
+
inserted,
|
|
1033
|
+
skipped,
|
|
1034
|
+
...skipped > 0 && {
|
|
1035
|
+
partial: true,
|
|
1036
|
+
reason: inserted === 0 ? "all_invalid" : "some_invalid"
|
|
1037
|
+
}
|
|
1127
1038
|
}
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Build a tenant-scoped filter for bulk update/delete.
|
|
1043
|
+
*
|
|
1044
|
+
* Mirrors `AccessControl.buildIdFilter` semantics:
|
|
1045
|
+
* - Always merge `_policyFilters` (from permission middleware)
|
|
1046
|
+
* - `member` scope on a tenant resource → add org filter
|
|
1047
|
+
* - `elevated` scope → no org filter (admin cross-org operation)
|
|
1048
|
+
* - `public` scope on a tenant resource → deny
|
|
1049
|
+
* - No scope at all (unit tests) → leave filter unchanged
|
|
1050
|
+
*
|
|
1051
|
+
* Returns the merged filter, or `null` when access must be denied.
|
|
1052
|
+
*/
|
|
1053
|
+
buildBulkFilter(userFilter, req) {
|
|
1054
|
+
const filter = { ...userFilter };
|
|
1055
|
+
const arcContext = this.meta(req);
|
|
1056
|
+
const policyFilters = arcContext?._policyFilters;
|
|
1057
|
+
if (policyFilters) Object.assign(filter, policyFilters);
|
|
1058
|
+
if (this.tenantField) {
|
|
1059
|
+
const scope = arcContext?._scope;
|
|
1060
|
+
if (!scope) return filter;
|
|
1061
|
+
if (scope.kind === "public") return null;
|
|
1062
|
+
if (isElevated(scope)) return filter;
|
|
1063
|
+
const orgId = getOrgId(scope);
|
|
1064
|
+
if (!orgId) return null;
|
|
1065
|
+
filter[this.tenantField] = orgId;
|
|
1128
1066
|
}
|
|
1129
|
-
|
|
1130
|
-
}
|
|
1131
|
-
/**
|
|
1132
|
-
* Build a tenant-scoped filter for bulk update/delete.
|
|
1133
|
-
*
|
|
1134
|
-
* Mirrors `AccessControl.buildIdFilter` semantics for single-doc ops:
|
|
1135
|
-
* - Always merge `_policyFilters` (from permission middleware)
|
|
1136
|
-
* - When `tenantField` is set AND a `member` scope is present, add the
|
|
1137
|
-
* org filter so cross-tenant data can't be touched.
|
|
1138
|
-
* - When the scope is `elevated` (platform admin), no org filter is
|
|
1139
|
-
* applied — admins can bulk-update across orgs intentionally.
|
|
1140
|
-
* - When the scope is `public` on a tenant-scoped resource, deny.
|
|
1141
|
-
* - When NO scope is present at all (e.g., direct controller calls in
|
|
1142
|
-
* unit tests, or app routes without auth middleware), the controller
|
|
1143
|
-
* stays lenient — it's the middleware layer's job to fail-close.
|
|
1144
|
-
* Apps that want fail-close on bulk routes should run the multi-tenant
|
|
1145
|
-
* preset middleware (or equivalent) ahead of these handlers.
|
|
1146
|
-
*
|
|
1147
|
-
* Returns the merged filter, or `null` when access must be denied.
|
|
1148
|
-
*/
|
|
1149
|
-
buildBulkFilter(userFilter, req) {
|
|
1150
|
-
const filter = { ...userFilter };
|
|
1151
|
-
const arcContext = this.meta(req);
|
|
1152
|
-
const policyFilters = arcContext?._policyFilters;
|
|
1153
|
-
if (policyFilters) Object.assign(filter, policyFilters);
|
|
1154
|
-
if (this.tenantField) {
|
|
1155
|
-
const scope = arcContext?._scope;
|
|
1156
|
-
if (!scope) return filter;
|
|
1157
|
-
if (scope.kind === "public") return null;
|
|
1158
|
-
if (isElevated(scope)) return filter;
|
|
1159
|
-
const orgId = getOrgId(scope);
|
|
1160
|
-
if (!orgId) return null;
|
|
1161
|
-
filter[this.tenantField] = orgId;
|
|
1067
|
+
return filter;
|
|
1162
1068
|
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
const sanitized =
|
|
1194
|
-
for (const
|
|
1069
|
+
/**
|
|
1070
|
+
* Sanitize a bulk update data payload through the same write-permission
|
|
1071
|
+
* pipeline as single-doc update. Handles both shapes:
|
|
1072
|
+
* - Flat: `{ name: 'x', status: 'y' }`
|
|
1073
|
+
* - Mongo operator: `{ $set: { name: 'x' }, $inc: { views: 1 } }`
|
|
1074
|
+
*
|
|
1075
|
+
* Mixed shapes (operator + flat keys) are rejected — mongo silently
|
|
1076
|
+
* drops the flat keys in operator mode, which is a footgun.
|
|
1077
|
+
*/
|
|
1078
|
+
sanitizeBulkUpdateData(data, req, arcContext) {
|
|
1079
|
+
const stripped = /* @__PURE__ */ new Set();
|
|
1080
|
+
const keys = Object.keys(data);
|
|
1081
|
+
const operatorKeys = keys.filter((k) => k.startsWith("$"));
|
|
1082
|
+
const flatKeys = keys.filter((k) => !k.startsWith("$"));
|
|
1083
|
+
const isOperatorShape = operatorKeys.length > 0;
|
|
1084
|
+
if (isOperatorShape && flatKeys.length > 0) return {
|
|
1085
|
+
sanitized: {},
|
|
1086
|
+
stripped: [],
|
|
1087
|
+
mixedShape: true
|
|
1088
|
+
};
|
|
1089
|
+
if (!isOperatorShape) {
|
|
1090
|
+
const before = new Set(Object.keys(data));
|
|
1091
|
+
const sanitized = this.bodySanitizer.sanitize(data, "update", req, arcContext);
|
|
1092
|
+
for (const key of before) if (!(key in sanitized)) stripped.add(key);
|
|
1093
|
+
return {
|
|
1094
|
+
sanitized,
|
|
1095
|
+
stripped: [...stripped],
|
|
1096
|
+
mixedShape: false
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
const sanitized = {};
|
|
1100
|
+
for (const [op, operand] of Object.entries(data)) {
|
|
1101
|
+
if (!op.startsWith("$") || operand === null || typeof operand !== "object") {
|
|
1102
|
+
sanitized[op] = operand;
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
const operandObj = operand;
|
|
1106
|
+
const before = new Set(Object.keys(operandObj));
|
|
1107
|
+
const sanitizedOperand = this.bodySanitizer.sanitize(operandObj, "update", req, arcContext);
|
|
1108
|
+
for (const key of before) if (!(key in sanitizedOperand)) stripped.add(key);
|
|
1109
|
+
if (Object.keys(sanitizedOperand).length > 0) sanitized[op] = sanitizedOperand;
|
|
1110
|
+
}
|
|
1195
1111
|
return {
|
|
1196
1112
|
sanitized,
|
|
1197
1113
|
stripped: [...stripped],
|
|
1198
1114
|
mixedShape: false
|
|
1199
1115
|
};
|
|
1200
1116
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
if (!
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
const
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1117
|
+
async bulkUpdate(req) {
|
|
1118
|
+
const repo = this.repository;
|
|
1119
|
+
if (!repo.updateMany) return {
|
|
1120
|
+
success: false,
|
|
1121
|
+
error: "Repository does not support updateMany",
|
|
1122
|
+
status: 501
|
|
1123
|
+
};
|
|
1124
|
+
const body = req.body;
|
|
1125
|
+
if (!body.filter || Object.keys(body.filter).length === 0) return {
|
|
1126
|
+
success: false,
|
|
1127
|
+
error: "Bulk update requires a non-empty filter",
|
|
1128
|
+
status: 400
|
|
1129
|
+
};
|
|
1130
|
+
if (!body.data || Object.keys(body.data).length === 0) return {
|
|
1131
|
+
success: false,
|
|
1132
|
+
error: "Bulk update requires non-empty data",
|
|
1133
|
+
status: 400
|
|
1134
|
+
};
|
|
1135
|
+
const scopedFilter = this.buildBulkFilter(body.filter, req);
|
|
1136
|
+
if (scopedFilter === null) return {
|
|
1137
|
+
success: false,
|
|
1138
|
+
error: "Organization context required for bulk update",
|
|
1139
|
+
details: { code: "ORG_CONTEXT_REQUIRED" },
|
|
1140
|
+
status: 403
|
|
1141
|
+
};
|
|
1142
|
+
const arcContext = this.meta(req);
|
|
1143
|
+
const user = req.user;
|
|
1144
|
+
const { sanitized, stripped, mixedShape } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
|
|
1145
|
+
if (mixedShape) return {
|
|
1146
|
+
success: false,
|
|
1147
|
+
error: "Bulk update payload cannot mix operator keys ($set, $inc, ...) with flat fields. Pick one shape.",
|
|
1148
|
+
details: { code: "MIXED_UPDATE_SHAPE" },
|
|
1149
|
+
status: 400
|
|
1150
|
+
};
|
|
1151
|
+
if (Object.keys(sanitized).length === 0) return {
|
|
1152
|
+
success: false,
|
|
1153
|
+
error: "Bulk update payload contained only protected fields",
|
|
1154
|
+
details: {
|
|
1155
|
+
code: "ALL_FIELDS_STRIPPED",
|
|
1156
|
+
stripped
|
|
1157
|
+
},
|
|
1158
|
+
status: 400
|
|
1159
|
+
};
|
|
1160
|
+
return {
|
|
1161
|
+
success: true,
|
|
1162
|
+
data: await repo.updateMany(scopedFilter, sanitized, {
|
|
1163
|
+
user,
|
|
1164
|
+
context: arcContext
|
|
1165
|
+
}),
|
|
1166
|
+
status: 200,
|
|
1167
|
+
...stripped.length > 0 && { meta: { stripped } }
|
|
1168
|
+
};
|
|
1212
1169
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
status: 400
|
|
1261
|
-
};
|
|
1262
|
-
return {
|
|
1263
|
-
success: true,
|
|
1264
|
-
data: await repo.updateMany(scopedFilter, sanitized, {
|
|
1265
|
-
user,
|
|
1170
|
+
/**
|
|
1171
|
+
* Bulk delete by `filter` or `ids`.
|
|
1172
|
+
*
|
|
1173
|
+
* Body shape (one of):
|
|
1174
|
+
* - `{ filter: { status: 'archived' } }` — delete by query filter
|
|
1175
|
+
* - `{ ids: ['id1', 'id2', 'id3'] }` — delete specific docs by id
|
|
1176
|
+
*
|
|
1177
|
+
* The `ids` form translates to `{ [idField]: { $in: ids } }` using the
|
|
1178
|
+
* resource's `idField` (so it works with custom PKs like `slug`, `jobId`,
|
|
1179
|
+
* UUID). Tenant scope and policy filters are merged in either way.
|
|
1180
|
+
*
|
|
1181
|
+
* Both forms perform a single `repo.deleteMany()` DB call — no per-doc
|
|
1182
|
+
* fetch loop. Per-doc lifecycle hooks do NOT fire.
|
|
1183
|
+
*/
|
|
1184
|
+
async bulkDelete(req) {
|
|
1185
|
+
const repo = this.repository;
|
|
1186
|
+
if (!repo.deleteMany) return {
|
|
1187
|
+
success: false,
|
|
1188
|
+
error: "Repository does not support deleteMany",
|
|
1189
|
+
status: 501
|
|
1190
|
+
};
|
|
1191
|
+
const body = req.body;
|
|
1192
|
+
let userFilter;
|
|
1193
|
+
if (body.ids && body.ids.length > 0) {
|
|
1194
|
+
if (body.filter && Object.keys(body.filter).length > 0) return {
|
|
1195
|
+
success: false,
|
|
1196
|
+
error: "Bulk delete accepts either `ids` or `filter`, not both",
|
|
1197
|
+
status: 400
|
|
1198
|
+
};
|
|
1199
|
+
userFilter = { [this.idField]: { $in: body.ids } };
|
|
1200
|
+
} else if (body.filter && Object.keys(body.filter).length > 0) userFilter = body.filter;
|
|
1201
|
+
else return {
|
|
1202
|
+
success: false,
|
|
1203
|
+
error: "Bulk delete requires a non-empty `filter` or `ids` array",
|
|
1204
|
+
status: 400
|
|
1205
|
+
};
|
|
1206
|
+
const scopedFilter = this.buildBulkFilter(userFilter, req);
|
|
1207
|
+
if (scopedFilter === null) return {
|
|
1208
|
+
success: false,
|
|
1209
|
+
error: "Organization context required for bulk delete",
|
|
1210
|
+
details: { code: "ORG_CONTEXT_REQUIRED" },
|
|
1211
|
+
status: 403
|
|
1212
|
+
};
|
|
1213
|
+
const hardHint = req.query?.hard === "true" || req.query?.hard === true || body.mode === "hard";
|
|
1214
|
+
const arcContext = this.meta(req);
|
|
1215
|
+
const options = {
|
|
1216
|
+
user: req.user,
|
|
1266
1217
|
context: arcContext
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
if (body.ids && body.ids.length > 0) {
|
|
1299
|
-
if (body.filter && Object.keys(body.filter).length > 0) return {
|
|
1218
|
+
};
|
|
1219
|
+
if (hardHint) options.mode = "hard";
|
|
1220
|
+
return {
|
|
1221
|
+
success: true,
|
|
1222
|
+
data: await repo.deleteMany(scopedFilter, options),
|
|
1223
|
+
status: 200
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
//#endregion
|
|
1229
|
+
//#region src/core/mixins/slug.ts
|
|
1230
|
+
function SlugMixin(Base) {
|
|
1231
|
+
return class SlugController extends Base {
|
|
1232
|
+
async getBySlug(req) {
|
|
1233
|
+
const slugField = this._presetFields.slugField ?? "slug";
|
|
1234
|
+
const slug = req.params[slugField] ?? req.params.slug;
|
|
1235
|
+
const options = {
|
|
1236
|
+
...this.queryResolver.resolve(req, this.meta(req)),
|
|
1237
|
+
...this.tenantRepoOptions(req)
|
|
1238
|
+
};
|
|
1239
|
+
const repo = this.repository;
|
|
1240
|
+
let item = null;
|
|
1241
|
+
if (repo.getBySlug) item = await repo.getBySlug(slug, options);
|
|
1242
|
+
else if (repo.getOne) {
|
|
1243
|
+
const filter = {
|
|
1244
|
+
[slugField]: slug,
|
|
1245
|
+
...options?.filter ?? {}
|
|
1246
|
+
};
|
|
1247
|
+
item = await repo.getOne(filter, options);
|
|
1248
|
+
} else return {
|
|
1300
1249
|
success: false,
|
|
1301
|
-
error: "
|
|
1250
|
+
error: "Slug lookup not implemented — repository needs getBySlug() or getOne()",
|
|
1251
|
+
status: 501
|
|
1252
|
+
};
|
|
1253
|
+
if (!this.accessControl.validateItemAccess(item, req)) return this.notFoundResponse(item ? "POLICY_FILTERED" : "NOT_FOUND");
|
|
1254
|
+
return {
|
|
1255
|
+
success: true,
|
|
1256
|
+
data: item,
|
|
1257
|
+
status: 200
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
//#endregion
|
|
1263
|
+
//#region src/core/mixins/softDelete.ts
|
|
1264
|
+
function SoftDeleteMixin(Base) {
|
|
1265
|
+
return class SoftDeleteController extends Base {
|
|
1266
|
+
async getDeleted(req) {
|
|
1267
|
+
const repo = this.repository;
|
|
1268
|
+
if (!repo.getDeleted) return {
|
|
1269
|
+
success: false,
|
|
1270
|
+
error: "Soft delete not implemented",
|
|
1271
|
+
status: 501
|
|
1272
|
+
};
|
|
1273
|
+
const parsed = this.queryResolver.resolve(req, this.meta(req));
|
|
1274
|
+
return {
|
|
1275
|
+
success: true,
|
|
1276
|
+
data: await repo.getDeleted(parsed, parsed),
|
|
1277
|
+
status: 200
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
async restore(req) {
|
|
1281
|
+
const repo = this.repository;
|
|
1282
|
+
if (!repo.restore) return {
|
|
1283
|
+
success: false,
|
|
1284
|
+
error: "Restore not implemented",
|
|
1285
|
+
status: 501
|
|
1286
|
+
};
|
|
1287
|
+
const id = req.params.id;
|
|
1288
|
+
if (!id) return {
|
|
1289
|
+
success: false,
|
|
1290
|
+
error: "ID parameter is required",
|
|
1302
1291
|
status: 400
|
|
1303
1292
|
};
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
};
|
|
1293
|
+
const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
|
|
1294
|
+
if (!existing) return this.notFoundResponse("NOT_FOUND");
|
|
1295
|
+
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
1296
|
+
success: false,
|
|
1297
|
+
error: "You do not have permission to restore this resource",
|
|
1298
|
+
details: { code: "OWNERSHIP_DENIED" },
|
|
1299
|
+
status: 403
|
|
1300
|
+
};
|
|
1301
|
+
const arcContext = this.meta(req);
|
|
1302
|
+
const user = req.user;
|
|
1303
|
+
const repoId = this.resolveRepoId(id, existing);
|
|
1304
|
+
const hooks = this.getHooks(req);
|
|
1305
|
+
if (hooks && this.resourceName) try {
|
|
1306
|
+
await hooks.executeBefore(this.resourceName, "restore", existing, {
|
|
1307
|
+
user,
|
|
1308
|
+
context: arcContext,
|
|
1309
|
+
meta: { id }
|
|
1310
|
+
});
|
|
1311
|
+
} catch (err) {
|
|
1312
|
+
return {
|
|
1313
|
+
success: false,
|
|
1314
|
+
error: "Hook execution failed",
|
|
1315
|
+
details: {
|
|
1316
|
+
code: "BEFORE_RESTORE_HOOK_ERROR",
|
|
1317
|
+
message: err.message
|
|
1318
|
+
},
|
|
1319
|
+
status: 400
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
const repoRestore = () => repo.restore(repoId);
|
|
1323
|
+
let item;
|
|
1324
|
+
if (hooks && this.resourceName) item = await hooks.executeAround(this.resourceName, "restore", existing, repoRestore, {
|
|
1325
|
+
user,
|
|
1326
|
+
context: arcContext,
|
|
1327
|
+
meta: { id }
|
|
1328
|
+
});
|
|
1329
|
+
else item = await repoRestore();
|
|
1330
|
+
if (!item) return this.notFoundResponse("NOT_FOUND");
|
|
1331
|
+
if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "restore", item, {
|
|
1332
|
+
user,
|
|
1333
|
+
context: arcContext,
|
|
1334
|
+
meta: { id }
|
|
1335
|
+
});
|
|
1336
|
+
return {
|
|
1337
|
+
success: true,
|
|
1338
|
+
data: item,
|
|
1339
|
+
status: 200,
|
|
1340
|
+
meta: { message: "Restored successfully" }
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
//#endregion
|
|
1346
|
+
//#region src/core/mixins/tree.ts
|
|
1347
|
+
function TreeMixin(Base) {
|
|
1348
|
+
return class TreeController extends Base {
|
|
1349
|
+
async getTree(req) {
|
|
1350
|
+
const repo = this.repository;
|
|
1351
|
+
if (!repo.getTree) return {
|
|
1352
|
+
success: false,
|
|
1353
|
+
error: "Tree structure not implemented",
|
|
1354
|
+
status: 501
|
|
1355
|
+
};
|
|
1356
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
1357
|
+
return {
|
|
1358
|
+
success: true,
|
|
1359
|
+
data: await repo.getTree(options),
|
|
1360
|
+
status: 200
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
async getChildren(req) {
|
|
1364
|
+
const repo = this.repository;
|
|
1365
|
+
if (!repo.getChildren) return {
|
|
1366
|
+
success: false,
|
|
1367
|
+
error: "Tree structure not implemented",
|
|
1368
|
+
status: 501
|
|
1369
|
+
};
|
|
1370
|
+
const parentField = this._presetFields.parentField ?? "parent";
|
|
1371
|
+
const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
|
|
1372
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
1373
|
+
return {
|
|
1374
|
+
success: true,
|
|
1375
|
+
data: await repo.getChildren(parentId, options),
|
|
1376
|
+
status: 200
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
//#endregion
|
|
1382
|
+
//#region src/core/BaseController.ts
|
|
1383
|
+
/**
|
|
1384
|
+
* Fully-composed controller: `BaseCrudController` + SoftDelete + Tree +
|
|
1385
|
+
* Slug + Bulk. Drop-in replacement for the pre-2.11 god class. The
|
|
1386
|
+
* companion interface above gives every method full generic precision
|
|
1387
|
+
* on `TDoc` via declaration merging.
|
|
1388
|
+
*/
|
|
1389
|
+
var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
|
|
1332
1390
|
//#endregion
|
|
1333
|
-
export {
|
|
1391
|
+
export { BulkMixin as a, BodySanitizer as c, SlugMixin as i, AccessControl as l, TreeMixin as n, BaseCrudController as o, SoftDeleteMixin as r, QueryResolver as s, BaseController as t };
|