@classytic/arc 2.18.2 → 2.18.4
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/dist/{BaseController-DPwe89qs.mjs → BaseController-B-uXzf6I.mjs} +403 -216
- package/dist/{QueryCache-8YmAQ-zW.d.mts → QueryCache-Dsyv2mLS.d.mts} +1 -1
- package/dist/audit/index.d.mts +1 -1
- package/dist/auth/index.d.mts +4 -4
- package/dist/auth/index.mjs +4 -4
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi-ClWxaceA.mjs → betterAuthOpenApi-DAAhMZff.mjs} +1 -1
- package/dist/{buildHandler-C_SkBNF3.mjs → buildHandler-Cvrc2p7r.mjs} +1 -1
- package/dist/cache/index.d.mts +3 -3
- package/dist/cache/index.mjs +3 -3
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/describe.mjs +1 -1
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.d.mts +1 -1
- package/dist/cli/commands/generate.mjs +1 -1
- package/dist/cli/commands/init.d.mts +1 -1
- package/dist/cli/commands/init.mjs +1 -1
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/cli/index.d.mts +2 -2
- package/dist/cli/index.mjs +2 -2
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +3 -3
- package/dist/{core-B41eZxyM.mjs → core-C88uIwd3.mjs} +161 -103
- package/dist/{createActionRouter-CRaIwB5S.mjs → createActionRouter-DGCCp-kP.mjs} +4 -2
- package/dist/{createAggregationRouter-n3O6WdLO.mjs → createAggregationRouter-Ca77b8iK.mjs} +3 -3
- package/dist/{createApp-DZvrb587.mjs → createApp-J1iPXY9p.mjs} +11 -11
- package/dist/{defineEvent-NPKUJRt8.mjs → defineEvent-DC981g-o.mjs} +1 -1
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-6mfTLBC-.mjs → elevation-BzhyVWfn.mjs} +1 -1
- package/dist/{eventPlugin-Dxp9JlKK.mjs → eventPlugin-Bw4bUDEO.mjs} +2 -2
- package/dist/{eventPlugin-DjEx_J3j.d.mts → eventPlugin-C1W1CRbf.d.mts} +1 -1
- package/dist/events/index.d.mts +8 -5
- package/dist/events/index.mjs +26 -7
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +52 -5
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +3 -3
- package/dist/{fastify-augmentation-CL48B6gW.d.mts → fastify-augmentation-BdCl_eNo.d.mts} +2 -2
- package/dist/{fields-DqZLp5d6.d.mts → fields-CKLrhyOb.d.mts} +1 -1
- package/dist/{generate-CcM24hoq.mjs → generate-BaxDdmJ8.mjs} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +2 -2
- package/dist/idempotency/index.mjs +1 -1
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-CNiryUMN.d.mts → index-Cvo1u9n6.d.mts} +3 -3
- package/dist/{index-C000faom.d.mts → index-QYma8mdC.d.mts} +215 -241
- package/dist/{index-3L-adFJO.d.mts → index-vcWUGkwf.d.mts} +3 -3
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +5 -5
- package/dist/{init-DQ_rY1J7.mjs → init-DSsdRfk9.mjs} +3 -3
- 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/jobs.mjs +66 -1
- package/dist/integrations/mcp/index.d.mts +4 -4
- 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 +16 -1
- package/dist/integrations/streamline.mjs +33 -2
- package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +2 -2
- package/dist/{loadResourcesFromEntry-DCjYMgfC.mjs → loadResourcesFromEntry-XHNeUWpu.mjs} +1 -1
- package/dist/{mcpPlugin-BrepZ0cV.mjs → mcpPlugin-CzKdt3B8.mjs} +9 -9
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +1 -1
- package/dist/{openapi-CLZx9bwS.mjs → openapi-ButnRyOM.mjs} +5 -5
- package/dist/permissions/index.d.mts +2 -2
- package/dist/permissions/index.mjs +2 -2
- package/dist/{permissions-B1Qy55fg.mjs → permissions-3uzwkbQt.mjs} +99 -99
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +1 -1
- package/dist/pipeline-D4sqiokR.mjs +336 -0
- package/dist/plugins/index.d.mts +5 -5
- package/dist/plugins/index.mjs +9 -9
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +4 -4
- package/dist/presets/filesUpload.mjs +2 -2
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-BYbPobbF.mjs → presets-CF79RHEo.mjs} +1 -1
- package/dist/{queryCachePlugin-Do4P_WBC.d.mts → queryCachePlugin-Bd2tYgh5.d.mts} +2 -2
- package/dist/{queryCachePlugin-B4XMSSe7.mjs → queryCachePlugin-CnAeFqju.mjs} +3 -3
- package/dist/rateLimit-yKvxWAky.mjs +207 -0
- package/dist/{redis-cpqVb85w.d.mts → redis-3hqH_Qk_.d.mts} +1 -1
- package/dist/{redis-stream-BuiAS3aE.d.mts → redis-stream-BtEUtjxZ.d.mts} +5 -4
- package/dist/registerRoute-BEYn0ylk.mjs +108 -0
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{schemaIR-Bk6Tns82.mjs → schemaIR-CE6USIRi.mjs} +1 -1
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +2 -2
- package/dist/{sse-jh5OqFjR.mjs → sse-BrY1W6U8.mjs} +11 -2
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/index.mjs +12 -2
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-DH-7WO64.d.mts → types-BWBcb88c.d.mts} +1 -1
- package/dist/{types-DEEWwQFR.d.mts → types-WVlAVp8T.d.mts} +10 -10
- package/dist/utils/index.d.mts +3 -3
- package/dist/utils/index.mjs +3 -3
- package/dist/{utils-BMQK8mRl.mjs → utils-xAQeDyU_.mjs} +1 -1
- package/dist/{validate-DFIP2VrM.mjs → validate-0__MuUsK.mjs} +201 -190
- package/dist/{versioning-5H4930tj.d.mts → versioning-BSEDGXKC.d.mts} +1 -1
- package/dist/{websocket-BZMUP7Gr.d.mts → websocket-BFBT5rH-.d.mts} +1 -1
- package/package.json +5 -5
- package/skills/arc/references/api-reference.md +1 -1
- package/dist/routerShared-Dl0A77Ba.mjs +0 -724
- /package/dist/{EventTransport-ClEDfiMS.mjs → EventTransport-7LPXLEUX.mjs} +0 -0
- /package/dist/{EventTransport-B1GFiYUy.d.mts → EventTransport-CaaXM5Y1.d.mts} +0 -0
- /package/dist/{HookSystem-Cmf7-Etp.mjs → HookSystem-BfCpWbwM.mjs} +0 -0
- /package/dist/{ResourceRegistry-CzSzVK1m.mjs → ResourceRegistry-CWmOggxS.mjs} +0 -0
- /package/dist/{actionPermissions-Bjmvn7Eb.mjs → actionPermissions-CX9QIMTd.mjs} +0 -0
- /package/dist/{caching-8MfrrUYU.mjs → caching-C0DdKiYB.mjs} +0 -0
- /package/dist/{elevation-DvwtjDwu.d.mts → elevation-DW90z5bT.d.mts} +0 -0
- /package/dist/{errorHandler-wQejMBT3.mjs → errorHandler-Bua97SEt.mjs} +0 -0
- /package/dist/{errorHandler-DuqTqeqn.d.mts → errorHandler-_Lp2k9M3.d.mts} +0 -0
- /package/dist/{errors-4ZloYFvm.d.mts → errors-DZOemeGa.d.mts} +0 -0
- /package/dist/{externalPaths-ClykOQqa.d.mts → externalPaths-DLle0R07.d.mts} +0 -0
- /package/dist/{generate-cRnsafMS.d.mts → generate-Dmk8cfvX.d.mts} +0 -0
- /package/dist/{init-D-vrTZ1G.d.mts → init-BS4Daqnv.d.mts} +0 -0
- /package/dist/{interface-DoE6UoC2.d.mts → interface-CsrGR8LF.d.mts} +0 -0
- /package/dist/{interface-BVkZsvEe.d.mts → interface-DL7LlWS5.d.mts} +0 -0
- /package/dist/{keys-DopsCuyQ.mjs → keys-B3yyBEyH.mjs} +0 -0
- /package/dist/{loadResources-fol6JuvW.mjs → loadResources-CwXt9MCh.mjs} +0 -0
- /package/dist/{memory-QOLe11D5.mjs → memory-TdTHCS9-.mjs} +0 -0
- /package/dist/{metrics-B-PU4-Yu.mjs → metrics-Da2EUPi9.mjs} +0 -0
- /package/dist/{multipartBody-VjVWmZl8.mjs → multipartBody-709YcXi1.mjs} +0 -0
- /package/dist/{pipe-DiCyvyPN.mjs → pipe-9Tl-OAus.mjs} +0 -0
- /package/dist/{pluralize-B9M8xvy-.mjs → pluralize-BxdUl2P4.mjs} +0 -0
- /package/dist/{preloadResources-BOXzPhKU.mjs → preloadResources-B-j-iqo-.mjs} +0 -0
- /package/dist/{pushref-registry-Bq_-EVsC.d.mts → pushref-registry-Dqclzjy7.d.mts} +0 -0
- /package/dist/{registry-BrlHkrRS.mjs → registry-Br15xdGM.mjs} +0 -0
- /package/dist/{replyHelpers-boS5Rx26.mjs → replyHelpers-DXHVxrrj.mjs} +0 -0
- /package/dist/{schemaConverter-De34B1ZG.mjs → schemaConverter-DatAaVNj.mjs} +0 -0
- /package/dist/{sessionManager-DlmaHUAl.d.mts → sessionManager-CyHpYXoB.d.mts} +0 -0
- /package/dist/{storage-BFmamN5m.d.mts → storage-C5q8fuEt.d.mts} +0 -0
- /package/dist/{store-helpers-5JgaxieH.mjs → store-helpers-DjSYI2uP.mjs} +0 -0
- /package/dist/{tracing-BYQ3hsbM.d.mts → tracing-Bnb1-YVd.d.mts} +0 -0
- /package/dist/{typeGuards-BzkXkvVv.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
- /package/dist/{types-CfapW2t8.d.mts → types-BJwJN2Pg.d.mts} +0 -0
- /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
- /package/dist/{versioning-CuIwUQZ2.mjs → versioning-C0K8KQLx.mjs} +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { o as DEFAULT_TENANT_FIELD } from "./constants-TrJVIJl0.mjs";
|
|
1
|
+
import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-TrJVIJl0.mjs";
|
|
2
2
|
import { arcLog } from "./logger/index.mjs";
|
|
3
3
|
import { d as createDomainError, f as createError, i as NotFoundError, r as ForbiddenError } from "./errors-j4aJm1Wg.mjs";
|
|
4
|
-
import { b as isElevated, c as getOrgId } from "./types-Bi0r0vjG.mjs";
|
|
5
|
-
import { I as simpleEqualityMatcher, b as ArcQueryParser, o as scheduleBackground, t as getUserId } from "./utils-
|
|
6
|
-
import {
|
|
7
|
-
import { t as
|
|
4
|
+
import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-Bi0r0vjG.mjs";
|
|
5
|
+
import { I as simpleEqualityMatcher, b as ArcQueryParser, o as scheduleBackground, t as getUserId } from "./utils-xAQeDyU_.mjs";
|
|
6
|
+
import { F as applyFieldWritePermissions, L as resolveEffectiveRoles } from "./permissions-3uzwkbQt.mjs";
|
|
7
|
+
import { t as getUserRoles } from "./types-D57iXYb8.mjs";
|
|
8
|
+
import { t as buildQueryKey } from "./keys-B3yyBEyH.mjs";
|
|
8
9
|
//#region src/core/AccessControl.ts
|
|
9
10
|
const log = arcLog("access-control");
|
|
10
11
|
var AccessControl = class {
|
|
@@ -241,6 +242,337 @@ var AccessControl = class {
|
|
|
241
242
|
}
|
|
242
243
|
};
|
|
243
244
|
//#endregion
|
|
245
|
+
//#region src/core/BodySanitizer.ts
|
|
246
|
+
/**
|
|
247
|
+
* BodySanitizer - Composable body sanitization logic extracted from BaseController.
|
|
248
|
+
*
|
|
249
|
+
* Strips readonly fields, system-managed fields, and applies field-level
|
|
250
|
+
* write permissions from request bodies before create/update operations.
|
|
251
|
+
*
|
|
252
|
+
* Designed to be used standalone or composed into controllers.
|
|
253
|
+
*/
|
|
254
|
+
const DEFAULT_FIELD_WRITE_DENIAL_POLICY = "reject";
|
|
255
|
+
var BodySanitizer = class {
|
|
256
|
+
schemaOptions;
|
|
257
|
+
onFieldWriteDenied;
|
|
258
|
+
constructor(config) {
|
|
259
|
+
this.schemaOptions = config.schemaOptions;
|
|
260
|
+
this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Strip readonly and system-managed fields from request body.
|
|
264
|
+
* Prevents clients from overwriting _id, timestamps, __v, etc.
|
|
265
|
+
*
|
|
266
|
+
* Also applies field-level write permissions when the request has
|
|
267
|
+
* field permission metadata.
|
|
268
|
+
*/
|
|
269
|
+
sanitize(body, _operation, req, meta) {
|
|
270
|
+
let sanitized = { ...body };
|
|
271
|
+
for (const field of SYSTEM_FIELDS) delete sanitized[field];
|
|
272
|
+
const scopeForRules = req ? (meta ?? req.metadata)?._scope ?? PUBLIC_SCOPE : void 0;
|
|
273
|
+
const scopeIsElevated = scopeForRules ? isElevated(scopeForRules) : false;
|
|
274
|
+
const fieldRules = this.schemaOptions.fieldRules ?? {};
|
|
275
|
+
for (const [field, rules] of Object.entries(fieldRules)) {
|
|
276
|
+
const bypass = Boolean(rules.preserveForElevated) && scopeIsElevated;
|
|
277
|
+
if ((rules.systemManaged || rules.readonly) && !bypass) delete sanitized[field];
|
|
278
|
+
if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate) && !bypass) delete sanitized[field];
|
|
279
|
+
}
|
|
280
|
+
if (req) {
|
|
281
|
+
const arcContext = meta ?? req.metadata;
|
|
282
|
+
const scope = arcContext?._scope ?? PUBLIC_SCOPE;
|
|
283
|
+
if (!isElevated(scope)) {
|
|
284
|
+
const fieldPerms = arcContext?.arc?.fields;
|
|
285
|
+
if (fieldPerms) {
|
|
286
|
+
const effectiveRoles = resolveEffectiveRoles(getUserRoles(req.user), isMember(scope) ? scope.orgRoles : []);
|
|
287
|
+
const { body: filtered, deniedFields } = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
|
|
288
|
+
if (deniedFields.length > 0 && this.onFieldWriteDenied === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
|
|
289
|
+
sanitized = filtered;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return sanitized;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/core/crud/requestPipeline.ts
|
|
298
|
+
/**
|
|
299
|
+
* Build the canonical repo-options bag from the Fastify request.
|
|
300
|
+
*
|
|
301
|
+
* Forwards the cross-kit canonical set (see repo-core's
|
|
302
|
+
* `STANDARD_REPO_OPTION_KEYS`) into every CRUD repo call so kit
|
|
303
|
+
* plugins (multi-tenant, audit, audit-trail, observability) get
|
|
304
|
+
* what they need without per-resource wiring:
|
|
305
|
+
*
|
|
306
|
+
* - **Tenant scope** — `[tenantField]: orgId` from `RequestScope`.
|
|
307
|
+
* Plugin-scoped repos (mongokit's `multiTenantPlugin`) read tenant
|
|
308
|
+
* scope from the TOP of the options bag, not `data.organizationId`.
|
|
309
|
+
* Without this stamping, a tenant-scoped repo throws "Missing
|
|
310
|
+
* 'organizationId' in context" even when arc has injected the
|
|
311
|
+
* tenant into the request body.
|
|
312
|
+
* Multi-field tenancy from `_tenantFields` (populated by
|
|
313
|
+
* `multiTenantPreset`) is merged in.
|
|
314
|
+
*
|
|
315
|
+
* - **Audit attribution** — `userId` + `user` from the authenticated
|
|
316
|
+
* actor. Mongokit's audit-log / audit-trail plugins read these
|
|
317
|
+
* into the `who` column; sqlitekit's audit plugin reads the same
|
|
318
|
+
* names. No host-side forwarding needed.
|
|
319
|
+
*
|
|
320
|
+
* - **Trace correlation** — `requestId` from Fastify's request id
|
|
321
|
+
* for stitching logs / events / downstream calls, plus `traceId`
|
|
322
|
+
* (the 32-hex W3C trace id, canonical repo-option key since
|
|
323
|
+
* repo-core 0.6) when the requestId plugin validated an incoming
|
|
324
|
+
* `traceparent` header.
|
|
325
|
+
*
|
|
326
|
+
* - **`session` is intentionally NOT auto-set.** Sessions are tied
|
|
327
|
+
* to explicit transaction scopes the controller doesn't manage;
|
|
328
|
+
* pass `session` inline at the call site when running inside a
|
|
329
|
+
* `withTransaction` helper.
|
|
330
|
+
*/
|
|
331
|
+
function buildTenantRepoOptions(req, tenantField, arcContext) {
|
|
332
|
+
const cached = req._tenantRepoOptions;
|
|
333
|
+
if (cached) return cached;
|
|
334
|
+
const out = {};
|
|
335
|
+
const scope = arcContext?._scope;
|
|
336
|
+
if (tenantField) {
|
|
337
|
+
const orgId = scope ? getOrgId(scope) : void 0;
|
|
338
|
+
if (orgId) out[tenantField] = orgId;
|
|
339
|
+
}
|
|
340
|
+
const presetFields = req._tenantFields;
|
|
341
|
+
if (presetFields && typeof presetFields === "object") {
|
|
342
|
+
for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
|
|
343
|
+
}
|
|
344
|
+
if (tenantField && out[tenantField] == null && scope && isElevated(scope)) out.bypassTenant = true;
|
|
345
|
+
const userId = getUserId(req.user);
|
|
346
|
+
if (userId) out.userId = userId;
|
|
347
|
+
if (req.user) out.user = req.user;
|
|
348
|
+
const requestId = req.id;
|
|
349
|
+
if (requestId) out.requestId = requestId;
|
|
350
|
+
const traceparent = req.traceContext?.traceparent;
|
|
351
|
+
if (traceparent) {
|
|
352
|
+
const traceId = traceparent.split("-")[1];
|
|
353
|
+
if (traceId) out.traceId = traceId;
|
|
354
|
+
}
|
|
355
|
+
Object.freeze(out);
|
|
356
|
+
req._tenantRepoOptions = out;
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Resolve the repository primary key for mutation calls.
|
|
361
|
+
*
|
|
362
|
+
* When the resource declares a custom `idField` (slug, jobId, UUID), the
|
|
363
|
+
* default behavior is to translate the route id → the fetched doc's `_id`
|
|
364
|
+
* because most Mongo repositories key mutation methods off `_id`.
|
|
365
|
+
*
|
|
366
|
+
* Exception: if the repo exposes a matching `idField` property (e.g.
|
|
367
|
+
* MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
|
|
368
|
+
* repo handles lookup itself — pass the route id through unchanged.
|
|
369
|
+
*/
|
|
370
|
+
function resolveMutationRepoId(id, existing, idField, repository) {
|
|
371
|
+
if (idField === "_id") return id;
|
|
372
|
+
if (!existing) return id;
|
|
373
|
+
const repoIdField = repository.idField;
|
|
374
|
+
if (repoIdField && repoIdField === idField) return id;
|
|
375
|
+
return String(existing["_id"] ?? id);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Build the canonical CRUD 404. Maps the denial reason from
|
|
379
|
+
* `AccessControl.fetchDetailed()` into a `NotFoundError` so consumers can
|
|
380
|
+
* distinguish "doc doesn't exist" from "doc filtered by policy/org scope"
|
|
381
|
+
* via the error `details.code` set by the global error handler.
|
|
382
|
+
*/
|
|
383
|
+
function buildNotFoundError(reason, resourceName) {
|
|
384
|
+
const code = reason ?? "NOT_FOUND";
|
|
385
|
+
const err = new NotFoundError(resourceName ?? "Resource");
|
|
386
|
+
err.details = {
|
|
387
|
+
...err.details ?? {},
|
|
388
|
+
code
|
|
389
|
+
};
|
|
390
|
+
return err;
|
|
391
|
+
}
|
|
392
|
+
/** Resolve cache config for a specific operation, merging per-op overrides. */
|
|
393
|
+
function resolveOpCacheConfig(cfg, operation) {
|
|
394
|
+
if (!cfg || cfg.disabled) return null;
|
|
395
|
+
const opOverride = cfg[operation];
|
|
396
|
+
return {
|
|
397
|
+
staleTime: opOverride?.staleTime ?? cfg.staleTime ?? 0,
|
|
398
|
+
gcTime: opOverride?.gcTime ?? cfg.gcTime ?? 60,
|
|
399
|
+
tags: cfg.tags
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Extract user/org IDs from request for cache key scoping.
|
|
404
|
+
* Only includes orgId when the resource uses tenant-scoped data (tenantField is set).
|
|
405
|
+
* Universal resources (tenantField: false) get shared cache keys.
|
|
406
|
+
*/
|
|
407
|
+
function deriveCacheScope(req, tenantField, arcContext) {
|
|
408
|
+
return {
|
|
409
|
+
userId: getUserId(req.user),
|
|
410
|
+
orgId: tenantField ? (() => {
|
|
411
|
+
const scope = arcContext?._scope;
|
|
412
|
+
return scope ? getOrgId(scope) : void 0;
|
|
413
|
+
})() : void 0
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/** Shared `x-cache` response envelope builder. */
|
|
417
|
+
function buildCacheEnvelope(data, cacheStatus) {
|
|
418
|
+
return {
|
|
419
|
+
data,
|
|
420
|
+
status: 200,
|
|
421
|
+
headers: { "x-cache": cacheStatus }
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Run `executeBefore` then `executeAround` (or just the executor if no
|
|
426
|
+
* hooks are wired). Returns the around-phase result directly. Throws an
|
|
427
|
+
* `ArcError` (status 400, code `BEFORE_<OP>_HOOK_ERROR`) when the
|
|
428
|
+
* before-hook fails — the global error handler emits the canonical
|
|
429
|
+
* `ErrorContract` shape.
|
|
430
|
+
*
|
|
431
|
+
* The caller runs `executeAfter` separately via `executeAfterHook` —
|
|
432
|
+
* typically after success-checking the result (delete checks the repo
|
|
433
|
+
* result, update checks `if (!item)`).
|
|
434
|
+
*
|
|
435
|
+
* **Knobs:**
|
|
436
|
+
* - `meta` — passed verbatim into `executeBefore` / `executeAround` opts.
|
|
437
|
+
* - `pipeProcessedData` (default `true`) — whether `executeBefore`'s
|
|
438
|
+
* return value flows into `executeAround` as the data parameter.
|
|
439
|
+
* Set `false` for delete (current behaviour: discards before's
|
|
440
|
+
* return, passes original input to around).
|
|
441
|
+
*/
|
|
442
|
+
async function executeHookedOp(ctx, args, executor) {
|
|
443
|
+
const { hooks, resourceName, arcContext, user } = ctx;
|
|
444
|
+
const hookOpts = {
|
|
445
|
+
user,
|
|
446
|
+
context: arcContext,
|
|
447
|
+
...args.meta ? { meta: args.meta } : {}
|
|
448
|
+
};
|
|
449
|
+
const pipeProcessed = args.pipeProcessedData !== false;
|
|
450
|
+
let processedData = args.input;
|
|
451
|
+
if (hooks && resourceName) try {
|
|
452
|
+
const beforeReturn = await hooks.executeBefore(resourceName, args.op, args.input, hookOpts);
|
|
453
|
+
if (pipeProcessed) processedData = beforeReturn;
|
|
454
|
+
} catch (err) {
|
|
455
|
+
throw createError(400, "Hook execution failed", {
|
|
456
|
+
code: `BEFORE_${args.op.toUpperCase()}_HOOK_ERROR`,
|
|
457
|
+
message: err.message
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
let result;
|
|
461
|
+
if (hooks && resourceName) result = await hooks.executeAround(resourceName, args.op, processedData, () => executor(processedData), hookOpts);
|
|
462
|
+
else result = await executor(processedData);
|
|
463
|
+
return result;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Run `executeAfter` for the given op + data. No-op when hooks aren't
|
|
467
|
+
* wired or `resourceName` isn't set. Caller passes the data shape it
|
|
468
|
+
* wants downstream after-handlers to receive — typically the result for
|
|
469
|
+
* create/update, the original input (`existing`) for delete.
|
|
470
|
+
*/
|
|
471
|
+
async function executeAfterHook(ctx, op, data, meta) {
|
|
472
|
+
const { hooks, resourceName, arcContext, user } = ctx;
|
|
473
|
+
if (!hooks || !resourceName) return;
|
|
474
|
+
await hooks.executeAfter(resourceName, op, data, {
|
|
475
|
+
user,
|
|
476
|
+
context: arcContext,
|
|
477
|
+
...meta ? { meta } : {}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
//#endregion
|
|
481
|
+
//#region src/utils/repoFeature.ts
|
|
482
|
+
/**
|
|
483
|
+
* Repository feature-detection helper for arc mixins.
|
|
484
|
+
*
|
|
485
|
+
* Arc's `RepositoryLike` is the minimum contract every adapter
|
|
486
|
+
* implements (`getById`, `getOne`, `create`, `update`, `delete`,
|
|
487
|
+
* `find`). Beyond that, kits MAY expose specialized methods —
|
|
488
|
+
* `createMany`, `updateMany`, `deleteMany`, `getTree`, `getBySlug`,
|
|
489
|
+
* `getDeleted`, `restore`, etc. — that arc mixins detect at call
|
|
490
|
+
* time and surface as 501 when missing.
|
|
491
|
+
*
|
|
492
|
+
* Every mixin used to spell out its own narrowing cast inline:
|
|
493
|
+
*
|
|
494
|
+
* ```ts
|
|
495
|
+
* const repo = this.repository as unknown as {
|
|
496
|
+
* getTree?: (options?: unknown) => Promise<AnyRecord[]>;
|
|
497
|
+
* };
|
|
498
|
+
* ```
|
|
499
|
+
*
|
|
500
|
+
* Centralizing the cast keeps every feature-detection site reading
|
|
501
|
+
* the same shape. The helper does not intersect with `RepositoryLike`
|
|
502
|
+
* on purpose: some standard methods (`createMany`, `updateMany`) live
|
|
503
|
+
* on `StandardRepo` with kit-generic signatures, and intersecting
|
|
504
|
+
* would force the caller's narrowed signature to merge with the wider
|
|
505
|
+
* default, producing mismatched return types. Declare every method
|
|
506
|
+
* you need (base or feature) in `TFeatures`.
|
|
507
|
+
*
|
|
508
|
+
* @example
|
|
509
|
+
* ```ts
|
|
510
|
+
* const repo = withRepoFeatures<{
|
|
511
|
+
* createMany?: (items: unknown[], options?: unknown) => Promise<AnyRecord[]>;
|
|
512
|
+
* }>(this.repository);
|
|
513
|
+
* if (!repo.createMany) throw createError(501, "...");
|
|
514
|
+
* ```
|
|
515
|
+
*/
|
|
516
|
+
function withRepoFeatures(repo) {
|
|
517
|
+
return repo;
|
|
518
|
+
}
|
|
519
|
+
//#endregion
|
|
520
|
+
//#region src/core/crud/resourceVerbs.ts
|
|
521
|
+
const isTruthyFlag = (value) => value !== void 0 && value !== "" && value !== "false" && value !== false;
|
|
522
|
+
/**
|
|
523
|
+
* Match a request query against the resource-dispatch verbs. Returns
|
|
524
|
+
* `null` when the request is a regular list query.
|
|
525
|
+
*
|
|
526
|
+
* Verbs (mutually exclusive — first match wins):
|
|
527
|
+
* - `?_count=true` → `{ kind: 'count' }`
|
|
528
|
+
* - `?_distinct=field` → `{ kind: 'distinct', field }`
|
|
529
|
+
* - `?_exists=true` → `{ kind: 'exists' }`
|
|
530
|
+
*/
|
|
531
|
+
function matchResourceVerb(query) {
|
|
532
|
+
if (!query) return null;
|
|
533
|
+
if (isTruthyFlag(query._count)) return { kind: "count" };
|
|
534
|
+
const distinctField = query._distinct;
|
|
535
|
+
if (typeof distinctField === "string" && distinctField.length > 0) return {
|
|
536
|
+
kind: "distinct",
|
|
537
|
+
field: distinctField
|
|
538
|
+
};
|
|
539
|
+
if (isTruthyFlag(query._exists)) return { kind: "exists" };
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Normalizes `repo.exists()` return shapes across adapters. Per
|
|
544
|
+
* StandardRepo's contract, `exists` may return `boolean`, `{ _id }`,
|
|
545
|
+
* or `null` — every truthy non-null shape collapses to `true`.
|
|
546
|
+
*/
|
|
547
|
+
function isExistsResultTruthy(result) {
|
|
548
|
+
return result !== null && result !== false && result !== void 0;
|
|
549
|
+
}
|
|
550
|
+
/** `?_count=true` → `repo.count(filter)` */
|
|
551
|
+
async function runCountVerb(repository, resolveScope) {
|
|
552
|
+
const repo = withRepoFeatures(repository);
|
|
553
|
+
if (typeof repo.count !== "function") throw createError(501, "_count is not supported: the resource's storage adapter does not implement repo.count()");
|
|
554
|
+
const { filter, options } = resolveScope();
|
|
555
|
+
return { count: await repo.count(filter, options) };
|
|
556
|
+
}
|
|
557
|
+
/** `?_distinct=field` → `repo.distinct(field, filter)` */
|
|
558
|
+
async function runDistinctVerb(repository, field, resolveScope) {
|
|
559
|
+
const repo = withRepoFeatures(repository);
|
|
560
|
+
if (typeof repo.distinct !== "function") throw createError(501, "_distinct is not supported: the resource's storage adapter does not implement repo.distinct()");
|
|
561
|
+
const { filter, options } = resolveScope();
|
|
562
|
+
return await repo.distinct(field, filter, options);
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* `?_exists=true` → `repo.exists(filter)`. Returns the RAW repo result —
|
|
566
|
+
* the controller collapses it via its (overridable) `isExistsTruthy`
|
|
567
|
+
* so subclass overrides of the truthiness rule keep working.
|
|
568
|
+
*/
|
|
569
|
+
async function runExistsVerb(repository, resolveScope) {
|
|
570
|
+
const repo = withRepoFeatures(repository);
|
|
571
|
+
if (typeof repo.exists !== "function") throw createError(501, "_exists is not supported: the resource's storage adapter does not implement repo.exists()");
|
|
572
|
+
const { filter, options } = resolveScope();
|
|
573
|
+
return await repo.exists(filter, options);
|
|
574
|
+
}
|
|
575
|
+
//#endregion
|
|
244
576
|
//#region src/core/fieldRulePredicates.ts
|
|
245
577
|
/**
|
|
246
578
|
* True when the field is allowed to appear in client-readable surfaces
|
|
@@ -466,6 +798,15 @@ var BaseCrudController = class {
|
|
|
466
798
|
_matchesFilter;
|
|
467
799
|
_presetFields = {};
|
|
468
800
|
_cacheConfig;
|
|
801
|
+
/**
|
|
802
|
+
* Retained construction-time values so partial `configure()` rebuilds
|
|
803
|
+
* (e.g. `configure({ schemaOptions })`) reuse them instead of silently
|
|
804
|
+
* falling back to the sub-component defaults (`-createdAt` sort,
|
|
805
|
+
* `reject` write-denial) — `defaultSort: false` matters for kits
|
|
806
|
+
* without a `createdAt` column, `strip` for legacy write semantics.
|
|
807
|
+
*/
|
|
808
|
+
_defaultSort;
|
|
809
|
+
_onFieldWriteDenied;
|
|
469
810
|
constructor(repository, options = {}) {
|
|
470
811
|
this.repository = repository;
|
|
471
812
|
this.schemaOptions = options.schemaOptions ?? {};
|
|
@@ -478,6 +819,8 @@ var BaseCrudController = class {
|
|
|
478
819
|
this._matchesFilter = options.matchesFilter;
|
|
479
820
|
if (options.cache) this._cacheConfig = options.cache;
|
|
480
821
|
if (options.presetFields) this._presetFields = options.presetFields;
|
|
822
|
+
this._defaultSort = options.defaultSort;
|
|
823
|
+
this._onFieldWriteDenied = options.onFieldWriteDenied;
|
|
481
824
|
this.accessControl = new AccessControl({
|
|
482
825
|
tenantField: this.tenantField,
|
|
483
826
|
idField: this.idField,
|
|
@@ -485,13 +828,13 @@ var BaseCrudController = class {
|
|
|
485
828
|
});
|
|
486
829
|
this.bodySanitizer = new BodySanitizer({
|
|
487
830
|
schemaOptions: this.schemaOptions,
|
|
488
|
-
onFieldWriteDenied:
|
|
831
|
+
onFieldWriteDenied: this._onFieldWriteDenied
|
|
489
832
|
});
|
|
490
833
|
this.queryResolver = new QueryResolver({
|
|
491
834
|
queryParser: this.queryParser,
|
|
492
835
|
maxLimit: this.maxLimit,
|
|
493
836
|
defaultLimit: this.defaultLimit,
|
|
494
|
-
defaultSort:
|
|
837
|
+
defaultSort: this._defaultSort,
|
|
495
838
|
schemaOptions: this.schemaOptions,
|
|
496
839
|
tenantField: this.tenantField
|
|
497
840
|
});
|
|
@@ -547,8 +890,6 @@ var BaseCrudController = class {
|
|
|
547
890
|
let rebuildAccessControl = false;
|
|
548
891
|
let rebuildBodySanitizer = false;
|
|
549
892
|
let rebuildQueryResolver = false;
|
|
550
|
-
let bodySanitizerOnFieldWriteDenied;
|
|
551
|
-
let resolverDefaultSort;
|
|
552
893
|
if (options.tenantField !== void 0) {
|
|
553
894
|
this.tenantField = options.tenantField;
|
|
554
895
|
rebuildAccessControl = true;
|
|
@@ -568,7 +909,7 @@ var BaseCrudController = class {
|
|
|
568
909
|
rebuildQueryResolver = true;
|
|
569
910
|
}
|
|
570
911
|
if (options.onFieldWriteDenied !== void 0) {
|
|
571
|
-
|
|
912
|
+
this._onFieldWriteDenied = options.onFieldWriteDenied;
|
|
572
913
|
rebuildBodySanitizer = true;
|
|
573
914
|
}
|
|
574
915
|
if (options.queryParser !== void 0) this.setQueryParser(options.queryParser);
|
|
@@ -581,7 +922,7 @@ var BaseCrudController = class {
|
|
|
581
922
|
rebuildQueryResolver = true;
|
|
582
923
|
}
|
|
583
924
|
if (options.defaultSort !== void 0) {
|
|
584
|
-
|
|
925
|
+
this._defaultSort = options.defaultSort;
|
|
585
926
|
rebuildQueryResolver = true;
|
|
586
927
|
}
|
|
587
928
|
if (options.cache !== void 0) this._cacheConfig = options.cache;
|
|
@@ -593,13 +934,13 @@ var BaseCrudController = class {
|
|
|
593
934
|
});
|
|
594
935
|
if (rebuildBodySanitizer) this.bodySanitizer = new BodySanitizer({
|
|
595
936
|
schemaOptions: this.schemaOptions,
|
|
596
|
-
onFieldWriteDenied:
|
|
937
|
+
onFieldWriteDenied: this._onFieldWriteDenied
|
|
597
938
|
});
|
|
598
939
|
if (rebuildQueryResolver) this.queryResolver = new QueryResolver({
|
|
599
940
|
queryParser: this.queryParser,
|
|
600
941
|
maxLimit: this.maxLimit,
|
|
601
942
|
defaultLimit: this.defaultLimit,
|
|
602
|
-
defaultSort:
|
|
943
|
+
defaultSort: this._defaultSort,
|
|
603
944
|
schemaOptions: this.schemaOptions,
|
|
604
945
|
tenantField: this.tenantField
|
|
605
946
|
});
|
|
@@ -612,62 +953,20 @@ var BaseCrudController = class {
|
|
|
612
953
|
return this.tenantField || void 0;
|
|
613
954
|
}
|
|
614
955
|
/**
|
|
615
|
-
* Build the canonical repo-options bag
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
* `
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
* - **Tenant scope** — `[tenantField]: orgId` from `RequestScope`.
|
|
623
|
-
* Plugin-scoped repos (mongokit's `multiTenantPlugin`) read tenant
|
|
624
|
-
* scope from the TOP of the options bag, not `data.organizationId`.
|
|
625
|
-
* Without this stamping, a tenant-scoped repo throws "Missing
|
|
626
|
-
* 'organizationId' in context" even when arc has injected the
|
|
627
|
-
* tenant into the request body.
|
|
628
|
-
* Multi-field tenancy from `_tenantFields` (populated by
|
|
629
|
-
* `multiTenantPreset`) is merged in.
|
|
630
|
-
*
|
|
631
|
-
* - **Audit attribution** — `userId` + `user` from the authenticated
|
|
632
|
-
* actor. Mongokit's audit-log / audit-trail plugins read these
|
|
633
|
-
* into the `who` column; sqlitekit's audit plugin reads the same
|
|
634
|
-
* names. No host-side forwarding needed.
|
|
635
|
-
*
|
|
636
|
-
* - **Trace correlation** — `requestId` from Fastify's request id
|
|
637
|
-
* for stitching logs / events / downstream calls.
|
|
638
|
-
*
|
|
639
|
-
* - **`session` is intentionally NOT auto-set.** Sessions are tied
|
|
640
|
-
* to explicit transaction scopes the controller doesn't manage;
|
|
641
|
-
* pass `session` inline at the call site when running inside a
|
|
642
|
-
* `withTransaction` helper.
|
|
956
|
+
* Build the canonical repo-options bag (tenant scope, audit
|
|
957
|
+
* attribution, trace correlation) from the Fastify request. Full
|
|
958
|
+
* contract + rationale on `buildTenantRepoOptions` in
|
|
959
|
+
* `crud/requestPipeline.ts` — this delegate exists so host overrides
|
|
960
|
+
* and duck-typed consumers (aggregation router, MCP bridge) keep the
|
|
961
|
+
* same `protected` entry point.
|
|
643
962
|
*
|
|
644
963
|
* Method kept named `tenantRepoOptions` for back-compat with hosts
|
|
645
964
|
* that spread `...this.tenantRepoOptions(req)` (10+ call sites in
|
|
646
|
-
* arc, plus host overrides). The bag has always grown over time
|
|
965
|
+
* arc, plus host overrides). The bag has always grown over time —
|
|
647
966
|
* hosts that don't want audit forwarding never read those keys.
|
|
648
967
|
*/
|
|
649
968
|
tenantRepoOptions(req) {
|
|
650
|
-
|
|
651
|
-
if (cached) return cached;
|
|
652
|
-
const out = {};
|
|
653
|
-
const scope = this.meta(req)?._scope;
|
|
654
|
-
if (this.tenantField) {
|
|
655
|
-
const orgId = scope ? getOrgId(scope) : void 0;
|
|
656
|
-
if (orgId) out[this.tenantField] = orgId;
|
|
657
|
-
}
|
|
658
|
-
const presetFields = req._tenantFields;
|
|
659
|
-
if (presetFields && typeof presetFields === "object") {
|
|
660
|
-
for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
|
|
661
|
-
}
|
|
662
|
-
if (this.tenantField && out[this.tenantField] == null && scope && isElevated(scope)) out.bypassTenant = true;
|
|
663
|
-
const userId = getUserId(req.user);
|
|
664
|
-
if (userId) out.userId = userId;
|
|
665
|
-
if (req.user) out.user = req.user;
|
|
666
|
-
const requestId = req.id;
|
|
667
|
-
if (requestId) out.requestId = requestId;
|
|
668
|
-
Object.freeze(out);
|
|
669
|
-
req._tenantRepoOptions = out;
|
|
670
|
-
return out;
|
|
969
|
+
return buildTenantRepoOptions(req, this.tenantField, this.meta(req));
|
|
671
970
|
}
|
|
672
971
|
/** Extract typed Arc internal metadata from request */
|
|
673
972
|
meta(req) {
|
|
@@ -678,22 +977,13 @@ var BaseCrudController = class {
|
|
|
678
977
|
return this.meta(req)?.arc?.hooks ?? null;
|
|
679
978
|
}
|
|
680
979
|
/**
|
|
681
|
-
* Resolve the repository primary key for mutation calls.
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
* because most Mongo repositories key mutation methods off `_id`.
|
|
686
|
-
*
|
|
687
|
-
* Exception: if the repo exposes a matching `idField` property (e.g.
|
|
688
|
-
* MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
|
|
689
|
-
* repo handles lookup itself — pass the route id through unchanged.
|
|
980
|
+
* Resolve the repository primary key for mutation calls. Translates a
|
|
981
|
+
* custom route `idField` (slug, jobId, UUID) to the fetched doc's `_id`
|
|
982
|
+
* unless the repo declares a matching `idField` of its own — see
|
|
983
|
+
* `resolveMutationRepoId` in `crud/requestPipeline.ts`.
|
|
690
984
|
*/
|
|
691
985
|
resolveRepoId(id, existing) {
|
|
692
|
-
|
|
693
|
-
if (!existing) return id;
|
|
694
|
-
const repoIdField = this.repository.idField;
|
|
695
|
-
if (repoIdField && repoIdField === this.idField) return id;
|
|
696
|
-
return String(existing["_id"] ?? id);
|
|
986
|
+
return resolveMutationRepoId(id, existing, this.idField, this.repository);
|
|
697
987
|
}
|
|
698
988
|
/**
|
|
699
989
|
* Read-side preflight for mutable-target operations (`update`, `delete`).
|
|
@@ -732,24 +1022,11 @@ var BaseCrudController = class {
|
|
|
732
1022
|
* `details.code` set by the global error handler.
|
|
733
1023
|
*/
|
|
734
1024
|
throwNotFound(reason = "NOT_FOUND") {
|
|
735
|
-
|
|
736
|
-
const err = new NotFoundError(this.resourceName ?? "Resource");
|
|
737
|
-
err.details = {
|
|
738
|
-
...err.details ?? {},
|
|
739
|
-
code
|
|
740
|
-
};
|
|
741
|
-
throw err;
|
|
1025
|
+
throw buildNotFoundError(reason, this.resourceName);
|
|
742
1026
|
}
|
|
743
1027
|
/** Resolve cache config for a specific operation, merging per-op overrides */
|
|
744
1028
|
resolveCacheConfig(operation) {
|
|
745
|
-
|
|
746
|
-
if (!cfg || cfg.disabled) return null;
|
|
747
|
-
const opOverride = cfg[operation];
|
|
748
|
-
return {
|
|
749
|
-
staleTime: opOverride?.staleTime ?? cfg.staleTime ?? 0,
|
|
750
|
-
gcTime: opOverride?.gcTime ?? cfg.gcTime ?? 60,
|
|
751
|
-
tags: cfg.tags
|
|
752
|
-
};
|
|
1029
|
+
return resolveOpCacheConfig(this._cacheConfig, operation);
|
|
753
1030
|
}
|
|
754
1031
|
/**
|
|
755
1032
|
* Extract user/org IDs from request for cache key scoping.
|
|
@@ -757,21 +1034,11 @@ var BaseCrudController = class {
|
|
|
757
1034
|
* Universal resources (tenantField: false) get shared cache keys.
|
|
758
1035
|
*/
|
|
759
1036
|
cacheScope(req) {
|
|
760
|
-
return
|
|
761
|
-
userId: getUserId(req.user),
|
|
762
|
-
orgId: this.tenantField ? (() => {
|
|
763
|
-
const scope = this.meta(req)?._scope;
|
|
764
|
-
return scope ? getOrgId(scope) : void 0;
|
|
765
|
-
})() : void 0
|
|
766
|
-
};
|
|
1037
|
+
return deriveCacheScope(req, this.tenantField, this.meta(req));
|
|
767
1038
|
}
|
|
768
1039
|
/** Shared `x-cache` response envelope builder. */
|
|
769
1040
|
cacheResponse(data, cacheStatus) {
|
|
770
|
-
return
|
|
771
|
-
data,
|
|
772
|
-
status: 200,
|
|
773
|
-
headers: { "x-cache": cacheStatus }
|
|
774
|
-
};
|
|
1041
|
+
return buildCacheEnvelope(data, cacheStatus);
|
|
775
1042
|
}
|
|
776
1043
|
/** Required route-id helper shared by get/update/delete. Throws on missing id. */
|
|
777
1044
|
requireIdParam(req) {
|
|
@@ -785,48 +1052,26 @@ var BaseCrudController = class {
|
|
|
785
1052
|
* or `null` — every truthy non-null shape collapses to `true`.
|
|
786
1053
|
*/
|
|
787
1054
|
isExistsTruthy(result) {
|
|
788
|
-
return result
|
|
1055
|
+
return isExistsResultTruthy(result);
|
|
1056
|
+
}
|
|
1057
|
+
/** Assemble the per-request hook context from the protected accessors. */
|
|
1058
|
+
hookOpContext(req) {
|
|
1059
|
+
return {
|
|
1060
|
+
hooks: this.getHooks(req),
|
|
1061
|
+
resourceName: this.resourceName,
|
|
1062
|
+
arcContext: this.meta(req),
|
|
1063
|
+
user: req.user
|
|
1064
|
+
};
|
|
789
1065
|
}
|
|
790
1066
|
/**
|
|
791
1067
|
* Run `executeBefore` then `executeAround` (or just the executor if no
|
|
792
1068
|
* hooks are wired). Returns the around-phase result directly. Throws an
|
|
793
1069
|
* `ArcError` (status 400, code `BEFORE_<OP>_HOOK_ERROR`) when the
|
|
794
|
-
* before-hook fails
|
|
795
|
-
* `
|
|
796
|
-
*
|
|
797
|
-
* The caller runs `executeAfter` separately via `runAfterHook` — typically
|
|
798
|
-
* after success-checking the result (delete checks `isDeleteSuccess`,
|
|
799
|
-
* update checks `if (!item)`).
|
|
800
|
-
*
|
|
801
|
-
* **Knobs:**
|
|
802
|
-
* - `meta` — passed verbatim into `executeBefore` / `executeAround` opts.
|
|
803
|
-
* - `pipeProcessedData` (default `true`) — whether `executeBefore`'s
|
|
804
|
-
* return value flows into `executeAround` as the data parameter.
|
|
805
|
-
* Set `false` for delete (current behaviour: discards before's
|
|
806
|
-
* return, passes original input to around).
|
|
1070
|
+
* before-hook fails. Full contract on `executeHookedOp` in
|
|
1071
|
+
* `crud/requestPipeline.ts`.
|
|
807
1072
|
*/
|
|
808
1073
|
async runHookedOpUntilResult(req, args, executor) {
|
|
809
|
-
|
|
810
|
-
const hookOpts = {
|
|
811
|
-
user: req.user,
|
|
812
|
-
context: this.meta(req),
|
|
813
|
-
...args.meta ? { meta: args.meta } : {}
|
|
814
|
-
};
|
|
815
|
-
const pipeProcessed = args.pipeProcessedData !== false;
|
|
816
|
-
let processedData = args.input;
|
|
817
|
-
if (hooks && this.resourceName) try {
|
|
818
|
-
const beforeReturn = await hooks.executeBefore(this.resourceName, args.op, args.input, hookOpts);
|
|
819
|
-
if (pipeProcessed) processedData = beforeReturn;
|
|
820
|
-
} catch (err) {
|
|
821
|
-
throw createError(400, "Hook execution failed", {
|
|
822
|
-
code: `BEFORE_${args.op.toUpperCase()}_HOOK_ERROR`,
|
|
823
|
-
message: err.message
|
|
824
|
-
});
|
|
825
|
-
}
|
|
826
|
-
let result;
|
|
827
|
-
if (hooks && this.resourceName) result = await hooks.executeAround(this.resourceName, args.op, processedData, () => executor(processedData), hookOpts);
|
|
828
|
-
else result = await executor(processedData);
|
|
829
|
-
return result;
|
|
1074
|
+
return executeHookedOp(this.hookOpContext(req), args, executor);
|
|
830
1075
|
}
|
|
831
1076
|
/**
|
|
832
1077
|
* Run `executeAfter` for the given op + data. No-op when hooks aren't
|
|
@@ -835,15 +1080,7 @@ var BaseCrudController = class {
|
|
|
835
1080
|
* create/update, the original input (`existing`) for delete.
|
|
836
1081
|
*/
|
|
837
1082
|
async runAfterHook(req, op, data, meta) {
|
|
838
|
-
|
|
839
|
-
if (!hooks || !this.resourceName) return;
|
|
840
|
-
const user = req.user;
|
|
841
|
-
const arcContext = this.meta(req);
|
|
842
|
-
await hooks.executeAfter(this.resourceName, op, data, {
|
|
843
|
-
user,
|
|
844
|
-
context: arcContext,
|
|
845
|
-
...meta ? { meta } : {}
|
|
846
|
-
});
|
|
1083
|
+
return executeAfterHook(this.hookOpContext(req), op, data, meta);
|
|
847
1084
|
}
|
|
848
1085
|
/** Cached `list()` flow with SWR semantics. Returns null when cache is disabled. */
|
|
849
1086
|
async withListCache(req, options) {
|
|
@@ -908,25 +1145,23 @@ var BaseCrudController = class {
|
|
|
908
1145
|
* Resource-dispatch verbs router. Returns `null` when the request is
|
|
909
1146
|
* a regular list query, otherwise returns the dispatch promise.
|
|
910
1147
|
*
|
|
911
|
-
* Verbs (mutually exclusive
|
|
912
|
-
* - `?_count=true`
|
|
913
|
-
* - `?_distinct=field`
|
|
914
|
-
* - `?_exists=true`
|
|
1148
|
+
* Verbs (mutually exclusive — first match wins):
|
|
1149
|
+
* - `?_count=true` → `{ count: number }` via `repo.count()`
|
|
1150
|
+
* - `?_distinct=field` → `unknown[]` via `repo.distinct(field)`
|
|
1151
|
+
* - `?_exists=true` → `{ exists: boolean }` via `repo.exists()`
|
|
915
1152
|
*
|
|
916
1153
|
* All verbs share the resolved filter (parsed query + policy filters
|
|
917
1154
|
* + tenant scope). Adapters that don't ship the underlying repo
|
|
918
1155
|
* method get a `501` so failures surface loudly instead of falling
|
|
919
|
-
* back to a full table scan.
|
|
1156
|
+
* back to a full table scan. Matching + repo invocation live in
|
|
1157
|
+
* `crud/resourceVerbs.ts`.
|
|
920
1158
|
*/
|
|
921
1159
|
dispatchResourceVerb(req) {
|
|
922
|
-
const
|
|
923
|
-
if (!
|
|
924
|
-
|
|
925
|
-
if (
|
|
926
|
-
|
|
927
|
-
if (typeof distinctField === "string" && distinctField.length > 0) return this.dispatchDistinct(req, distinctField);
|
|
928
|
-
if (isTruthyFlag(query._exists)) return this.dispatchExists(req);
|
|
929
|
-
return null;
|
|
1160
|
+
const verb = matchResourceVerb(req.query);
|
|
1161
|
+
if (!verb) return null;
|
|
1162
|
+
if (verb.kind === "count") return this.dispatchCount(req);
|
|
1163
|
+
if (verb.kind === "distinct") return this.dispatchDistinct(req, verb.field);
|
|
1164
|
+
return this.dispatchExists(req);
|
|
930
1165
|
}
|
|
931
1166
|
/** Resolve filter + tenant/audit options for a dispatch verb. */
|
|
932
1167
|
resolveDispatchScope(req) {
|
|
@@ -935,33 +1170,24 @@ var BaseCrudController = class {
|
|
|
935
1170
|
options: this.tenantRepoOptions(req)
|
|
936
1171
|
};
|
|
937
1172
|
}
|
|
938
|
-
/** `?_count=true`
|
|
1173
|
+
/** `?_count=true` → `repo.count(filter)` */
|
|
939
1174
|
async dispatchCount(req) {
|
|
940
|
-
const repo = this.repository;
|
|
941
|
-
if (typeof repo.count !== "function") throw createError(501, "_count is not supported: the resource's storage adapter does not implement repo.count()");
|
|
942
|
-
const { filter, options } = this.resolveDispatchScope(req);
|
|
943
1175
|
return {
|
|
944
|
-
data:
|
|
1176
|
+
data: await runCountVerb(this.repository, () => this.resolveDispatchScope(req)),
|
|
945
1177
|
status: 200
|
|
946
1178
|
};
|
|
947
1179
|
}
|
|
948
|
-
/** `?_distinct=field`
|
|
1180
|
+
/** `?_distinct=field` → `repo.distinct(field, filter)` */
|
|
949
1181
|
async dispatchDistinct(req, field) {
|
|
950
1182
|
if (!this.isFieldExposedForRead(field)) throw createError(400, `_distinct field "${field}" is not allowed (hidden or system-managed)`);
|
|
951
|
-
const repo = this.repository;
|
|
952
|
-
if (typeof repo.distinct !== "function") throw createError(501, "_distinct is not supported: the resource's storage adapter does not implement repo.distinct()");
|
|
953
|
-
const { filter, options } = this.resolveDispatchScope(req);
|
|
954
1183
|
return {
|
|
955
|
-
data: await
|
|
1184
|
+
data: await runDistinctVerb(this.repository, field, () => this.resolveDispatchScope(req)),
|
|
956
1185
|
status: 200
|
|
957
1186
|
};
|
|
958
1187
|
}
|
|
959
|
-
/** `?_exists=true`
|
|
1188
|
+
/** `?_exists=true` → `repo.exists(filter)` */
|
|
960
1189
|
async dispatchExists(req) {
|
|
961
|
-
const
|
|
962
|
-
if (typeof repo.exists !== "function") throw createError(501, "_exists is not supported: the resource's storage adapter does not implement repo.exists()");
|
|
963
|
-
const { filter, options } = this.resolveDispatchScope(req);
|
|
964
|
-
const result = await repo.exists(filter, options);
|
|
1190
|
+
const result = await runExistsVerb(this.repository, () => this.resolveDispatchScope(req));
|
|
965
1191
|
return {
|
|
966
1192
|
data: { exists: this.isExistsTruthy(result) },
|
|
967
1193
|
status: 200
|
|
@@ -1109,45 +1335,6 @@ var BaseCrudController = class {
|
|
|
1109
1335
|
}
|
|
1110
1336
|
};
|
|
1111
1337
|
//#endregion
|
|
1112
|
-
//#region src/utils/repoFeature.ts
|
|
1113
|
-
/**
|
|
1114
|
-
* Repository feature-detection helper for arc mixins.
|
|
1115
|
-
*
|
|
1116
|
-
* Arc's `RepositoryLike` is the minimum contract every adapter
|
|
1117
|
-
* implements (`getById`, `getOne`, `create`, `update`, `delete`,
|
|
1118
|
-
* `find`). Beyond that, kits MAY expose specialized methods —
|
|
1119
|
-
* `createMany`, `updateMany`, `deleteMany`, `getTree`, `getBySlug`,
|
|
1120
|
-
* `getDeleted`, `restore`, etc. — that arc mixins detect at call
|
|
1121
|
-
* time and surface as 501 when missing.
|
|
1122
|
-
*
|
|
1123
|
-
* Every mixin used to spell out its own narrowing cast inline:
|
|
1124
|
-
*
|
|
1125
|
-
* ```ts
|
|
1126
|
-
* const repo = this.repository as unknown as {
|
|
1127
|
-
* getTree?: (options?: unknown) => Promise<AnyRecord[]>;
|
|
1128
|
-
* };
|
|
1129
|
-
* ```
|
|
1130
|
-
*
|
|
1131
|
-
* Centralizing the cast keeps every feature-detection site reading
|
|
1132
|
-
* the same shape. The helper does not intersect with `RepositoryLike`
|
|
1133
|
-
* on purpose: some standard methods (`createMany`, `updateMany`) live
|
|
1134
|
-
* on `StandardRepo` with kit-generic signatures, and intersecting
|
|
1135
|
-
* would force the caller's narrowed signature to merge with the wider
|
|
1136
|
-
* default, producing mismatched return types. Declare every method
|
|
1137
|
-
* you need (base or feature) in `TFeatures`.
|
|
1138
|
-
*
|
|
1139
|
-
* @example
|
|
1140
|
-
* ```ts
|
|
1141
|
-
* const repo = withRepoFeatures<{
|
|
1142
|
-
* createMany?: (items: unknown[], options?: unknown) => Promise<AnyRecord[]>;
|
|
1143
|
-
* }>(this.repository);
|
|
1144
|
-
* if (!repo.createMany) throw createError(501, "...");
|
|
1145
|
-
* ```
|
|
1146
|
-
*/
|
|
1147
|
-
function withRepoFeatures(repo) {
|
|
1148
|
-
return repo;
|
|
1149
|
-
}
|
|
1150
|
-
//#endregion
|
|
1151
1338
|
//#region src/core/mixins/bulk.ts
|
|
1152
1339
|
/**
|
|
1153
1340
|
* BulkMixin — `bulkCreate` / `bulkUpdate` / `bulkDelete` endpoints.
|
|
@@ -1489,4 +1676,4 @@ function TreeMixin(Base) {
|
|
|
1489
1676
|
*/
|
|
1490
1677
|
var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
|
|
1491
1678
|
//#endregion
|
|
1492
|
-
export { BulkMixin as a, collectReadBlockedFields as c, SlugMixin as i, isFieldReadable as l, TreeMixin as n, BaseCrudController as o, SoftDeleteMixin as r, QueryResolver as s, BaseController as t,
|
|
1679
|
+
export { BulkMixin as a, collectReadBlockedFields as c, DEFAULT_FIELD_WRITE_DENIAL_POLICY as d, AccessControl as f, SlugMixin as i, isFieldReadable as l, TreeMixin as n, BaseCrudController as o, SoftDeleteMixin as r, QueryResolver as s, BaseController as t, BodySanitizer as u };
|