@classytic/arc 2.8.5 → 2.10.3
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 +50 -38
- package/dist/{BaseController-DAGGc5Xn.mjs → BaseController-CbKKIflT.mjs} +193 -143
- package/dist/EventTransport-CUw5NNWe.d.mts +293 -0
- package/dist/{ResourceRegistry-C6uXlWe3.mjs → ResourceRegistry-BPd6NQDm.mjs} +1 -1
- package/dist/adapters/index.d.mts +3 -3
- package/dist/adapters/index.mjs +2 -2
- package/dist/{adapters-BBqAVvPK.mjs → adapters-BXY4i-hw.mjs} +210 -41
- package/dist/audit/index.d.mts +135 -11
- package/dist/audit/index.mjs +107 -20
- package/dist/auth/index.d.mts +17 -9
- package/dist/auth/index.mjs +14 -7
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi-BuUcUEJq.mjs → betterAuthOpenApi-BBRVhjQN.mjs} +1 -1
- package/dist/cache/index.d.mts +17 -15
- package/dist/cache/index.mjs +15 -14
- package/dist/{caching-IMuYVjTL.mjs → caching-CBpK_SCM.mjs} +8 -3
- package/dist/cli/commands/describe.mjs +1 -1
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +1 -1
- package/dist/cli/commands/init.mjs +1 -1
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -6
- package/dist/{defineResource-tcgySDo1.mjs → core-CcR01lup.mjs} +58 -61
- package/dist/{createActionRouter-BORM8f17.mjs → createActionRouter-Bp_5c_2b.mjs} +3 -3
- package/dist/{createApp-B1EY8zxa.mjs → createApp-BuvPma24.mjs} +15 -14
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-DtFxrG0s.mjs → elevation-C7hgL_aI.mjs} +22 -8
- package/dist/{errorHandler-f869_8PQ.mjs → errorHandler-Bb49BvPD.mjs} +59 -7
- package/dist/{errorHandler-Bah5JhBd.d.mts → errorHandler-DRQ3EqfL.d.mts} +37 -2
- package/dist/{eventPlugin-D9DKB2zM.d.mts → eventPlugin-CxWgpd6K.d.mts} +14 -2
- package/dist/{eventPlugin-CDjVTM82.mjs → eventPlugin-DCUjuiQT.mjs} +83 -5
- package/dist/events/index.d.mts +150 -36
- package/dist/events/index.mjs +355 -101
- 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/{types-DZi1aYhm.d.mts → fields-Lo1VUDpt.d.mts} +121 -1
- package/dist/{fields-ipsbIRPK.mjs → fields-bxkeltzz.mjs} +18 -5
- package/dist/{filesUpload-C7r7HIeA.mjs → filesUpload-t21LS-py.mjs} +65 -7
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +32 -5
- package/dist/idempotency/index.mjs +119 -12
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-DtDzOBn8.d.mts → index-8qw4y6ff.d.mts} +4 -135
- package/dist/{index-BLXBmWud.d.mts → index-ChIw3776.d.mts} +283 -408
- package/dist/{interface-CMRutPfe.d.mts → index-Cl0uoKd5.d.mts} +1758 -2506
- package/dist/{index-C1meYuDn.d.mts → index-DStwgFUK.d.mts} +81 -7
- package/dist/index.d.mts +7 -8
- package/dist/index.mjs +11 -12
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +26 -8
- package/dist/integrations/mcp/index.mjs +96 -17
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/webhooks.d.mts +5 -0
- package/dist/integrations/webhooks.mjs +6 -0
- package/dist/interface-D218ikEo.d.mts +77 -0
- package/dist/{memory-Cp7_cAko.mjs → memory-B5Amv9A1.mjs} +23 -8
- package/dist/{openapi-CbKUJY_m.mjs → openapi-B5F8AddX.mjs} +3 -3
- package/dist/org/index.d.mts +2 -2
- package/dist/permissions/index.d.mts +3 -4
- package/dist/permissions/index.mjs +5 -5
- package/dist/{permissions-CH4cNwJi.mjs → permissions-Dk6mshja.mjs} +315 -397
- package/dist/plugins/index.d.mts +7 -7
- package/dist/plugins/index.mjs +14 -16
- package/dist/plugins/response-cache.mjs +2 -2
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +27 -5
- package/dist/presets/filesUpload.mjs +1 -1
- package/dist/presets/index.d.mts +3 -2
- package/dist/presets/index.mjs +4 -3
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +2 -2
- package/dist/presets/search.d.mts +178 -0
- package/dist/presets/search.mjs +150 -0
- package/dist/{presets-C2xgzW6x.mjs → presets-fLJVXdVn.mjs} +1 -1
- package/dist/{queryCachePlugin-BJJGBTlu.d.mts → queryCachePlugin-BKbWjgDG.d.mts} +1 -1
- package/dist/{queryCachePlugin-BH-fidlv.mjs → queryCachePlugin-DQCEfJis.mjs} +9 -9
- package/dist/{queryParser-CgCtsjti.mjs → queryParser-DBqBB6AC.mjs} +1 -1
- package/dist/{redis-BM00zaPB.d.mts → redis-DqyeggCa.d.mts} +1 -1
- package/dist/{redis-stream-CrsfUmPt.d.mts → redis-stream-CakIQmwR.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{resourceToTools-8s-EsCCe.mjs → resourceToTools-BElv3xPT.mjs} +65 -48
- package/dist/{schemaConverter-Y7nCYaLJ.mjs → schemaConverter-BxFDdtXu.mjs} +1 -1
- package/dist/scope/index.d.mts +1 -1
- package/dist/scope/index.mjs +2 -2
- package/dist/{sse-Ad7ypl9e.mjs → sse-yBCgOLGu.mjs} +1 -1
- package/dist/store-helpers-ZCSMJJAX.mjs +57 -0
- package/dist/testing/index.d.mts +9 -17
- package/dist/testing/index.mjs +27 -83
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/index.mjs +1 -31
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-BsbNMEDR.d.mts → types-Btdda02s.d.mts} +1 -1
- package/dist/{types-Ch9pTQbf.d.mts → types-Co8k3NyS.d.mts} +11 -9
- package/dist/types-Csi3FLfq.mjs +27 -0
- package/dist/utils/index.d.mts +208 -4
- package/dist/utils/index.mjs +5 -6
- package/dist/{utils-yYT3HDXt.mjs → utils-B2fNOD_i.mjs} +285 -2
- package/dist/{versioning-CDugduqI.mjs → versioning-C2U_bLY0.mjs} +3 -5
- package/package.json +20 -26
- package/skills/arc/SKILL.md +97 -23
- package/skills/arc/references/auth.md +94 -0
- package/skills/arc/references/events.md +200 -12
- package/skills/arc/references/mcp.md +4 -17
- package/skills/arc/references/multi-tenancy.md +43 -0
- package/skills/arc/references/production.md +34 -60
- package/dist/EventTransport-BXja8NOc.d.mts +0 -135
- package/dist/audit/mongodb.d.mts +0 -2
- package/dist/audit/mongodb.mjs +0 -2
- package/dist/circuitBreaker-cmi5XDv5.mjs +0 -284
- package/dist/circuitBreaker-dTtG-UyS.d.mts +0 -206
- package/dist/core-F0QoWBt2.mjs +0 -34
- package/dist/dynamic/index.d.mts +0 -93
- package/dist/dynamic/index.mjs +0 -122
- package/dist/fields-DpZQa_Q3.d.mts +0 -109
- package/dist/idempotency/mongodb.d.mts +0 -2
- package/dist/idempotency/mongodb.mjs +0 -123
- package/dist/interface-4y979v99.d.mts +0 -54
- package/dist/mongodb-BsP-WbhN.d.mts +0 -127
- package/dist/mongodb-CTcp0hQZ.d.mts +0 -80
- package/dist/mongodb-Utc5k_-0.mjs +0 -90
- package/dist/policies/index.d.mts +0 -432
- package/dist/policies/index.mjs +0 -318
- package/dist/rpc/index.d.mts +0 -90
- package/dist/rpc/index.mjs +0 -248
- /package/dist/{HookSystem-HprTmvVY.mjs → HookSystem-BNYKnrXF.mjs} +0 -0
- /package/dist/{applyPermissionResult-D6GPMsvh.mjs → applyPermissionResult-QhV1Pa-g.mjs} +0 -0
- /package/dist/{constants-Cxde4rpC.mjs → constants-BhY1OHoH.mjs} +0 -0
- /package/dist/{elevation-B6S5csVA.d.mts → elevation-C5SwtkAn.d.mts} +0 -0
- /package/dist/{errors-Ck2h67pm.d.mts → errors-CCSsMpXE.d.mts} +0 -0
- /package/dist/{errors-BF2bIOIS.mjs → errors-D5c-5BJL.mjs} +0 -0
- /package/dist/{externalPaths-BnkYrNzp.d.mts → externalPaths-BQ8QijNH.d.mts} +0 -0
- /package/dist/{interface-DfLGcus7.d.mts → interface-CSbZdv_3.d.mts} +0 -0
- /package/dist/{loadResources-PWd0OCpV.mjs → loadResources-BAzJItAJ.mjs} +0 -0
- /package/dist/{logger-D1YrIImS.mjs → logger-DLg8-Ueg.mjs} +0 -0
- /package/dist/{metrics-B-PU4-Yu.mjs → metrics-DuhiSEZI.mjs} +0 -0
- /package/dist/{pluralize-CWP6MB39.mjs → pluralize-A0tWEl1K.mjs} +0 -0
- /package/dist/{registry-BiTKT1Dg.mjs → registry-B3lRFBWo.mjs} +0 -0
- /package/dist/{replyHelpers-CxkYGT81.mjs → replyHelpers-CXtJDAZ0.mjs} +0 -0
- /package/dist/{requestContext-DYvHl113.mjs → requestContext-xHIKedG6.mjs} +0 -0
- /package/dist/{sessionManager-DDCmiNIo.d.mts → sessionManager-BkzVU8h2.d.mts} +0 -0
- /package/dist/{storage-Dfzt4VTl.d.mts → storage-CVk_SEn2.d.mts} +0 -0
- /package/dist/{tracing-DdN2-wHJ.d.mts → tracing-65B51Dw3.d.mts} +0 -0
- /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-Cj5Rgvlg.mjs} +0 -0
- /package/dist/{types-ZUu_h0jp.mjs → types-DV9WDfeg.mjs} +0 -0
|
@@ -1,19 +1,12 @@
|
|
|
1
|
-
import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-
|
|
1
|
+
import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-BhY1OHoH.mjs";
|
|
2
2
|
import { _ as isElevated, n as PUBLIC_SCOPE, o as getOrgId, v as isMember } from "./types-AOD8fxIw.mjs";
|
|
3
3
|
import { t as buildQueryKey } from "./keys-qcD-TVJl.mjs";
|
|
4
|
-
import { getUserId } from "./types
|
|
5
|
-
import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-
|
|
6
|
-
import { t as getUserRoles } from "./types-
|
|
7
|
-
import {
|
|
4
|
+
import { n as getUserId } from "./types-Csi3FLfq.mjs";
|
|
5
|
+
import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-bxkeltzz.mjs";
|
|
6
|
+
import { t as getUserRoles } from "./types-DV9WDfeg.mjs";
|
|
7
|
+
import { r as ForbiddenError } from "./errors-D5c-5BJL.mjs";
|
|
8
|
+
import { t as ArcQueryParser } from "./queryParser-DBqBB6AC.mjs";
|
|
8
9
|
//#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
10
|
var AccessControl = class AccessControl {
|
|
18
11
|
tenantField;
|
|
19
12
|
idField;
|
|
@@ -95,20 +88,82 @@ var AccessControl = class AccessControl {
|
|
|
95
88
|
* buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
|
|
96
89
|
*/
|
|
97
90
|
async fetchWithAccessControl(id, req, repository, queryOptions) {
|
|
91
|
+
return (await this.fetchDetailed(id, req, repository, queryOptions)).doc;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Same as `fetchWithAccessControl` but returns a structured result with
|
|
95
|
+
* a denial reason so callers can distinguish "doc doesn't exist" from
|
|
96
|
+
* "doc exists but was filtered by policy/org scope" from "repo threw".
|
|
97
|
+
*
|
|
98
|
+
* Codes:
|
|
99
|
+
* - `null` — doc was found, no denial
|
|
100
|
+
* - `'NOT_FOUND'` — doc genuinely doesn't exist in the DB
|
|
101
|
+
* - `'POLICY_FILTERED'` — doc exists but the request's policy filters exclude it
|
|
102
|
+
* - `'ORG_SCOPE_DENIED'` — doc exists but the caller's org context doesn't match
|
|
103
|
+
* - `'REPO_ERROR'` — the repository threw a "not found" error (mongokit style)
|
|
104
|
+
*/
|
|
105
|
+
async fetchDetailed(id, req, repository, queryOptions) {
|
|
98
106
|
const compoundFilter = this.buildIdFilter(id, req);
|
|
99
|
-
const
|
|
107
|
+
const hasCompoundFilters = Object.keys(compoundFilter).length > 1;
|
|
108
|
+
const needsCompoundLookup = hasCompoundFilters || this.idField !== "_id";
|
|
109
|
+
const translateStatus404 = (error) => {
|
|
110
|
+
if (error && typeof error === "object" && error.status === 404) return {
|
|
111
|
+
doc: null,
|
|
112
|
+
reason: "NOT_FOUND"
|
|
113
|
+
};
|
|
114
|
+
return null;
|
|
115
|
+
};
|
|
100
116
|
try {
|
|
101
|
-
if (needsCompoundLookup && typeof repository.getOne === "function")
|
|
117
|
+
if (needsCompoundLookup && typeof repository.getOne === "function") {
|
|
118
|
+
const doc = await repository.getOne(compoundFilter, queryOptions);
|
|
119
|
+
if (doc) return {
|
|
120
|
+
doc,
|
|
121
|
+
reason: null
|
|
122
|
+
};
|
|
123
|
+
if (hasCompoundFilters) {
|
|
124
|
+
const idOnly = { [this.idField]: id };
|
|
125
|
+
const rawDoc = await repository.getOne(idOnly);
|
|
126
|
+
if (rawDoc) {
|
|
127
|
+
const arcContext = this._meta(req);
|
|
128
|
+
if (!this.checkOrgScope(rawDoc, arcContext)) return {
|
|
129
|
+
doc: null,
|
|
130
|
+
reason: "ORG_SCOPE_DENIED"
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
doc: null,
|
|
134
|
+
reason: "POLICY_FILTERED"
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
doc: null,
|
|
140
|
+
reason: "NOT_FOUND"
|
|
141
|
+
};
|
|
142
|
+
}
|
|
102
143
|
if (this.idField !== "_id") {
|
|
103
144
|
if (typeof repository.getOne !== "function") throw new Error(`Resource with idField="${this.idField}" requires repository.getOne() to look up by custom field. Arc's BaseController cannot fall back to getById() because it would query by _id.`);
|
|
104
145
|
}
|
|
105
146
|
const item = await repository.getById(id, queryOptions);
|
|
106
|
-
if (!item) return
|
|
147
|
+
if (!item) return {
|
|
148
|
+
doc: null,
|
|
149
|
+
reason: "NOT_FOUND"
|
|
150
|
+
};
|
|
107
151
|
const arcContext = this._meta(req);
|
|
108
|
-
if (!this.checkOrgScope(item, arcContext)
|
|
109
|
-
|
|
152
|
+
if (!this.checkOrgScope(item, arcContext)) return {
|
|
153
|
+
doc: null,
|
|
154
|
+
reason: "ORG_SCOPE_DENIED"
|
|
155
|
+
};
|
|
156
|
+
if (!this.checkPolicyFilters(item, req)) return {
|
|
157
|
+
doc: null,
|
|
158
|
+
reason: "POLICY_FILTERED"
|
|
159
|
+
};
|
|
160
|
+
return {
|
|
161
|
+
doc: item,
|
|
162
|
+
reason: null
|
|
163
|
+
};
|
|
110
164
|
} catch (error) {
|
|
111
|
-
|
|
165
|
+
const translated = translateStatus404(error);
|
|
166
|
+
if (translated) return translated;
|
|
112
167
|
throw error;
|
|
113
168
|
}
|
|
114
169
|
}
|
|
@@ -224,20 +279,12 @@ var AccessControl = class AccessControl {
|
|
|
224
279
|
}
|
|
225
280
|
}
|
|
226
281
|
};
|
|
227
|
-
//#endregion
|
|
228
|
-
//#region src/core/BodySanitizer.ts
|
|
229
|
-
/**
|
|
230
|
-
* BodySanitizer - Composable body sanitization logic extracted from BaseController.
|
|
231
|
-
*
|
|
232
|
-
* Strips readonly fields, system-managed fields, and applies field-level
|
|
233
|
-
* write permissions from request bodies before create/update operations.
|
|
234
|
-
*
|
|
235
|
-
* Designed to be used standalone or composed into controllers.
|
|
236
|
-
*/
|
|
237
282
|
var BodySanitizer = class {
|
|
238
283
|
schemaOptions;
|
|
284
|
+
onFieldWriteDenied;
|
|
239
285
|
constructor(config) {
|
|
240
286
|
this.schemaOptions = config.schemaOptions;
|
|
287
|
+
this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
|
|
241
288
|
}
|
|
242
289
|
/**
|
|
243
290
|
* Strip readonly and system-managed fields from request body.
|
|
@@ -261,7 +308,9 @@ var BodySanitizer = class {
|
|
|
261
308
|
const fieldPerms = arcContext?.arc?.fields;
|
|
262
309
|
if (fieldPerms) {
|
|
263
310
|
const effectiveRoles = resolveEffectiveRoles(getUserRoles(req.user), isMember(scope) ? scope.orgRoles : []);
|
|
264
|
-
|
|
311
|
+
const { body: filtered, deniedFields } = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
|
|
312
|
+
if (deniedFields.length > 0 && this.onFieldWriteDenied === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
|
|
313
|
+
sanitized = filtered;
|
|
265
314
|
}
|
|
266
315
|
}
|
|
267
316
|
}
|
|
@@ -404,6 +453,12 @@ var QueryResolver = class {
|
|
|
404
453
|
//#endregion
|
|
405
454
|
//#region src/core/BaseController.ts
|
|
406
455
|
/**
|
|
456
|
+
* Portable "run on next tick" scheduler. `setImmediate` is Node-only — not
|
|
457
|
+
* available in Bun workers, Deno, Cloudflare Workers, or edge runtimes. Fall
|
|
458
|
+
* back to queueMicrotask (universal) when setImmediate is absent.
|
|
459
|
+
*/
|
|
460
|
+
const scheduleBackground = typeof setImmediate === "function" ? (cb) => void setImmediate(cb) : (cb) => queueMicrotask(cb);
|
|
461
|
+
/**
|
|
407
462
|
* Framework-agnostic base controller implementing IController.
|
|
408
463
|
*
|
|
409
464
|
* Composes AccessControl, BodySanitizer, and QueryResolver for clean
|
|
@@ -450,7 +505,10 @@ var BaseController = class {
|
|
|
450
505
|
idField: this.idField,
|
|
451
506
|
matchesFilter: this._matchesFilter
|
|
452
507
|
});
|
|
453
|
-
this.bodySanitizer = new BodySanitizer({
|
|
508
|
+
this.bodySanitizer = new BodySanitizer({
|
|
509
|
+
schemaOptions: this.schemaOptions,
|
|
510
|
+
onFieldWriteDenied: options.onFieldWriteDenied
|
|
511
|
+
});
|
|
454
512
|
this.queryResolver = new QueryResolver({
|
|
455
513
|
queryParser: this.queryParser,
|
|
456
514
|
maxLimit: this.maxLimit,
|
|
@@ -506,6 +564,28 @@ var BaseController = class {
|
|
|
506
564
|
if (repoIdField && repoIdField === this.idField) return id;
|
|
507
565
|
return String(existing["_id"] ?? id);
|
|
508
566
|
}
|
|
567
|
+
/**
|
|
568
|
+
* Centralized 404 response builder. Maps the denial reason from
|
|
569
|
+
* `fetchDetailed()` into a structured `details.code` so consumers can
|
|
570
|
+
* programmatically distinguish "doc doesn't exist" from "doc filtered
|
|
571
|
+
* by policy/org scope" without parsing error strings.
|
|
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.
|
|
575
|
+
*/
|
|
576
|
+
notFoundResponse(reason = "NOT_FOUND") {
|
|
577
|
+
const code = reason ?? "NOT_FOUND";
|
|
578
|
+
return {
|
|
579
|
+
success: false,
|
|
580
|
+
error: {
|
|
581
|
+
NOT_FOUND: "Resource not found",
|
|
582
|
+
POLICY_FILTERED: "Resource not found",
|
|
583
|
+
ORG_SCOPE_DENIED: "Resource not found"
|
|
584
|
+
}[code] ?? "Resource not found",
|
|
585
|
+
status: 404,
|
|
586
|
+
details: { code }
|
|
587
|
+
};
|
|
588
|
+
}
|
|
509
589
|
/** Resolve cache config for a specific operation, merging per-op overrides */
|
|
510
590
|
resolveCacheConfig(operation) {
|
|
511
591
|
const cfg = this._cacheConfig;
|
|
@@ -547,7 +627,7 @@ var BaseController = class {
|
|
|
547
627
|
headers: { "x-cache": "HIT" }
|
|
548
628
|
};
|
|
549
629
|
if (status === "stale") {
|
|
550
|
-
|
|
630
|
+
scheduleBackground(() => {
|
|
551
631
|
this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
|
|
552
632
|
});
|
|
553
633
|
return {
|
|
@@ -576,20 +656,10 @@ var BaseController = class {
|
|
|
576
656
|
async executeListQuery(options, req) {
|
|
577
657
|
const hooks = this.getHooks(req);
|
|
578
658
|
const repoGetAll = async () => this.repository.getAll(options);
|
|
579
|
-
|
|
659
|
+
return hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "list", options, repoGetAll, {
|
|
580
660
|
user: req.user,
|
|
581
661
|
context: this.meta(req)
|
|
582
662
|
}) : await repoGetAll();
|
|
583
|
-
if (Array.isArray(result)) return {
|
|
584
|
-
docs: result,
|
|
585
|
-
page: 1,
|
|
586
|
-
limit: result.length,
|
|
587
|
-
total: result.length,
|
|
588
|
-
pages: 1,
|
|
589
|
-
hasNext: false,
|
|
590
|
-
hasPrev: false
|
|
591
|
-
};
|
|
592
|
-
return result;
|
|
593
663
|
}
|
|
594
664
|
async get(req) {
|
|
595
665
|
const id = req.params.id;
|
|
@@ -616,8 +686,8 @@ var BaseController = class {
|
|
|
616
686
|
headers: { "x-cache": "HIT" }
|
|
617
687
|
};
|
|
618
688
|
if (status === "stale") {
|
|
619
|
-
|
|
620
|
-
this.executeGetQuery(id, options, req).then((fresh) => {
|
|
689
|
+
scheduleBackground(() => {
|
|
690
|
+
this.executeGetQuery(id, options, req).then(({ doc: fresh }) => {
|
|
621
691
|
if (fresh) qc.set(key, fresh, cacheConfig);
|
|
622
692
|
}).catch(() => {});
|
|
623
693
|
});
|
|
@@ -628,49 +698,42 @@ var BaseController = class {
|
|
|
628
698
|
headers: { "x-cache": "STALE" }
|
|
629
699
|
};
|
|
630
700
|
}
|
|
631
|
-
const
|
|
632
|
-
if (!
|
|
633
|
-
|
|
634
|
-
error: "Resource not found",
|
|
635
|
-
status: 404
|
|
636
|
-
};
|
|
637
|
-
await qc.set(key, item, cacheConfig);
|
|
701
|
+
const { doc: cached, reason: cacheReason } = await this.executeGetQuery(id, options, req);
|
|
702
|
+
if (!cached) return this.notFoundResponse(cacheReason);
|
|
703
|
+
await qc.set(key, cached, cacheConfig);
|
|
638
704
|
return {
|
|
639
705
|
success: true,
|
|
640
|
-
data:
|
|
706
|
+
data: cached,
|
|
641
707
|
status: 200,
|
|
642
708
|
headers: { "x-cache": "MISS" }
|
|
643
709
|
};
|
|
644
710
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
return {
|
|
653
|
-
success: true,
|
|
654
|
-
data: item,
|
|
655
|
-
status: 200
|
|
656
|
-
};
|
|
657
|
-
} catch (error) {
|
|
658
|
-
if (error instanceof Error && error.message?.includes("not found")) return {
|
|
659
|
-
success: false,
|
|
660
|
-
error: "Resource not found",
|
|
661
|
-
status: 404
|
|
662
|
-
};
|
|
663
|
-
throw error;
|
|
664
|
-
}
|
|
711
|
+
const { doc, reason } = await this.executeGetQuery(id, options, req);
|
|
712
|
+
if (!doc) return this.notFoundResponse(reason);
|
|
713
|
+
return {
|
|
714
|
+
success: true,
|
|
715
|
+
data: doc,
|
|
716
|
+
status: 200
|
|
717
|
+
};
|
|
665
718
|
}
|
|
666
719
|
/** Execute get query through hooks (extracted for cache revalidation) */
|
|
667
720
|
async executeGetQuery(id, options, req) {
|
|
668
721
|
const hooks = this.getHooks(req);
|
|
669
|
-
const fetchItem = async () =>
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
722
|
+
const fetchItem = async () => {
|
|
723
|
+
return await this.accessControl.fetchDetailed(id, req, this.repository, options);
|
|
724
|
+
};
|
|
725
|
+
if (hooks && this.resourceName) {
|
|
726
|
+
const result = await fetchItem();
|
|
727
|
+
if (!result.doc) return result;
|
|
728
|
+
return {
|
|
729
|
+
doc: await hooks.executeAround(this.resourceName, "read", null, async () => result.doc, {
|
|
730
|
+
user: req.user,
|
|
731
|
+
context: this.meta(req)
|
|
732
|
+
}) ?? null,
|
|
733
|
+
reason: null
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
return fetchItem();
|
|
674
737
|
}
|
|
675
738
|
async create(req) {
|
|
676
739
|
const arcContext = this.meta(req);
|
|
@@ -733,12 +796,8 @@ var BaseController = class {
|
|
|
733
796
|
const user = req.user;
|
|
734
797
|
const userId = getUserId(user);
|
|
735
798
|
if (userId) data.updatedBy = userId;
|
|
736
|
-
const existing = await this.accessControl.
|
|
737
|
-
if (!existing) return
|
|
738
|
-
success: false,
|
|
739
|
-
error: "Resource not found",
|
|
740
|
-
status: 404
|
|
741
|
-
};
|
|
799
|
+
const { doc: existing, reason: updateReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
|
|
800
|
+
if (!existing) return this.notFoundResponse(updateReason);
|
|
742
801
|
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
743
802
|
success: false,
|
|
744
803
|
error: "You do not have permission to modify this resource",
|
|
@@ -791,11 +850,7 @@ var BaseController = class {
|
|
|
791
850
|
}
|
|
792
851
|
});
|
|
793
852
|
} else item = await repoUpdate();
|
|
794
|
-
if (!item) return
|
|
795
|
-
success: false,
|
|
796
|
-
error: "Resource not found",
|
|
797
|
-
status: 404
|
|
798
|
-
};
|
|
853
|
+
if (!item) return this.notFoundResponse("NOT_FOUND");
|
|
799
854
|
return {
|
|
800
855
|
success: true,
|
|
801
856
|
data: item,
|
|
@@ -812,12 +867,8 @@ var BaseController = class {
|
|
|
812
867
|
};
|
|
813
868
|
const arcContext = this.meta(req);
|
|
814
869
|
const user = req.user;
|
|
815
|
-
const existing = await this.accessControl.
|
|
816
|
-
if (!existing) return
|
|
817
|
-
success: false,
|
|
818
|
-
error: "Resource not found",
|
|
819
|
-
status: 404
|
|
820
|
-
};
|
|
870
|
+
const { doc: existing, reason: deleteReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
|
|
871
|
+
if (!existing) return this.notFoundResponse(deleteReason);
|
|
821
872
|
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
822
873
|
success: false,
|
|
823
874
|
error: "You do not have permission to delete this resource",
|
|
@@ -862,11 +913,7 @@ var BaseController = class {
|
|
|
862
913
|
if (typeof r.success === "boolean") return r.success;
|
|
863
914
|
if (typeof r.deletedCount === "number") return r.deletedCount > 0;
|
|
864
915
|
return true;
|
|
865
|
-
})()) return
|
|
866
|
-
success: false,
|
|
867
|
-
error: "Resource not found",
|
|
868
|
-
status: 404
|
|
869
|
-
};
|
|
916
|
+
})()) return this.notFoundResponse("NOT_FOUND");
|
|
870
917
|
if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "delete", existing, {
|
|
871
918
|
user,
|
|
872
919
|
context: arcContext,
|
|
@@ -901,11 +948,7 @@ var BaseController = class {
|
|
|
901
948
|
error: "Slug lookup not implemented — repository needs getBySlug() or getOne()",
|
|
902
949
|
status: 501
|
|
903
950
|
};
|
|
904
|
-
if (!this.accessControl.validateItemAccess(item, req)) return
|
|
905
|
-
success: false,
|
|
906
|
-
error: "Resource not found",
|
|
907
|
-
status: 404
|
|
908
|
-
};
|
|
951
|
+
if (!this.accessControl.validateItemAccess(item, req)) return this.notFoundResponse(item ? "POLICY_FILTERED" : "NOT_FOUND");
|
|
909
952
|
return {
|
|
910
953
|
success: true,
|
|
911
954
|
data: item,
|
|
@@ -920,27 +963,9 @@ var BaseController = class {
|
|
|
920
963
|
status: 501
|
|
921
964
|
};
|
|
922
965
|
const parsed = this.queryResolver.resolve(req, this.meta(req));
|
|
923
|
-
const result = await repo.getDeleted(parsed, parsed);
|
|
924
|
-
if (Array.isArray(result)) {
|
|
925
|
-
const docs = result;
|
|
926
|
-
return {
|
|
927
|
-
success: true,
|
|
928
|
-
data: {
|
|
929
|
-
method: "offset",
|
|
930
|
-
docs,
|
|
931
|
-
page: 1,
|
|
932
|
-
limit: docs.length,
|
|
933
|
-
total: docs.length,
|
|
934
|
-
pages: 1,
|
|
935
|
-
hasNext: false,
|
|
936
|
-
hasPrev: false
|
|
937
|
-
},
|
|
938
|
-
status: 200
|
|
939
|
-
};
|
|
940
|
-
}
|
|
941
966
|
return {
|
|
942
967
|
success: true,
|
|
943
|
-
data:
|
|
968
|
+
data: await repo.getDeleted(parsed, parsed),
|
|
944
969
|
status: 200
|
|
945
970
|
};
|
|
946
971
|
}
|
|
@@ -958,11 +983,7 @@ var BaseController = class {
|
|
|
958
983
|
status: 400
|
|
959
984
|
};
|
|
960
985
|
const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
|
|
961
|
-
if (!existing) return
|
|
962
|
-
success: false,
|
|
963
|
-
error: "Resource not found",
|
|
964
|
-
status: 404
|
|
965
|
-
};
|
|
986
|
+
if (!existing) return this.notFoundResponse("NOT_FOUND");
|
|
966
987
|
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
967
988
|
success: false,
|
|
968
989
|
error: "You do not have permission to restore this resource",
|
|
@@ -998,11 +1019,7 @@ var BaseController = class {
|
|
|
998
1019
|
meta: { id }
|
|
999
1020
|
});
|
|
1000
1021
|
else item = await repoRestore();
|
|
1001
|
-
if (!item) return
|
|
1002
|
-
success: false,
|
|
1003
|
-
error: "Resource not found",
|
|
1004
|
-
status: 404
|
|
1005
|
-
};
|
|
1022
|
+
if (!item) return this.notFoundResponse("NOT_FOUND");
|
|
1006
1023
|
if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "restore", item, {
|
|
1007
1024
|
user,
|
|
1008
1025
|
context: arcContext,
|
|
@@ -1052,13 +1069,15 @@ var BaseController = class {
|
|
|
1052
1069
|
error: "Repository does not support createMany",
|
|
1053
1070
|
status: 501
|
|
1054
1071
|
};
|
|
1055
|
-
const
|
|
1056
|
-
if (!
|
|
1072
|
+
const rawItems = req.body?.items;
|
|
1073
|
+
if (!Array.isArray(rawItems) || rawItems.length === 0) return {
|
|
1057
1074
|
success: false,
|
|
1058
1075
|
error: "Bulk create requires a non-empty items array",
|
|
1059
1076
|
status: 400
|
|
1060
1077
|
};
|
|
1078
|
+
const items = rawItems;
|
|
1061
1079
|
const arcContext = this.meta(req);
|
|
1080
|
+
const user = req.user;
|
|
1062
1081
|
const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
|
|
1063
1082
|
let scopedItems = sanitizedItems;
|
|
1064
1083
|
if (this.tenantField) {
|
|
@@ -1086,7 +1105,10 @@ var BaseController = class {
|
|
|
1086
1105
|
}
|
|
1087
1106
|
}
|
|
1088
1107
|
}
|
|
1089
|
-
const created = await repo.createMany(scopedItems
|
|
1108
|
+
const created = await repo.createMany(scopedItems, {
|
|
1109
|
+
user,
|
|
1110
|
+
context: arcContext
|
|
1111
|
+
});
|
|
1090
1112
|
const requested = items.length;
|
|
1091
1113
|
const inserted = created.length;
|
|
1092
1114
|
const skipped = requested - inserted;
|
|
@@ -1157,13 +1179,23 @@ var BaseController = class {
|
|
|
1157
1179
|
*/
|
|
1158
1180
|
sanitizeBulkUpdateData(data, req, arcContext) {
|
|
1159
1181
|
const stripped = /* @__PURE__ */ new Set();
|
|
1160
|
-
|
|
1182
|
+
const keys = Object.keys(data);
|
|
1183
|
+
const operatorKeys = keys.filter((k) => k.startsWith("$"));
|
|
1184
|
+
const flatKeys = keys.filter((k) => !k.startsWith("$"));
|
|
1185
|
+
const isOperatorShape = operatorKeys.length > 0;
|
|
1186
|
+
if (isOperatorShape && flatKeys.length > 0) return {
|
|
1187
|
+
sanitized: {},
|
|
1188
|
+
stripped: [],
|
|
1189
|
+
mixedShape: true
|
|
1190
|
+
};
|
|
1191
|
+
if (!isOperatorShape) {
|
|
1161
1192
|
const before = new Set(Object.keys(data));
|
|
1162
1193
|
const sanitized = this.bodySanitizer.sanitize(data, "update", req, arcContext);
|
|
1163
1194
|
for (const key of before) if (!(key in sanitized)) stripped.add(key);
|
|
1164
1195
|
return {
|
|
1165
1196
|
sanitized,
|
|
1166
|
-
stripped: [...stripped]
|
|
1197
|
+
stripped: [...stripped],
|
|
1198
|
+
mixedShape: false
|
|
1167
1199
|
};
|
|
1168
1200
|
}
|
|
1169
1201
|
const sanitized = {};
|
|
@@ -1180,7 +1212,8 @@ var BaseController = class {
|
|
|
1180
1212
|
}
|
|
1181
1213
|
return {
|
|
1182
1214
|
sanitized,
|
|
1183
|
-
stripped: [...stripped]
|
|
1215
|
+
stripped: [...stripped],
|
|
1216
|
+
mixedShape: false
|
|
1184
1217
|
};
|
|
1185
1218
|
}
|
|
1186
1219
|
async bulkUpdate(req) {
|
|
@@ -1209,7 +1242,14 @@ var BaseController = class {
|
|
|
1209
1242
|
status: 403
|
|
1210
1243
|
};
|
|
1211
1244
|
const arcContext = this.meta(req);
|
|
1212
|
-
const
|
|
1245
|
+
const user = req.user;
|
|
1246
|
+
const { sanitized, stripped, mixedShape } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
|
|
1247
|
+
if (mixedShape) return {
|
|
1248
|
+
success: false,
|
|
1249
|
+
error: "Bulk update payload cannot mix operator keys ($set, $inc, ...) with flat fields. Pick one shape.",
|
|
1250
|
+
details: { code: "MIXED_UPDATE_SHAPE" },
|
|
1251
|
+
status: 400
|
|
1252
|
+
};
|
|
1213
1253
|
if (Object.keys(sanitized).length === 0) return {
|
|
1214
1254
|
success: false,
|
|
1215
1255
|
error: "Bulk update payload contained only protected fields",
|
|
@@ -1221,7 +1261,10 @@ var BaseController = class {
|
|
|
1221
1261
|
};
|
|
1222
1262
|
return {
|
|
1223
1263
|
success: true,
|
|
1224
|
-
data: await repo.updateMany(scopedFilter, sanitized
|
|
1264
|
+
data: await repo.updateMany(scopedFilter, sanitized, {
|
|
1265
|
+
user,
|
|
1266
|
+
context: arcContext
|
|
1267
|
+
}),
|
|
1225
1268
|
status: 200,
|
|
1226
1269
|
...stripped.length > 0 && { meta: { stripped } }
|
|
1227
1270
|
};
|
|
@@ -1272,9 +1315,16 @@ var BaseController = class {
|
|
|
1272
1315
|
details: { code: "ORG_CONTEXT_REQUIRED" },
|
|
1273
1316
|
status: 403
|
|
1274
1317
|
};
|
|
1318
|
+
const hardHint = req.query?.hard === "true" || req.query?.hard === true || body.mode === "hard";
|
|
1319
|
+
const arcContext = this.meta(req);
|
|
1320
|
+
const options = {
|
|
1321
|
+
user: req.user,
|
|
1322
|
+
context: arcContext
|
|
1323
|
+
};
|
|
1324
|
+
if (hardHint) options.mode = "hard";
|
|
1275
1325
|
return {
|
|
1276
1326
|
success: true,
|
|
1277
|
-
data:
|
|
1327
|
+
data: await repo.deleteMany(scopedFilter, options),
|
|
1278
1328
|
status: 200
|
|
1279
1329
|
};
|
|
1280
1330
|
}
|