@classytic/arc 2.41.0 → 2.43.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/dist/{EventTransport-CbUKDzZZ.d.mts → EventTransport-CiicKz2Z.d.mts} +1 -1
- package/dist/{EventTransport-n7qO5Cz3.mjs → EventTransport-tzA7CAB4.mjs} +22 -17
- package/dist/{HookSystem-CxvZBo77.mjs → HookSystem-B6UaKRbF.mjs} +2 -2
- package/dist/{QueryCache-DFA-QrU9.d.mts → QueryCache--5uAuYPW.d.mts} +7 -3
- package/dist/{ResourceRegistry-Cu7uTJp9.mjs → ResourceRegistry-CuCARu2B.mjs} +44 -13
- package/dist/actionPermissions-BotX1YOa.mjs +776 -0
- package/dist/audit/index.d.mts +43 -5
- package/dist/audit/index.mjs +48 -26
- package/dist/auth/audit.mjs +1 -1
- package/dist/auth/index.d.mts +43 -13
- package/dist/auth/index.mjs +130 -155
- package/dist/auth/redis-session.mjs +1 -1
- package/dist/{authorizationDecision-2YY_Kaj1.mjs → authorizationDecision-DQYrkl5f.mjs} +7 -102
- package/dist/{base-CxrV1LmG.d.mts → base-CZyixEQR.d.mts} +27 -5
- package/dist/boundedMap-BULzfXYU.mjs +57 -0
- package/dist/{buildHandler-CUhjGHGZ.mjs → buildHandler-CRQ8EcX5.mjs} +4 -4
- package/dist/cache/index.d.mts +22 -34
- package/dist/cache/index.mjs +4 -4
- package/dist/{caching-DsWDjIxx.mjs → caching-Cc2M1DwO.mjs} +3 -3
- package/dist/claimed-B10RTfkM.mjs +8 -0
- package/dist/cleanup/index.d.mts +6 -10
- package/dist/cleanup/index.mjs +27 -73
- package/dist/cli/commands/describe.d.mts +4 -2
- package/dist/cli/commands/describe.mjs +7 -4
- package/dist/cli/commands/docs.mjs +3 -3
- package/dist/cli/commands/doctor.mjs +1 -1
- package/dist/cli/commands/generate.mjs +2 -2
- package/dist/cli/commands/init.mjs +2 -2
- package/dist/cli/commands/introspect.mjs +2 -2
- package/dist/cli/index.mjs +3 -3
- package/dist/{constants-BxLgNFF7.mjs → constants-Cu7Mx7_U.mjs} +4 -2
- package/dist/context/index.d.mts +2 -5
- package/dist/context/index.mjs +18 -38
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +8 -8
- package/dist/{core-iNGqYerO.mjs → core-Cb0LjccA.mjs} +5 -4
- package/dist/{createActionRouter-BBlbPs3k.mjs → createActionRouter-xjqonMRO.mjs} +87 -39
- package/dist/{createAggregationRouter-Dv5SuK2x.mjs → createAggregationRouter-zRYWw_5D.mjs} +11 -8
- package/dist/{preloadResources-Bbp4QJ8V.mjs → createApp-D-953ZAq.mjs} +299 -390
- package/dist/{createMcpServer-CbDWkZ1T.d.mts → createMcpServer-C9cxS8zt.d.mts} +1 -1
- package/dist/{defineAggregation-UyeurGkp.mjs → defineAggregation-Bm8RjMbq.mjs} +1 -1
- package/dist/{defineResource-DzGT-2LP.mjs → defineResource-DIC7JZGU.mjs} +118 -1024
- package/dist/discovery/index.mjs +1 -1
- package/dist/docs/index.d.mts +10 -4
- package/dist/docs/index.mjs +4 -4
- package/dist/{elevation-BXCfVcNG.d.mts → elevation-BW2J3tNj.d.mts} +1 -2
- package/dist/{elevation-BF-vhGxY.mjs → elevation-DG8SrLN-.mjs} +41 -12
- package/dist/encryption/index.mjs +3 -3
- package/dist/{errorHandler-CIMkCOWm.mjs → errorHandler-BnfDviL1.mjs} +26 -5
- package/dist/{errors-C_YhELhe.mjs → errors-CtrywMHP.mjs} +58 -23
- package/dist/{errors-BGdVwP25.d.mts → errors-JYc9vwqE.d.mts} +9 -0
- package/dist/{eventPlugin-B52abJ8W.d.mts → eventPlugin-BtmA4-3P.d.mts} +65 -9
- package/dist/{eventPlugin-CmXaPWLv.mjs → eventPlugin-Q6BhzHzs.mjs} +82 -21
- package/dist/{subscribe-helpers-AVYPgqTl.mjs → eventTypes-Cdhm6ZlV.mjs} +151 -17
- package/dist/events/index.d.mts +33 -8
- package/dist/events/index.mjs +49 -53
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +139 -157
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +2 -2
- package/dist/factory/index.d.mts +32 -17
- package/dist/factory/index.mjs +213 -32
- package/dist/{fastify-augmentation-Cq2nHVaW.d.mts → fastify-augmentation-DhUJ-mvo.d.mts} +11 -25
- package/dist/{fields-I_EeRWND.d.mts → fields-BBSpf8pS.d.mts} +1 -1
- package/dist/{fields-BTyPLHK0.mjs → fields-ToPYpMqS.mjs} +1 -1
- package/dist/filter-merge-IpGywvym.mjs +99 -0
- package/dist/{generate-D_YmiCNf.mjs → generate-DRTkLlIl.mjs} +2 -2
- package/dist/{headers-CNECDE6q.mjs → headers-BbkIF6vA.mjs} +2 -2
- package/dist/health-D-Ft6JM6.mjs +22 -0
- package/dist/{health-CxQzbJmr.mjs → health-DqbsG-_w.mjs} +115 -55
- package/dist/hooks/index.d.mts +2 -2
- package/dist/hooks/index.mjs +2 -2
- package/dist/idempotency/index.d.mts +31 -7
- package/dist/idempotency/index.mjs +155 -98
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +48 -11
- package/dist/{index-BHdZD8zK.d.mts → index-Bxriq3so.d.mts} +84 -21
- package/dist/{preloadResources-CjS2OfpU.d.mts → index-C8zVGYsk.d.mts} +37 -38
- package/dist/{index-D4u76eQW.d.mts → index-CRoPtISf.d.mts} +23 -73
- package/dist/{index-CbJiYrfv.d.mts → index-Chgz3d0i.d.mts} +1 -1
- package/dist/index-JdbZAIsm.d.mts +510 -0
- package/dist/{index-DHbyPo5j.d.mts → index-mqaqZNpe.d.mts} +253 -254
- package/dist/index.d.mts +8 -8
- package/dist/index.mjs +11 -11
- package/dist/{init-Y2MlSN9n.mjs → init-B_oUYDXI.mjs} +1 -1
- package/dist/integrations/event-gateway.d.mts +8 -3
- package/dist/integrations/event-gateway.mjs +4 -4
- package/dist/integrations/index.d.mts +3 -3
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs/index.d.mts +1 -1
- package/dist/integrations/jobs/index.mjs +18 -5
- package/dist/integrations/mcp/index.d.mts +72 -59
- package/dist/integrations/mcp/index.mjs +19 -4
- package/dist/integrations/mcp/testing.d.mts +2 -2
- package/dist/integrations/mcp/testing.mjs +2 -2
- package/dist/integrations/streamline.d.mts +78 -136
- package/dist/integrations/streamline.mjs +116 -54
- package/dist/integrations/webhooks.d.mts +60 -120
- package/dist/integrations/webhooks.mjs +36 -80
- package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
- package/dist/integrations/websocket-pushref-redis.mjs +1 -1
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket-redis.mjs +1 -1
- package/dist/integrations/websocket.d.mts +3 -3
- package/dist/integrations/websocket.mjs +2 -2
- package/dist/{interface-DEWNOMza.d.mts → interface-C0395yUN.d.mts} +13 -1
- package/dist/{jsonBody-Cq9KYl2Z.mjs → jsonBody-B4YVGcql.mjs} +2 -2
- package/dist/{keys-Cmv4cBRa.mjs → keys-B2KtgMLJ.mjs} +7 -18
- package/dist/{loadResources-CblF7xt2.mjs → loadResources-Dn4lalw2.mjs} +3 -3
- package/dist/{loadResourcesFromEntry-99fIMC3p.mjs → loadResourcesFromEntry-3sLYbuse.mjs} +2 -2
- package/dist/logger/index.d.mts +1 -8
- package/dist/logger/index.mjs +3 -153
- package/dist/logger-DXjfIqjV.mjs +160 -0
- package/dist/{mcpPlugin-xF3Gw-HW.mjs → mcpPlugin-C08HalN5.mjs} +433 -170
- package/dist/{memory-CpDZ9NPw.mjs → memory-9Cp7611C.mjs} +36 -34
- package/dist/{metrics-BaKnq8ag.mjs → metrics-CWI11eRu.mjs} +39 -72
- package/dist/middleware/index.d.mts +2 -2
- package/dist/middleware/index.mjs +3 -3
- package/dist/migrations/index.mjs +2 -2
- package/dist/{module-BkQEesNQ.mjs → module-BSvOQC7E.mjs} +8 -8
- package/dist/multiTenant-cY0FkDiA.d.mts +104 -0
- package/dist/{multipartBody-C92aJ8am.mjs → multipartBody-CXM2a-Dy.mjs} +46 -7
- package/dist/{openapi-C3LFRzPq.mjs → openapi-DT_G4VhY.mjs} +48 -10
- package/dist/outbox-admin/index.d.mts +3 -3
- package/dist/outbox-admin/index.mjs +6 -18
- package/dist/{outbox-module-dsCyLxXH.mjs → outbox-module-Bjgzl6tg.mjs} +203 -33
- package/dist/{outbox-module-BHdUnuuK.d.mts → outbox-module-gZCHoE4H.d.mts} +79 -6
- package/dist/permissions/index.d.mts +5 -4
- package/dist/permissions/index.mjs +8 -6
- package/dist/{permissions-DiHXy8Ck.mjs → permissions-Cm0uYHvd.mjs} +149 -33
- package/dist/{pipe-CBVmUJ-j.mjs → pipe-BqeGT6UO.mjs} +2 -2
- package/dist/pipeline/index.d.mts +2 -2
- package/dist/pipeline/index.mjs +2 -2
- package/dist/plan-FVR6LP59.mjs +565 -0
- package/dist/{BaseController-DYJRZ0EJ.mjs → plugin-BccET1tL.mjs} +1256 -344
- package/dist/plugins/index.d.mts +14 -11
- package/dist/plugins/index.mjs +44 -22
- package/dist/plugins/response-cache.mjs +8 -14
- package/dist/plugins/tracing-entry.mjs +2 -2
- package/dist/{pluralize-2_U6QZQg.mjs → pluralize-CVIOcgMF.mjs} +1 -1
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +5 -5
- package/dist/presets/index.d.mts +26 -31
- package/dist/presets/index.mjs +4 -4
- package/dist/presets/multiTenant.d.mts +2 -107
- package/dist/presets/multiTenant.mjs +22 -162
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +2 -2
- package/dist/{presets-BdlugYTc.mjs → presets-Cpvj6fGO.mjs} +99 -107
- package/dist/{projection-Uvgp-EEj.mjs → projection-Ce4aQhQk.mjs} +2 -2
- package/dist/{pushref-registry-Dov9l4qE.d.mts → pushref-registry-UxCkoA4C.d.mts} +1 -1
- package/dist/{queryCachePlugin-BzqxconG.d.mts → queryCachePlugin-C2lZjTxk.d.mts} +1 -1
- package/dist/{queryCachePlugin-B5F09bYr.mjs → queryCachePlugin-CFWNH8aT.mjs} +12 -14
- package/dist/{rateLimitKey-CK-EU2L8.mjs → rateLimitKey-C4O-kPm0.mjs} +1 -1
- package/dist/{realtime-FnnD0n3Z.mjs → realtime-DmPE9S54.mjs} +25 -21
- package/dist/redis-stream-BMk-bpQS.d.mts +219 -0
- package/dist/{redis-Bzi1y3F9.d.mts → redis-vLvtcGy_.d.mts} +13 -24
- package/dist/redisShapes-CQksY67q.d.mts +55 -0
- package/dist/registry/index.d.mts +9 -4
- package/dist/registry/index.mjs +4 -4
- package/dist/{registry-D3y8VEJ1.mjs → registry-Bcz9G1bh.mjs} +10 -8
- package/dist/{renewingLease-B3VYh5aU.mjs → renewingLease-qODa7KJQ.mjs} +1 -1
- package/dist/{replyHelpers-DPHsdw-5.mjs → replyHelpers-CG_VtVgS.mjs} +2 -2
- package/dist/{requestContext-cSVKnYPk.mjs → requestContext-Dsu9P1bV.mjs} +1 -1
- package/dist/requirement-CbiLdQ1n.d.mts +22 -0
- package/dist/requirement-KDdoRcOP.mjs +35 -0
- package/dist/resourceRateLimit-Wf6d6FL9.d.mts +30 -0
- package/dist/resourceRateLimit-dSjVr1pk.mjs +180 -0
- package/dist/{rolldown-runtime-DjDuyglE.mjs → rolldown-runtime-D1w9Y1VJ.mjs} +1 -1
- package/dist/routeValidator-D36SW8qt.mjs +70 -0
- package/dist/{runtimeCapabilities-DSA_dH1_.mjs → runtimeCapabilities-0tccjRlj.mjs} +1 -1
- package/dist/{schedules-BktJb3Y3.mjs → schedules-C-OpmHBq.mjs} +46 -37
- package/dist/{schemaConverter-BLCSmNbr.mjs → schemaConverter-BORek3O-.mjs} +2 -2
- package/dist/{schemaIR-K16Bb5BX.mjs → schemaIR-C7Vbp71I.mjs} +2 -2
- package/dist/schemas/index.d.mts +10 -0
- package/dist/schemas/index.mjs +8 -3
- package/dist/scim/index.mjs +2 -2
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +6 -6
- package/dist/{scope-jSxuBTFM.mjs → scope-DQthe88b.mjs} +4 -4
- package/dist/shutdown-MqA81z_P.mjs +59 -0
- package/dist/{simpleEqualityMatcher-BiOnyflP.mjs → simpleEqualityMatcher-auW8QjXN.mjs} +1 -1
- package/dist/{sse-CH7Slxc1.mjs → sse-CYzQBVFe.mjs} +11 -15
- package/dist/{sseStream-CN-Lh1Tu.mjs → sseStream-B5_Afiqn.mjs} +2 -2
- package/dist/{store-helpers-9DX9-1j_.mjs → store-helpers-BOzk-CuX.mjs} +1 -1
- package/dist/{streaming-dvppnxsr.mjs → streaming-DjTQhZrN.mjs} +1 -1
- package/dist/subscriberIdentity-CW39uA8G.mjs +28 -0
- package/dist/tenancy--ed9eH_z.mjs +43 -0
- package/dist/tenantPreparation-BKon6Yox.mjs +67 -0
- package/dist/testing/cleanupStoreContract.mjs +1 -1
- package/dist/testing/index.d.mts +193 -175
- package/dist/testing/index.mjs +286 -212
- package/dist/testing/outboxStoreContract.mjs +137 -2
- package/dist/testing/requiredPorts.d.mts +17 -0
- package/dist/testing/requiredPorts.mjs +65 -0
- package/dist/testing/storageContract.mjs +1 -1
- package/dist/timing-qczZ0YC-.mjs +46 -0
- package/dist/{transactionContext-DCWMI6hV.mjs → transactionContext-DjFkrNG6.mjs} +1 -1
- package/dist/{typeGuards-D7tHFdH6.mjs → typeGuards-CySF12G-.mjs} +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/index.mjs +1 -1
- package/dist/types/storage.mjs +1 -1
- package/dist/{types-CWQn9-IC.d.mts → types-B4Aq4Sdj.d.mts} +22 -9
- package/dist/{types-DfVDvkdP.d.mts → types-BJ-2XqVF.d.mts} +189 -204
- package/dist/{types-CDy3-zw1.mjs → types-Bk9NOgOC.mjs} +4 -4
- package/dist/usage/index.d.mts +2 -3
- package/dist/usage/index.mjs +5 -5
- package/dist/{userHelpers-DDMOZ_Pm.mjs → userHelpers-aklawRaK.mjs} +1 -1
- package/dist/utils/index.d.mts +84 -223
- package/dist/utils/index.mjs +13 -12
- package/dist/{utils-B-zj_b__.mjs → utils-ec1oTo1n.mjs} +315 -404
- package/dist/{validate-R0G_JrnK.mjs → validate-9yzHC1e2.mjs} +6 -14
- package/dist/{versioning-D6JIK8Tk.d.mts → versioning-Bsb51gGT.d.mts} +28 -13
- package/dist/{versioning-DuVWcu-F.mjs → versioning-fhhWHV74.mjs} +4 -4
- package/dist/{websocket-BOXVggMf.d.mts → websocket-Cs4awVyD.d.mts} +1 -1
- package/dist/{workScope-BhsWde4k.mjs → workScope-zpPyge4t.mjs} +2 -2
- package/package.json +578 -574
- package/skills/arc/SKILL.md +13 -4
- package/skills/arc/references/auth.md +8 -5
- package/skills/arc/references/integrations.md +1 -1
- package/skills/arc/references/mcp.md +8 -6
- package/skills/arc/references/multi-tenancy.md +19 -14
- package/skills/arc/references/production.md +27 -10
- package/dist/actionPermissions-DPwPWPOV.mjs +0 -350
- package/dist/index-CFfLKMH4.d.mts +0 -290
- package/dist/interface-BIpgzbZl.mjs +0 -17
- package/dist/redis-stream-BPKscsTB.d.mts +0 -260
- package/dist/registerRoute-DRsSHPSM.mjs +0 -364
- package/dist/sync/changelog.d.mts +0 -3
- package/dist/sync/changelog.mjs +0 -3
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import {
|
|
3
|
-
import { arcLog } from "./logger
|
|
4
|
-
import {
|
|
5
|
-
import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError, u as ValidationError } from "./errors-
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { t as
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
1
|
+
/*! @classytic/arc v2.43.0 | MIT | https://classytic.com */
|
|
2
|
+
import { g as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-Cu7Mx7_U.mjs";
|
|
3
|
+
import { t as arcLog } from "./logger-DXjfIqjV.mjs";
|
|
4
|
+
import { t as conjoinPolicyFilters } from "./filter-merge-IpGywvym.mjs";
|
|
5
|
+
import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError, u as ValidationError } from "./errors-CtrywMHP.mjs";
|
|
6
|
+
import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-auW8QjXN.mjs";
|
|
7
|
+
import { n as requireSingleHeaderValue } from "./headers-BbkIF6vA.mjs";
|
|
8
|
+
import { n as getUserRoles, t as getUserId } from "./userHelpers-aklawRaK.mjs";
|
|
9
|
+
import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-Bk9NOgOC.mjs";
|
|
10
|
+
import { r as resolveTenancy } from "./tenancy--ed9eH_z.mjs";
|
|
11
|
+
import { t as buildQueryKey } from "./keys-B2KtgMLJ.mjs";
|
|
12
|
+
import { t as transactionContext } from "./transactionContext-DjFkrNG6.mjs";
|
|
13
|
+
import { a as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-ToPYpMqS.mjs";
|
|
14
|
+
import { E as ArcQueryParser, v as getDefaultCrudSchemas } from "./utils-ec1oTo1n.mjs";
|
|
15
|
+
import { n as convertRouteSchema, r as ensureSchemaConverter, t as convertOpenApiSchemas } from "./schemaConverter-BORek3O-.mjs";
|
|
16
|
+
import { i as pipeUIMessageStreamToReply, r as isReadableStream } from "./streaming-DjTQhZrN.mjs";
|
|
17
|
+
import { _ as buildArcDecorator, a as resolveRouterPluginMw, b as createRequestContext, c as buildPipelineHandler, d as buildCrudPermissionMw, f as buildRouteHooks, h as buildAuthMiddleware, i as buildRouteConfig, l as resolvePipelineSteps, m as routeHookOptions, n as tryRegisterRoute, o as selectPluginMw, p as resolveRoutePreHandlers, r as buildRateLimitConfig, t as resolveActionPermission, v as createCrudHandlers, y as createFastifyHandler } from "./actionPermissions-BotX1YOa.mjs";
|
|
18
|
+
import { a as withoutTenantInjection } from "./tenantPreparation-BKon6Yox.mjs";
|
|
19
|
+
import { t as hasEvents } from "./typeGuards-CySF12G-.mjs";
|
|
20
|
+
import { n as idempotencyConfigProblem } from "./requirement-KDdoRcOP.mjs";
|
|
21
|
+
import { ARC_ERROR_CODES } from "@classytic/repo-core/errors";
|
|
13
22
|
import { policyRecordToFilter } from "@classytic/repo-core/filter";
|
|
14
23
|
import { retryingTransaction } from "@classytic/repo-core/repository";
|
|
15
|
-
import {
|
|
24
|
+
import { scheduleBackground } from "@classytic/repo-core/cache";
|
|
25
|
+
import { isProductionEnv, readEnv } from "@classytic/primitives/environment";
|
|
16
26
|
//#region src/core/repositoryFilter.ts
|
|
17
27
|
const NATIVE_FILTER_DIALECT = Symbol.for("@classytic/arc/native-filter-dialect");
|
|
18
28
|
/**
|
|
@@ -115,9 +125,8 @@ var AccessControl = class {
|
|
|
115
125
|
const arcContext = this._meta(req);
|
|
116
126
|
const policyFilters = arcContext?._policyFilters;
|
|
117
127
|
if (policyFilters) filter = conjoinPolicyFilters(filter, policyFilters);
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
if (this.tenantField && orgId) filter = conjoinPolicyFilters(filter, { [this.tenantField]: orgId });
|
|
128
|
+
const tenancy = resolveTenancy(arcContext, this.tenantField);
|
|
129
|
+
if (tenancy.mode === "scoped") filter = conjoinPolicyFilters(filter, { [tenancy.field]: tenancy.orgId });
|
|
121
130
|
return toRepositoryFilter(filter);
|
|
122
131
|
}
|
|
123
132
|
/**
|
|
@@ -178,14 +187,11 @@ var AccessControl = class {
|
|
|
178
187
|
* unscoped records from leaking across tenants.
|
|
179
188
|
*/
|
|
180
189
|
checkOrgScope(item, arcContext) {
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
const
|
|
184
|
-
const orgId = scope ? getOrgId(scope) : void 0;
|
|
185
|
-
if (!item || !orgId) return true;
|
|
186
|
-
const itemOrgId = item[this.tenantField];
|
|
190
|
+
const tenancy = resolveTenancy(arcContext, this.tenantField);
|
|
191
|
+
if (tenancy.mode !== "scoped" || !item) return true;
|
|
192
|
+
const itemOrgId = item[tenancy.field];
|
|
187
193
|
if (!itemOrgId) return false;
|
|
188
|
-
return String(itemOrgId) === String(orgId);
|
|
194
|
+
return String(itemOrgId) === String(tenancy.orgId);
|
|
189
195
|
}
|
|
190
196
|
/** Check ownership for update/delete (ownedByUser preset) */
|
|
191
197
|
checkOwnership(item, req) {
|
|
@@ -341,7 +347,7 @@ var BodySanitizer = class {
|
|
|
341
347
|
constructor(config) {
|
|
342
348
|
this.schemaOptions = config.schemaOptions;
|
|
343
349
|
this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
|
|
344
|
-
this.onImmutableWrite = config.onImmutableWrite ?? (process.env.ARC_STRICT_IMMUTABLE_WRITES
|
|
350
|
+
this.onImmutableWrite = config.onImmutableWrite ?? (readEnv(process.env).bool("ARC_STRICT_IMMUTABLE_WRITES", true) ? "reject" : "strip");
|
|
345
351
|
}
|
|
346
352
|
/**
|
|
347
353
|
* Strip readonly and system-managed fields from request body.
|
|
@@ -423,21 +429,16 @@ function buildTenantRepoOptions(req, tenantField, arcContext) {
|
|
|
423
429
|
const cached = req._tenantRepoOptions;
|
|
424
430
|
if (cached) return cached;
|
|
425
431
|
const out = {};
|
|
426
|
-
const
|
|
427
|
-
if (
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (
|
|
435
|
-
}
|
|
436
|
-
const presetFields = req._tenantFields;
|
|
437
|
-
if (presetFields && typeof presetFields === "object") {
|
|
438
|
-
for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
|
|
432
|
+
const tenancy = resolveTenancy(arcContext, tenantField);
|
|
433
|
+
if (tenancy.mode === "unscoped" && tenancy.reason === "cross-tenant") out.bypassTenant = true;
|
|
434
|
+
else {
|
|
435
|
+
if (tenancy.mode === "scoped") out[tenancy.field] = tenancy.orgId;
|
|
436
|
+
const presetFields = arcContext?._tenantFields ?? req._tenantFields;
|
|
437
|
+
if (presetFields && typeof presetFields === "object") {
|
|
438
|
+
for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
|
|
439
|
+
}
|
|
440
|
+
if (tenancy.mode === "unscoped" && tenancy.reason === "platform" && tenantField && out[tenantField] == null) out.bypassTenant = true;
|
|
439
441
|
}
|
|
440
|
-
if (tenantField && out[tenantField] == null && scope && isElevated(scope)) out.bypassTenant = true;
|
|
441
442
|
const userId = getUserId(req.user);
|
|
442
443
|
if (userId) out.userId = userId;
|
|
443
444
|
if (req.user) out.user = req.user;
|
|
@@ -566,6 +567,14 @@ function resolveOpCacheConfig(cfg, operation) {
|
|
|
566
567
|
* Extract user/org IDs from request for cache key scoping.
|
|
567
568
|
* Only includes orgId when the resource uses tenant-scoped data (tenantField is set).
|
|
568
569
|
* Universal resources (tenantField: false) get shared cache keys.
|
|
570
|
+
*
|
|
571
|
+
* Deliberately NOT `resolveTenancy`. That answers "is this read FILTERED by
|
|
572
|
+
* tenant?"; this answers "whose cache PARTITION is it?", and the two diverge on
|
|
573
|
+
* a cross-tenant read: unfiltered, yet still partitioned by the caller's org.
|
|
574
|
+
* A cross-tenant listing's row policy may differ per org ("plus anything your
|
|
575
|
+
* organization bought"), so dropping the org from the key would let one org's
|
|
576
|
+
* cached page answer another's. Over-partitioning costs hit rate; the other
|
|
577
|
+
* direction costs a leak.
|
|
569
578
|
*/
|
|
570
579
|
function deriveCacheScope(req, tenantField, arcContext) {
|
|
571
580
|
return {
|
|
@@ -750,6 +759,21 @@ function isFieldReadable(rule) {
|
|
|
750
759
|
return rule.hidden !== true;
|
|
751
760
|
}
|
|
752
761
|
/**
|
|
762
|
+
* Does `path` touch a blocked field? The ONE path rule every read surface uses: blocked by its own
|
|
763
|
+
* name or by ANY ancestor (a rule on a parent covers its children). With `descendants`, a blocked
|
|
764
|
+
* field UNDER `path` counts too — for surfaces that return the value raw (`_distinct`, aggregation
|
|
765
|
+
* groups), where the parent object carries the hidden child.
|
|
766
|
+
*/
|
|
767
|
+
function pathTouchesBlocked(path, blocked, options = {}) {
|
|
768
|
+
if (blocked.has(path)) return true;
|
|
769
|
+
for (let dot = path.indexOf("."); dot > 0; dot = path.indexOf(".", dot + 1)) if (blocked.has(path.slice(0, dot))) return true;
|
|
770
|
+
if (options.descendants) {
|
|
771
|
+
const prefix = `${path}.`;
|
|
772
|
+
for (const field of blocked) if (field.startsWith(prefix)) return true;
|
|
773
|
+
}
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
753
777
|
* The set of field names blocked from read-side surfaces (used by
|
|
754
778
|
* `QueryResolver.sanitizeSelectAny` and `BaseCrudController._distinct`).
|
|
755
779
|
*
|
|
@@ -795,27 +819,6 @@ function noteDeniedJoins(kind) {
|
|
|
795
819
|
notedDeniedJoins = true;
|
|
796
820
|
log.warn(`client requested ${kind} but no allowlist is configured — DENIED (2.24 default flip; it was allow-all). Set schemaOptions.query.allowedPopulate / allowedLookups to enable client-driven joins. (dev-only note, shown once)`);
|
|
797
821
|
}
|
|
798
|
-
/**
|
|
799
|
-
* A `$lookup` reaches ANOTHER collection, and the tenant filter arc conjoins applies to the
|
|
800
|
-
* BASE one only. The joined side carries whatever predicate the lookup itself names and
|
|
801
|
-
* nothing more — no tenant scope, no soft-delete filter — so a resource that opens
|
|
802
|
-
* `allowedLookups` can return rows from a collection the caller could not query directly.
|
|
803
|
-
*
|
|
804
|
-
* Unreachable by default (2.24 denies `lookup=` with no allowlist) and no resource in this
|
|
805
|
-
* fleet configures one, so this is a WARNING at the moment a deployment first opts in
|
|
806
|
-
* rather than machinery for a door nobody has opened. Warned once per collection set: the
|
|
807
|
-
* decision is made when the allowlist is written, not per request.
|
|
808
|
-
*
|
|
809
|
-
* Scope the join with the lookup's own `where`, which mongokit compiles into a `$match`
|
|
810
|
-
* inside the lookup pipeline.
|
|
811
|
-
*/
|
|
812
|
-
const notedUnscopedJoins = /* @__PURE__ */ new Set();
|
|
813
|
-
function noteUnscopedJoin(lookups) {
|
|
814
|
-
const key = lookups.map((l) => l.from).filter((f) => typeof f === "string").sort().join(",");
|
|
815
|
-
if (key.length === 0 || notedUnscopedJoins.has(key)) return;
|
|
816
|
-
notedUnscopedJoins.add(key);
|
|
817
|
-
log.warn(`client-driven $lookup on [${key}] — the JOINED side carries no tenant or soft-delete filter (arc scopes the base collection only). Narrow each lookup with its own \`where\`, or the join can surface rows the caller cannot query directly. (shown once)`);
|
|
818
|
-
}
|
|
819
822
|
var QueryResolver = class {
|
|
820
823
|
queryParser;
|
|
821
824
|
maxLimit;
|
|
@@ -938,6 +941,8 @@ var QueryResolver = class {
|
|
|
938
941
|
const { _policyFilters: _ignored, ...rest } = rawQuery;
|
|
939
942
|
query = rest;
|
|
940
943
|
}
|
|
944
|
+
const keyset = readKeysetControls(query);
|
|
945
|
+
if (keyset.stripped) query = keyset.query;
|
|
941
946
|
const parsed = this.queryParser.parse(query);
|
|
942
947
|
/**
|
|
943
948
|
* Client-supplied keys only, and BEFORE the policy conjunction below: trusted policy
|
|
@@ -947,14 +952,16 @@ var QueryResolver = class {
|
|
|
947
952
|
this.assertNoBlockedReadKeys(parsed.filters, parsed.sort);
|
|
948
953
|
const arcContext = meta ?? req.metadata;
|
|
949
954
|
const limit = Math.min(Math.max(1, parsed.limit || this.defaultLimit), this.maxLimit);
|
|
950
|
-
const
|
|
955
|
+
const after = parsed.after;
|
|
956
|
+
const before = keyset.before;
|
|
957
|
+
const keysetMode = keyset.mode === "keyset" || after !== void 0 || before !== void 0;
|
|
958
|
+
const page = keysetMode ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
|
|
951
959
|
const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.permittedDefaultSort();
|
|
952
960
|
const rawSelect = parsed.select ?? req.query?.select;
|
|
953
961
|
const policyFilters = arcContext?._policyFilters;
|
|
954
962
|
let filters = conjoinPolicyFilters(parsed.filters, policyFilters);
|
|
955
|
-
const
|
|
956
|
-
|
|
957
|
-
if (this.tenantField && orgId && !arcContext?._crossTenantRead && !policyFilters?.[this.tenantField]) filters = conjoinPolicyFilters(filters, { [this.tenantField]: orgId });
|
|
963
|
+
const tenancy = resolveTenancy(arcContext, this.tenantField);
|
|
964
|
+
if (tenancy.mode === "scoped" && !policyFilters?.[tenancy.field]) filters = conjoinPolicyFilters(filters, { [tenancy.field]: tenancy.orgId });
|
|
958
965
|
const portableFilters = toRepositoryFilter(filters);
|
|
959
966
|
return {
|
|
960
967
|
page,
|
|
@@ -963,10 +970,12 @@ var QueryResolver = class {
|
|
|
963
970
|
select: this.sanitizeSelectAny(rawSelect, this.schemaOptions),
|
|
964
971
|
populate: this.sanitizePopulate(parsed.populate, this.schemaOptions),
|
|
965
972
|
populateOptions: this.sanitizePopulateOptions(parsed.populateOptions, this.schemaOptions),
|
|
966
|
-
lookups: this.sanitizeLookups(parsed.lookups, this.schemaOptions),
|
|
973
|
+
lookups: this.sanitizeLookups(parsed.lookups, this.schemaOptions, arcContext?._scope),
|
|
967
974
|
filters: portableFilters,
|
|
968
975
|
search: parsed.search,
|
|
969
|
-
after:
|
|
976
|
+
...after !== void 0 ? { after } : {},
|
|
977
|
+
...before !== void 0 ? { before } : {},
|
|
978
|
+
...keysetMode ? { mode: "keyset" } : {},
|
|
970
979
|
user: req.user,
|
|
971
980
|
context: arcContext
|
|
972
981
|
};
|
|
@@ -1018,11 +1027,11 @@ var QueryResolver = class {
|
|
|
1018
1027
|
return sanitized.length > 0 ? sanitized : void 0;
|
|
1019
1028
|
}
|
|
1020
1029
|
/**
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1030
|
+
* Client lookups, deny-by-default. An allowed one is structurally validated, and its joined side
|
|
1031
|
+
* is filtered by the resource's server-owned `LookupPolicy.scope` — the base collection's tenant
|
|
1032
|
+
* filter never reaches a join, and a client-supplied `where` is replaced, never merged.
|
|
1024
1033
|
*/
|
|
1025
|
-
sanitizeLookups(lookups, schemaOptions) {
|
|
1034
|
+
sanitizeLookups(lookups, schemaOptions, scope) {
|
|
1026
1035
|
if (!lookups || lookups.length === 0) return void 0;
|
|
1027
1036
|
const allowedLookups = schemaOptions.query?.allowedLookups;
|
|
1028
1037
|
if (!allowedLookups) {
|
|
@@ -1030,15 +1039,17 @@ var QueryResolver = class {
|
|
|
1030
1039
|
return;
|
|
1031
1040
|
}
|
|
1032
1041
|
const validFieldName = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
|
|
1033
|
-
const sanitized = lookups.
|
|
1034
|
-
if (!lookup.from || !lookup.localField || !lookup.foreignField) return
|
|
1035
|
-
if (!validFieldName.test(lookup.from)) return
|
|
1036
|
-
if (!validFieldName.test(lookup.localField)) return
|
|
1037
|
-
if (!validFieldName.test(lookup.foreignField)) return
|
|
1038
|
-
|
|
1039
|
-
return
|
|
1042
|
+
const sanitized = lookups.flatMap((lookup) => {
|
|
1043
|
+
if (!lookup.from || !lookup.localField || !lookup.foreignField) return [];
|
|
1044
|
+
if (!validFieldName.test(lookup.from)) return [];
|
|
1045
|
+
if (!validFieldName.test(lookup.localField)) return [];
|
|
1046
|
+
if (!validFieldName.test(lookup.foreignField)) return [];
|
|
1047
|
+
const policy = Object.hasOwn(allowedLookups, lookup.from) ? allowedLookups[lookup.from] : void 0;
|
|
1048
|
+
return policy ? [{
|
|
1049
|
+
...lookup,
|
|
1050
|
+
where: policy.scope({ scope })
|
|
1051
|
+
}] : [];
|
|
1040
1052
|
});
|
|
1041
|
-
if (sanitized.length > 0) noteUnscopedJoin(sanitized);
|
|
1042
1053
|
return sanitized.length > 0 ? sanitized : void 0;
|
|
1043
1054
|
}
|
|
1044
1055
|
/**
|
|
@@ -1060,8 +1071,7 @@ var QueryResolver = class {
|
|
|
1060
1071
|
* whoever wrote it.
|
|
1061
1072
|
*/
|
|
1062
1073
|
isBlockedProjection(path, blockedFields) {
|
|
1063
|
-
|
|
1064
|
-
return blockedFields.some((blocked) => field === blocked || field.startsWith(`${blocked}.`));
|
|
1074
|
+
return pathTouchesBlocked(path.replace(/^-/, ""), new Set(blockedFields));
|
|
1065
1075
|
}
|
|
1066
1076
|
/**
|
|
1067
1077
|
* Refuse a read that FILTERS or SORTS on a `hidden` field.
|
|
@@ -1084,11 +1094,7 @@ var QueryResolver = class {
|
|
|
1084
1094
|
const blocked = collectReadBlockedFields(this.schemaOptions);
|
|
1085
1095
|
if (!blocked) return;
|
|
1086
1096
|
const offenders = /* @__PURE__ */ new Set();
|
|
1087
|
-
const isBlocked = (key) =>
|
|
1088
|
-
if (blocked.has(key)) return true;
|
|
1089
|
-
const dot = key.indexOf(".");
|
|
1090
|
-
return dot > 0 && blocked.has(key.slice(0, dot));
|
|
1091
|
-
};
|
|
1097
|
+
const isBlocked = (key) => pathTouchesBlocked(key, blocked);
|
|
1092
1098
|
const walk = (node, depth) => {
|
|
1093
1099
|
if (depth > 8 || !node || typeof node !== "object") return;
|
|
1094
1100
|
if (Array.isArray(node)) {
|
|
@@ -1114,14 +1120,33 @@ var QueryResolver = class {
|
|
|
1114
1120
|
})));
|
|
1115
1121
|
}
|
|
1116
1122
|
};
|
|
1123
|
+
/**
|
|
1124
|
+
* `mode` and `before` — lifted out of the query so no
|
|
1125
|
+
* parser reads them as filters. An unknown `mode` is refused with a closed code.
|
|
1126
|
+
*/
|
|
1127
|
+
function readKeysetControls(query) {
|
|
1128
|
+
if (!query || !("mode" in query || "before" in query)) return {
|
|
1129
|
+
query,
|
|
1130
|
+
stripped: false
|
|
1131
|
+
};
|
|
1132
|
+
const { mode, before, ...rest } = query;
|
|
1133
|
+
if (mode !== void 0 && mode !== "offset" && mode !== "keyset") throw createDomainError("arc.pagination.invalid_mode", `mode must be 'offset' or 'keyset', got '${String(mode)}'`, 400);
|
|
1134
|
+
if (before !== void 0 && (typeof before !== "string" || before === "")) throw new ValidationError("before must be a non-empty cursor string", [{
|
|
1135
|
+
field: "before",
|
|
1136
|
+
message: "a cursor from a page's prev"
|
|
1137
|
+
}]);
|
|
1138
|
+
return {
|
|
1139
|
+
query: rest,
|
|
1140
|
+
stripped: true,
|
|
1141
|
+
...mode !== void 0 ? { mode } : {},
|
|
1142
|
+
...typeof before === "string" ? { before } : {}
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1117
1145
|
//#endregion
|
|
1118
1146
|
//#region src/core/BaseCrudController.ts
|
|
1119
1147
|
/**
|
|
1120
1148
|
* Framework-agnostic CRUD controller implementing IController.
|
|
1121
|
-
*
|
|
1122
|
-
* Composes AccessControl, BodySanitizer, and QueryResolver. All shared
|
|
1123
|
-
* state and helpers are `protected` so the preset mixins (SoftDelete,
|
|
1124
|
-
* Tree, Slug, Bulk) can extend cleanly.
|
|
1149
|
+
* Composes AccessControl, BodySanitizer, and QueryResolver.
|
|
1125
1150
|
*
|
|
1126
1151
|
* @template TDoc - The document type.
|
|
1127
1152
|
* @template TRepository - The repository type (defaults to RepositoryLike).
|
|
@@ -1135,73 +1160,38 @@ var BaseCrudController = class {
|
|
|
1135
1160
|
resourceName;
|
|
1136
1161
|
tenantField;
|
|
1137
1162
|
idField = "_id";
|
|
1138
|
-
/**
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
* supplies tenant/idField/matchesFilter post-construction. Same model as
|
|
1143
|
-
* `queryResolver` after `setQueryParser` shipped in 2.10.9.
|
|
1144
|
-
*/
|
|
1163
|
+
/** Alternate unique keys, each with its own access control over the SAME scope. */
|
|
1164
|
+
lookupAccess = [];
|
|
1165
|
+
_lookupFields;
|
|
1166
|
+
/** Access control (ID filtering, policy checks, org scope, ownership). */
|
|
1145
1167
|
accessControl;
|
|
1146
|
-
/**
|
|
1147
|
-
* Composable body sanitization (field permissions, system fields).
|
|
1148
|
-
*
|
|
1149
|
-
* Not `readonly` — `configure()` rebuilds it when the host
|
|
1150
|
-
* supplies schemaOptions/onFieldWriteDenied post-construction.
|
|
1151
|
-
*/
|
|
1168
|
+
/** Body sanitization (field permissions, system fields). */
|
|
1152
1169
|
bodySanitizer;
|
|
1153
|
-
/**
|
|
1154
|
-
* Composable query resolution (parsing, pagination, sort, select/populate).
|
|
1155
|
-
*
|
|
1156
|
-
* Not `readonly` — `setQueryParser()` rebuilds this resolver to swap in a
|
|
1157
|
-
* different parser (e.g. mongokit's `QueryParser`). `defineResource` calls
|
|
1158
|
-
* it automatically when a resource supplies both `controller` and
|
|
1159
|
-
* `queryParser`.
|
|
1160
|
-
*/
|
|
1170
|
+
/** Query resolution (parsing, pagination, sort, select/populate). */
|
|
1161
1171
|
queryResolver;
|
|
1162
1172
|
_matchesFilter;
|
|
1163
1173
|
_presetFields = {};
|
|
1164
1174
|
_cacheConfig;
|
|
1165
1175
|
/**
|
|
1166
|
-
* Retained
|
|
1167
|
-
*
|
|
1168
|
-
* falling back to the sub-component defaults (`-createdAt` sort,
|
|
1169
|
-
* `reject` write-denial) — `defaultSort: false` matters for kits
|
|
1170
|
-
* without a `createdAt` column, `strip` for legacy write semantics.
|
|
1176
|
+
* Retained so partial `configure()` rebuilds reuse them instead of falling back
|
|
1177
|
+
* to sub-component defaults (`-createdAt` sort breaks kits without that column).
|
|
1171
1178
|
*/
|
|
1172
1179
|
_defaultSort;
|
|
1173
1180
|
_onFieldWriteDenied;
|
|
1174
1181
|
_onImmutableWrite;
|
|
1175
1182
|
/**
|
|
1176
|
-
* Domain commands bound to the write slots
|
|
1177
|
-
*
|
|
1178
|
-
* injection, actor stamp, hook sandwich) runs exactly as it does without
|
|
1179
|
-
* one, which is the entire point of the seam.
|
|
1183
|
+
* Domain commands bound to the write slots. Consulted at the PERSISTENCE step
|
|
1184
|
+
* only — sanitizer, tenant injection, actor stamp, and hooks run unchanged.
|
|
1180
1185
|
*/
|
|
1181
1186
|
_writes;
|
|
1182
1187
|
/** Transactional write envelope — see `ResourceConfig.transactional`. */
|
|
1183
1188
|
_transactional = false;
|
|
1189
|
+
/** Version field under optimistic concurrency — `null` when the resource did not opt in. */
|
|
1190
|
+
_versionField = null;
|
|
1184
1191
|
constructor(repository, options = {}) {
|
|
1185
1192
|
this.repository = repository;
|
|
1186
1193
|
this.schemaOptions = options.schemaOptions ?? {};
|
|
1187
1194
|
this.queryParser = options.queryParser ?? getDefaultQueryParser();
|
|
1188
|
-
/**
|
|
1189
|
-
* The parser's cap is consulted BEFORE the framework default.
|
|
1190
|
-
*
|
|
1191
|
-
* A resource that hands us a parser has already stated how large a page it
|
|
1192
|
-
* can serve — a bounded catalog like a chart of accounts declares 1000 so
|
|
1193
|
-
* pickers can read it whole. Defaulting straight to 100 here silently
|
|
1194
|
-
* overrode that: the resource asked for 1000, `QueryResolver` received a
|
|
1195
|
-
* `maxLimit` of 100 that was indistinguishable from a host deliberately
|
|
1196
|
-
* choosing 100, and the list came back truncated with a `200`. be-prod
|
|
1197
|
-
* served 100 of 696 accounts that way, and every account picker in the
|
|
1198
|
-
* product was short by 85% with nothing anywhere to report it.
|
|
1199
|
-
*
|
|
1200
|
-
* Order matters and is the whole point: an explicit `options.maxLimit` from
|
|
1201
|
-
* the host still wins over the parser, and the parser wins over this
|
|
1202
|
-
* framework floor. A general default must only ever fill a gap — never
|
|
1203
|
-
* outrank something specific that was actually stated.
|
|
1204
|
-
*/
|
|
1205
1195
|
this.maxLimit = options.maxLimit ?? this.queryParser?.maxLimit ?? 100;
|
|
1206
1196
|
this.defaultLimit = options.defaultLimit ?? 20;
|
|
1207
1197
|
this.resourceName = options.resourceName;
|
|
@@ -1215,11 +1205,14 @@ var BaseCrudController = class {
|
|
|
1215
1205
|
this._onImmutableWrite = options.onImmutableWrite;
|
|
1216
1206
|
this._writes = options.writes;
|
|
1217
1207
|
this._transactional = options.transactional === true;
|
|
1208
|
+
this._versionField = options.concurrency ? options.concurrency.versionField ?? "__v" : null;
|
|
1218
1209
|
this.accessControl = new AccessControl({
|
|
1219
1210
|
tenantField: this.tenantField,
|
|
1220
1211
|
idField: this.idField,
|
|
1221
1212
|
matchesFilter: this._matchesFilter
|
|
1222
1213
|
});
|
|
1214
|
+
this._lookupFields = options.lookupFields;
|
|
1215
|
+
this.lookupAccess = this.buildLookupAccess(options.lookupFields);
|
|
1223
1216
|
this.bodySanitizer = new BodySanitizer({
|
|
1224
1217
|
schemaOptions: this.schemaOptions,
|
|
1225
1218
|
onFieldWriteDenied: this._onFieldWriteDenied,
|
|
@@ -1235,63 +1228,39 @@ var BaseCrudController = class {
|
|
|
1235
1228
|
});
|
|
1236
1229
|
this.list = this.list.bind(this);
|
|
1237
1230
|
this.get = this.get.bind(this);
|
|
1238
|
-
/**
|
|
1239
|
-
* The write methods are bound here, which means EVERY instance carries an
|
|
1240
|
-
* own `create`/`update`/`delete` property — so "does it have an own
|
|
1241
|
-
* property" cannot distinguish arc's binding from a host's override.
|
|
1242
|
-
*
|
|
1243
|
-
* Marking what arc installed makes the difference observable. A class-field
|
|
1244
|
-
* override (`update = async (req) => …`) initialises AFTER `super()`
|
|
1245
|
-
* returns and therefore REPLACES this bound method outright — the mark goes
|
|
1246
|
-
* with it, which is exactly the signal `warnOnWriteMethodOverride` needs.
|
|
1247
|
-
* A prototype-method override binds through here and stays marked; that
|
|
1248
|
-
* shape is caught by the prototype comparison instead.
|
|
1249
|
-
*/
|
|
1250
1231
|
this.create = markArcBoundWrite(this.create.bind(this));
|
|
1251
1232
|
this.update = markArcBoundWrite(this.update.bind(this));
|
|
1252
1233
|
this.delete = markArcBoundWrite(this.delete.bind(this));
|
|
1253
1234
|
}
|
|
1254
1235
|
/**
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1257
|
-
*
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1236
|
+
* Bind the resource this controller serves — hooks, audit, events and cache key on it.
|
|
1237
|
+
* `defineResource` calls it for a supplied controller; an unset name is assigned, a
|
|
1238
|
+
* different one throws (the controller would run another resource's hooks).
|
|
1239
|
+
*/
|
|
1240
|
+
bindResourceName(name) {
|
|
1241
|
+
if (this.resourceName === void 0) {
|
|
1242
|
+
this.resourceName = name;
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (this.resourceName !== name) throw new Error(`Resource "${name}": its controller is bound to resourceName "${this.resourceName}", so hooks, audit and events would key on the wrong resource.`);
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Swap the controller's query parser. Mutates the existing `QueryResolver` in
|
|
1249
|
+
* place, so the resolver stays referentially stable. `defineResource` calls this
|
|
1250
|
+
* (duck-typed) when a resource supplies both `controller` and `queryParser`.
|
|
1264
1251
|
*/
|
|
1265
1252
|
setQueryParser(queryParser) {
|
|
1266
1253
|
this.queryParser = queryParser;
|
|
1267
1254
|
this.queryResolver.setParser(queryParser);
|
|
1268
1255
|
}
|
|
1269
1256
|
/**
|
|
1270
|
-
* Apply resource-level options to a
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
* user-supplied controller never received them. Hosts had to remember to
|
|
1275
|
-
* forward each one through `super(repo, { ... })` — easy to miss, silently
|
|
1276
|
-
* mis-scopes queries when missed.
|
|
1277
|
-
*
|
|
1278
|
-
* `defineResource()` now calls `controller.configure(resolvedOpts)` after
|
|
1279
|
-
* `resolveOrAutoCreateController()` runs. Configure-aware controllers receive
|
|
1280
|
-
* the resolved values; arc skips the dropped-options warn for them.
|
|
1281
|
-
*
|
|
1282
|
-
* Only the keys that affect cross-cutting state (tenant scope, schema/field
|
|
1283
|
-
* rules, sort/limit policy, cache, write-denial policy) are honoured —
|
|
1284
|
-
* `repository` / `resourceName` are constructor-only because they participate
|
|
1285
|
-
* in mixin composition. Each known key rebuilds the affected sub-component
|
|
1286
|
-
* (AccessControl / BodySanitizer / QueryResolver) so referentially-stable
|
|
1287
|
-
* consumers don't see stale state.
|
|
1288
|
-
*
|
|
1289
|
-
* Idempotent: safe to call zero, one, or many times before first request;
|
|
1290
|
-
* arc calls it exactly once.
|
|
1257
|
+
* Apply resource-level options to a controller AFTER construction.
|
|
1258
|
+
* `defineResource()` calls this once after resolving the controller, so custom
|
|
1259
|
+
* controllers receive tenant scope, field rules, sort/limit, cache, and
|
|
1260
|
+
* write-denial policy without forwarding them through `super(repo, { … })`.
|
|
1291
1261
|
*
|
|
1292
|
-
*
|
|
1293
|
-
* construction-only and
|
|
1294
|
-
* the resource at runtime" calls fail to typecheck.
|
|
1262
|
+
* Each key rebuilds only the affected sub-component. Idempotent.
|
|
1263
|
+
* `repository` / `resourceName` are construction-only and excluded by type.
|
|
1295
1264
|
*/
|
|
1296
1265
|
configure(options) {
|
|
1297
1266
|
let rebuildAccessControl = false;
|
|
@@ -1340,11 +1309,19 @@ var BaseCrudController = class {
|
|
|
1340
1309
|
if (options.presetFields !== void 0) this._presetFields = options.presetFields;
|
|
1341
1310
|
if (options.writes !== void 0) this._writes = options.writes;
|
|
1342
1311
|
if (options.transactional !== void 0) this._transactional = options.transactional === true;
|
|
1343
|
-
if (
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
}
|
|
1312
|
+
if (options.concurrency !== void 0) this._versionField = options.concurrency ? options.concurrency.versionField ?? "__v" : null;
|
|
1313
|
+
if (options.lookupFields !== void 0) {
|
|
1314
|
+
this._lookupFields = options.lookupFields;
|
|
1315
|
+
rebuildAccessControl = true;
|
|
1316
|
+
}
|
|
1317
|
+
if (rebuildAccessControl) {
|
|
1318
|
+
this.accessControl = new AccessControl({
|
|
1319
|
+
tenantField: this.tenantField,
|
|
1320
|
+
idField: this.idField,
|
|
1321
|
+
matchesFilter: this._matchesFilter
|
|
1322
|
+
});
|
|
1323
|
+
this.lookupAccess = this.buildLookupAccess(this._lookupFields);
|
|
1324
|
+
}
|
|
1348
1325
|
if (rebuildBodySanitizer) this.bodySanitizer = new BodySanitizer({
|
|
1349
1326
|
schemaOptions: this.schemaOptions,
|
|
1350
1327
|
onFieldWriteDenied: this._onFieldWriteDenied,
|
|
@@ -1359,25 +1336,15 @@ var BaseCrudController = class {
|
|
|
1359
1336
|
tenantField: this.tenantField
|
|
1360
1337
|
});
|
|
1361
1338
|
}
|
|
1362
|
-
/**
|
|
1363
|
-
* Get the tenant field name if multi-tenant scoping is enabled.
|
|
1364
|
-
* Returns `undefined` when `tenantField` is `false`.
|
|
1365
|
-
*/
|
|
1339
|
+
/** Tenant field name, or `undefined` when `tenantField` is `false`. */
|
|
1366
1340
|
getTenantField() {
|
|
1367
1341
|
return this.tenantField || void 0;
|
|
1368
1342
|
}
|
|
1369
1343
|
/**
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
* and duck-typed consumers (aggregation router, MCP bridge) keep the
|
|
1375
|
-
* same `protected` entry point.
|
|
1376
|
-
*
|
|
1377
|
-
* Method kept named `tenantRepoOptions` for back-compat with hosts
|
|
1378
|
-
* that spread `...this.tenantRepoOptions(req)` (10+ call sites in
|
|
1379
|
-
* arc, plus host overrides). The bag has always grown over time —
|
|
1380
|
-
* hosts that don't want audit forwarding never read those keys.
|
|
1344
|
+
* Canonical repo-options bag (tenant scope, audit attribution, trace
|
|
1345
|
+
* correlation) — see `buildTenantRepoOptions` in `crud/requestPipeline.ts`.
|
|
1346
|
+
* Kept as a `protected` entry point for host overrides and duck-typed
|
|
1347
|
+
* consumers (aggregation router, MCP bridge).
|
|
1381
1348
|
*/
|
|
1382
1349
|
tenantRepoOptions(req) {
|
|
1383
1350
|
return buildTenantRepoOptions(req, this.tenantField, this.meta(req));
|
|
@@ -1400,18 +1367,11 @@ var BaseCrudController = class {
|
|
|
1400
1367
|
return resolveMutationRepoId(id, existing, this.idField, this.repository);
|
|
1401
1368
|
}
|
|
1402
1369
|
/**
|
|
1403
|
-
*
|
|
1370
|
+
* Shared preflight for mutations: extract `:id`, fetch under access control +
|
|
1371
|
+
* tenant scope, verify ownership, translate the route id to the repo primary
|
|
1372
|
+
* key. One path so `update` and `delete` cannot drift.
|
|
1404
1373
|
*
|
|
1405
|
-
*
|
|
1406
|
-
* repo: (1) extract `:id`, (2) fetch under access control + tenant scope,
|
|
1407
|
-
* (3) verify ownership, (4) translate the route id to the repo's primary
|
|
1408
|
-
* key. Returning `{id, existing, repoId}` keeps the call sites a single
|
|
1409
|
-
* line and makes drift between `update` and `delete` structurally
|
|
1410
|
-
* impossible — there is one preflight, one denial-reason mapping, one
|
|
1411
|
-
* ownership check.
|
|
1412
|
-
*
|
|
1413
|
-
* Pass `extraFetchOptions` for callers (e.g. soft-delete restore) that
|
|
1414
|
-
* need to widen the fetch (`{ includeDeleted: true }`).
|
|
1374
|
+
* `extraFetchOptions` widens the fetch (e.g. restore's `{ includeDeleted: true }`).
|
|
1415
1375
|
*/
|
|
1416
1376
|
async loadMutableTarget(req, extraFetchOptions) {
|
|
1417
1377
|
const id = this.requireIdParam(req);
|
|
@@ -1430,10 +1390,83 @@ var BaseCrudController = class {
|
|
|
1430
1390
|
};
|
|
1431
1391
|
}
|
|
1432
1392
|
/**
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
1393
|
+
* Whether the target of an id-bound ACTION is a row this request may read — the same
|
|
1394
|
+
* compound filter (id + tenant + `_policyFilters`) `GET /:id` uses. The action router
|
|
1395
|
+
* refuses the action with a 404 when it is not, so a caller can act on a row iff it can
|
|
1396
|
+
* read it. Skips the read when neither tenancy nor a row policy narrows the request.
|
|
1397
|
+
*/
|
|
1398
|
+
buildLookupAccess(fields) {
|
|
1399
|
+
return (fields ?? []).filter((field) => field !== this.idField).map((idField) => new AccessControl({
|
|
1400
|
+
tenantField: this.tenantField,
|
|
1401
|
+
idField,
|
|
1402
|
+
matchesFilter: this._matchesFilter
|
|
1403
|
+
}));
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* THE single-row lookup — `GET /:id` and the action-target fence both read through it.
|
|
1407
|
+
* The primary key first; when it finds nothing (or the id cannot be that key's type), each
|
|
1408
|
+
* `lookupFields` key in turn, under the same tenant and row policy.
|
|
1409
|
+
*/
|
|
1410
|
+
async fetchByAnyKey(id, req, options) {
|
|
1411
|
+
let primary;
|
|
1412
|
+
let primaryError;
|
|
1413
|
+
try {
|
|
1414
|
+
primary = await this.accessControl.fetchDetailed(id, req, this.repository, options);
|
|
1415
|
+
if (primary.doc || this.lookupAccess.length === 0) return primary;
|
|
1416
|
+
} catch (err) {
|
|
1417
|
+
if (this.lookupAccess.length === 0) throw err;
|
|
1418
|
+
primaryError = err;
|
|
1419
|
+
}
|
|
1420
|
+
let answered = primary;
|
|
1421
|
+
for (const access of this.lookupAccess) try {
|
|
1422
|
+
const found = await access.fetchDetailed(id, req, this.repository, options);
|
|
1423
|
+
if (found.doc) return found;
|
|
1424
|
+
answered ??= found;
|
|
1425
|
+
} catch {}
|
|
1426
|
+
if (answered) return answered;
|
|
1427
|
+
throw primaryError;
|
|
1428
|
+
}
|
|
1429
|
+
/** Whether {@link isActionTargetVisible} can answer — it reads through the repository. */
|
|
1430
|
+
canFenceActionTargets() {
|
|
1431
|
+
return this.repository !== void 0 && this.repository !== null;
|
|
1432
|
+
}
|
|
1433
|
+
async isActionTargetVisible(req, id) {
|
|
1434
|
+
const meta = this.meta(req);
|
|
1435
|
+
const scoped = resolveTenancy(meta, this.tenantField).mode === "scoped";
|
|
1436
|
+
const policy = meta?._policyFilters;
|
|
1437
|
+
if (!scoped && (!policy || Object.keys(policy).length === 0)) return true;
|
|
1438
|
+
const { doc } = await this.fetchByAnyKey(id, req, this.tenantRepoOptions(req));
|
|
1439
|
+
return doc !== null && doc !== void 0;
|
|
1440
|
+
}
|
|
1441
|
+
/** `ETag: "<version>"` for a versioned document under optimistic concurrency. */
|
|
1442
|
+
etagHeaders(doc) {
|
|
1443
|
+
if (!this._versionField) return void 0;
|
|
1444
|
+
const version = doc?.[this._versionField];
|
|
1445
|
+
return typeof version === "number" ? { etag: `"${version}"` } : void 0;
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* The version an update must apply at. `If-Match` must name the CURRENT version or the
|
|
1449
|
+
* request fails 412 before anything is written; without it (or with `*`) the write is a CAS
|
|
1450
|
+
* on the version just loaded, so a concurrent write in between is a 409, never overwritten.
|
|
1451
|
+
*/
|
|
1452
|
+
expectedVersion(req, existing) {
|
|
1453
|
+
if (!this._versionField) return { fromClient: false };
|
|
1454
|
+
const current = existing[this._versionField];
|
|
1455
|
+
const header = requireSingleHeaderValue(req.headers, "if-match")?.trim();
|
|
1456
|
+
if (header === void 0 || header === "*") return typeof current === "number" ? {
|
|
1457
|
+
version: current,
|
|
1458
|
+
fromClient: false
|
|
1459
|
+
} : { fromClient: false };
|
|
1460
|
+
const offered = header.split(",").map((tag) => /^"(\d+)"$/.exec(tag.trim())?.[1]);
|
|
1461
|
+
if (typeof current !== "number" || !offered.includes(String(current))) throw createDomainError(ARC_ERROR_CODES.PRECONDITION_FAILED, `If-Match ${header} does not match the current version${typeof current === "number" ? ` "${current}"` : ""} — re-read and retry.`, 412);
|
|
1462
|
+
return {
|
|
1463
|
+
version: current,
|
|
1464
|
+
fromClient: true
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* 404 thrower. Maps the `fetchDetailed()` denial reason into `details.code` so
|
|
1469
|
+
* "doesn't exist" is distinguishable from "filtered by policy/org scope".
|
|
1437
1470
|
*/
|
|
1438
1471
|
throwNotFound(reason = "NOT_FOUND") {
|
|
1439
1472
|
throw buildNotFoundError(reason, this.resourceName);
|
|
@@ -1443,9 +1476,8 @@ var BaseCrudController = class {
|
|
|
1443
1476
|
return resolveOpCacheConfig(this._cacheConfig, operation);
|
|
1444
1477
|
}
|
|
1445
1478
|
/**
|
|
1446
|
-
*
|
|
1447
|
-
*
|
|
1448
|
-
* Universal resources (tenantField: false) get shared cache keys.
|
|
1479
|
+
* User/org IDs for cache key scoping. orgId is included only when `tenantField`
|
|
1480
|
+
* is set; universal resources share cache keys.
|
|
1449
1481
|
*/
|
|
1450
1482
|
cacheScope(req) {
|
|
1451
1483
|
return deriveCacheScope(req, this.tenantField, this.meta(req));
|
|
@@ -1460,11 +1492,7 @@ var BaseCrudController = class {
|
|
|
1460
1492
|
if (!id) throw createError(400, "ID parameter is required");
|
|
1461
1493
|
return id;
|
|
1462
1494
|
}
|
|
1463
|
-
/**
|
|
1464
|
-
* Normalizes `repo.exists()` return shapes across adapters. Per
|
|
1465
|
-
* StandardRepo's contract, `exists` may return `boolean`, `{ _id }`,
|
|
1466
|
-
* or `null` — every truthy non-null shape collapses to `true`.
|
|
1467
|
-
*/
|
|
1495
|
+
/** Normalizes `repo.exists()` (`boolean | { _id } | null`) to a boolean. */
|
|
1468
1496
|
isExistsTruthy(result) {
|
|
1469
1497
|
return isExistsResultTruthy(result);
|
|
1470
1498
|
}
|
|
@@ -1478,25 +1506,18 @@ var BaseCrudController = class {
|
|
|
1478
1506
|
};
|
|
1479
1507
|
}
|
|
1480
1508
|
/**
|
|
1481
|
-
* Run
|
|
1482
|
-
*
|
|
1483
|
-
* `ArcError` (status 400, code `BEFORE_<OP>_HOOK_ERROR`) when the
|
|
1484
|
-
* before-hook fails. Full contract on `executeHookedOp` in
|
|
1485
|
-
* `crud/requestPipeline.ts`.
|
|
1509
|
+
* Run before + around hooks (or just the executor if none are wired) and
|
|
1510
|
+
* return the result. A failing before-hook throws 400 `BEFORE_<OP>_HOOK_ERROR`.
|
|
1486
1511
|
*/
|
|
1487
1512
|
async runHookedOpUntilResult(req, args, executor) {
|
|
1488
1513
|
return executeHookedOp(this.hookOpContext(req), args, executor);
|
|
1489
1514
|
}
|
|
1490
1515
|
/**
|
|
1491
1516
|
* Run a write's PERSISTENCE step, transactionally when the resource asked.
|
|
1492
|
-
*
|
|
1493
|
-
*
|
|
1494
|
-
*
|
|
1495
|
-
*
|
|
1496
|
-
* transaction), transient conflicts re-run `fn` with jittered bounded
|
|
1497
|
-
* backoff, everything else — including `VersionConflictError` — surfaces
|
|
1498
|
-
* once. Hooks stay OUTSIDE: before/around ran already and must not re-run;
|
|
1499
|
-
* after-hooks run post-commit, which they already do by running after this.
|
|
1517
|
+
* Transactional mode uses `retryingTransaction`: `fn` gets the TX-BOUND repo and
|
|
1518
|
+
* re-runs on transient conflicts; anything else (incl. `VersionConflictError`)
|
|
1519
|
+
* surfaces once. Hooks stay OUTSIDE so before/around never re-run and after
|
|
1520
|
+
* runs post-commit.
|
|
1500
1521
|
*/
|
|
1501
1522
|
runWritePersistence(fn) {
|
|
1502
1523
|
if (!this._transactional) return fn(this.repository);
|
|
@@ -1516,16 +1537,9 @@ var BaseCrudController = class {
|
|
|
1516
1537
|
/**
|
|
1517
1538
|
* Assemble the `MutationWriteContext` an `update` / `delete` verb receives.
|
|
1518
1539
|
*
|
|
1519
|
-
* `existing` is the document
|
|
1520
|
-
*
|
|
1521
|
-
*
|
|
1522
|
-
* it over rather than letting the verb re-fetch is deliberate: a second
|
|
1523
|
-
* read can observe a different document than the one the request was
|
|
1524
|
-
* authorised against.
|
|
1525
|
-
*
|
|
1526
|
-
* `id` is the REPOSITORY PRIMARY KEY (`repoId`), never the raw route param —
|
|
1527
|
-
* see the note on `MutationWriteContext.id`. Callers pass `resolveRepoId`'s
|
|
1528
|
-
* output.
|
|
1540
|
+
* `existing` is the document the request was AUTHORISED against — handed over
|
|
1541
|
+
* so the verb does not re-fetch and possibly observe a different document.
|
|
1542
|
+
* `id` is the REPOSITORY PRIMARY KEY (`resolveRepoId` output), never the route param.
|
|
1529
1543
|
*/
|
|
1530
1544
|
mutationWriteContext(req, id, existing, repo = this.repository, uow) {
|
|
1531
1545
|
return {
|
|
@@ -1537,24 +1551,16 @@ var BaseCrudController = class {
|
|
|
1537
1551
|
};
|
|
1538
1552
|
}
|
|
1539
1553
|
/**
|
|
1540
|
-
* Run
|
|
1541
|
-
*
|
|
1542
|
-
* wants downstream after-handlers to receive — typically the result for
|
|
1543
|
-
* create/update, the original input (`existing`) for delete.
|
|
1554
|
+
* Run after-hooks for `op`. No-op without hooks or `resourceName`. `data` is the
|
|
1555
|
+
* result for create/update, `existing` for delete.
|
|
1544
1556
|
*/
|
|
1545
1557
|
async runAfterHook(req, op, data, meta) {
|
|
1546
1558
|
return executeAfterHook(this.hookOpContext(req), op, data, meta);
|
|
1547
1559
|
}
|
|
1548
1560
|
/**
|
|
1549
|
-
* Per-
|
|
1550
|
-
*
|
|
1551
|
-
*
|
|
1552
|
-
* readers must produce 1 DB query, not 1,000). Keys already encode the
|
|
1553
|
-
* resolved query + user/org scope + resource version, so coalescing can
|
|
1554
|
-
* never cross tenants, scopes, or invalidation generations. Per-process
|
|
1555
|
-
* by design; distributed refresh leases are a store-level concern
|
|
1556
|
-
* (planned with the repo-core CacheEngine façade, which ships
|
|
1557
|
-
* single-flight natively).
|
|
1561
|
+
* Per-process single-flight for cache fills: concurrent requests for the SAME
|
|
1562
|
+
* key coalesce onto one repository query. Keys encode query + user/org scope +
|
|
1563
|
+
* resource version, so coalescing never crosses tenants or invalidation generations.
|
|
1558
1564
|
*/
|
|
1559
1565
|
inFlightCacheFills = /* @__PURE__ */ new Map();
|
|
1560
1566
|
cacheSingleFlight(key, fill) {
|
|
@@ -1567,10 +1573,8 @@ var BaseCrudController = class {
|
|
|
1567
1573
|
return flight;
|
|
1568
1574
|
}
|
|
1569
1575
|
/**
|
|
1570
|
-
* Register a background SWR refresh for `key` unless one is
|
|
1571
|
-
*
|
|
1572
|
-
* burst of stale readers arriving in the same tick schedules exactly one
|
|
1573
|
-
* revalidation.
|
|
1576
|
+
* Register a background SWR refresh for `key` unless one is in flight.
|
|
1577
|
+
* Registration is SYNCHRONOUS so a same-tick burst of stale readers schedules one.
|
|
1574
1578
|
*/
|
|
1575
1579
|
scheduleCacheRefresh(key, refresh) {
|
|
1576
1580
|
if (this.inFlightCacheFills.has(key)) return;
|
|
@@ -1652,19 +1656,14 @@ var BaseCrudController = class {
|
|
|
1652
1656
|
};
|
|
1653
1657
|
}
|
|
1654
1658
|
/**
|
|
1655
|
-
* Resource-dispatch verbs router
|
|
1656
|
-
*
|
|
1657
|
-
*
|
|
1658
|
-
* Verbs (mutually exclusive — first match wins):
|
|
1659
|
+
* Resource-dispatch verbs router; `null` for a regular list query.
|
|
1660
|
+
* First match wins:
|
|
1659
1661
|
* - `?_count=true` → `{ count: number }` via `repo.count()`
|
|
1660
1662
|
* - `?_distinct=field` → `unknown[]` via `repo.distinct(field)`
|
|
1661
1663
|
* - `?_exists=true` → `{ exists: boolean }` via `repo.exists()`
|
|
1662
1664
|
*
|
|
1663
|
-
* All
|
|
1664
|
-
*
|
|
1665
|
-
* method get a `501` so failures surface loudly instead of falling
|
|
1666
|
-
* back to a full table scan. Matching + repo invocation live in
|
|
1667
|
-
* `crud/resourceVerbs.ts`.
|
|
1665
|
+
* All share the resolved filter (query + policy + tenant scope). A missing repo
|
|
1666
|
+
* method is a `501`, never a fallback to a full scan.
|
|
1668
1667
|
*/
|
|
1669
1668
|
dispatchResourceVerb(req) {
|
|
1670
1669
|
const verb = matchResourceVerb(req.query);
|
|
@@ -1704,16 +1703,12 @@ var BaseCrudController = class {
|
|
|
1704
1703
|
};
|
|
1705
1704
|
}
|
|
1706
1705
|
/**
|
|
1707
|
-
* True when `field` is safe to expose via `_distinct`.
|
|
1708
|
-
*
|
|
1709
|
-
* Read-side gate only — only `hidden: true` blocks. `systemManaged`
|
|
1710
|
-
* is a *write* rule (clients can't PATCH the value); the field is
|
|
1711
|
-
* still in every list response, so blocking `_distinct` adds nothing
|
|
1712
|
-
* but inconvenience. See `core/fieldRulePredicates.ts` for the
|
|
1713
|
-
* canonical predicate shared with `QueryResolver`.
|
|
1706
|
+
* True when `field` is safe to expose via `_distinct`. Only `hidden: true`
|
|
1707
|
+
* blocks — `systemManaged` is a write rule and the field is already readable.
|
|
1714
1708
|
*/
|
|
1715
1709
|
isFieldExposedForRead(field) {
|
|
1716
|
-
|
|
1710
|
+
const blocked = collectReadBlockedFields(this.schemaOptions);
|
|
1711
|
+
return !blocked || !pathTouchesBlocked(field, blocked, { descendants: true });
|
|
1717
1712
|
}
|
|
1718
1713
|
/** Execute list query through hooks (extracted for cache revalidation) */
|
|
1719
1714
|
async executeListQuery(options, req) {
|
|
@@ -1735,21 +1730,30 @@ var BaseCrudController = class {
|
|
|
1735
1730
|
...this.tenantRepoOptions(req)
|
|
1736
1731
|
};
|
|
1737
1732
|
const cached = await this.withGetCache(req, id, options);
|
|
1738
|
-
if (cached)
|
|
1733
|
+
if (cached) {
|
|
1734
|
+
const etag = this.etagHeaders(cached.data);
|
|
1735
|
+
return etag ? {
|
|
1736
|
+
...cached,
|
|
1737
|
+
headers: {
|
|
1738
|
+
...cached.headers,
|
|
1739
|
+
...etag
|
|
1740
|
+
}
|
|
1741
|
+
} : cached;
|
|
1742
|
+
}
|
|
1739
1743
|
const { doc, reason } = await this.executeGetQuery(id, options, req);
|
|
1740
1744
|
if (!doc) this.throwNotFound(reason);
|
|
1741
1745
|
await this.runAfterHook(req, "read", doc);
|
|
1746
|
+
const etag = this.etagHeaders(doc);
|
|
1742
1747
|
return {
|
|
1743
1748
|
data: doc,
|
|
1744
|
-
status: 200
|
|
1749
|
+
status: 200,
|
|
1750
|
+
...etag ? { headers: etag } : {}
|
|
1745
1751
|
};
|
|
1746
1752
|
}
|
|
1747
1753
|
/** Execute get query through hooks (extracted for cache revalidation) */
|
|
1748
1754
|
async executeGetQuery(id, options, req) {
|
|
1749
1755
|
const hooks = this.getHooks(req);
|
|
1750
|
-
const fetchItem = async () =>
|
|
1751
|
-
return await this.accessControl.fetchDetailed(id, req, this.repository, options);
|
|
1752
|
-
};
|
|
1756
|
+
const fetchItem = async () => this.fetchByAnyKey(id, req, options);
|
|
1753
1757
|
if (hooks && this.resourceName) {
|
|
1754
1758
|
const result = await fetchItem();
|
|
1755
1759
|
if (!result.doc) return result;
|
|
@@ -1766,6 +1770,7 @@ var BaseCrudController = class {
|
|
|
1766
1770
|
async create(req) {
|
|
1767
1771
|
const arcContext = this.meta(req);
|
|
1768
1772
|
const data = this.bodySanitizer.sanitize(req.body ?? {}, "create", req, arcContext);
|
|
1773
|
+
Object.assign(data, arcContext?._tenantFields);
|
|
1769
1774
|
const scope = arcContext?._scope;
|
|
1770
1775
|
const createOrgId = scope ? getOrgId(scope) : void 0;
|
|
1771
1776
|
if (this.tenantField && createOrgId) data[this.tenantField] = createOrgId;
|
|
@@ -1780,16 +1785,7 @@ var BaseCrudController = class {
|
|
|
1780
1785
|
context: arcContext,
|
|
1781
1786
|
...this.tenantRepoOptions(req)
|
|
1782
1787
|
})));
|
|
1783
|
-
|
|
1784
|
-
* A create COMMAND must return the created document — enforced, because
|
|
1785
|
-
* the alternative is quietly worse than any failure: a `201` carrying
|
|
1786
|
-
* `undefined` data, after-hooks (audit, events, cache invalidation) fed a
|
|
1787
|
-
* non-document, and a client that believes the write happened with no way
|
|
1788
|
-
* to address what it created. TypeScript types the contract, but arc is a
|
|
1789
|
-
* JavaScript runtime framework and a JS command returning nothing is one
|
|
1790
|
-
* missing `return` away.
|
|
1791
|
-
*/
|
|
1792
|
-
if (this._writes?.create && item == null) throw createError(500, `writes.create for resource "${this.resourceName ?? "unknown"}" returned ${item === null ? "null" : "undefined"} — a create command must return the created document. Signal failure by throwing a typed error; a nullish return would otherwise answer 201 with no document.`, { code: "WRITE_VERB_CONTRACT_VIOLATION" });
|
|
1788
|
+
if (this._writes?.create && item == null) throw createError(500, `writes.create for resource "${this.resourceName ?? "unknown"}" returned ${item === null ? "null" : "undefined"} — a create command must return the created document. Signal failure by throwing a typed error; a nullish return would otherwise answer 201 with no document.`, { code: ARC_ERROR_CODES.WRITE_VERB_CONTRACT_VIOLATION });
|
|
1793
1789
|
await this.runAfterHook(req, "create", item);
|
|
1794
1790
|
return {
|
|
1795
1791
|
data: item,
|
|
@@ -1800,6 +1796,7 @@ var BaseCrudController = class {
|
|
|
1800
1796
|
async update(req) {
|
|
1801
1797
|
const arcContext = this.meta(req);
|
|
1802
1798
|
const data = this.bodySanitizer.sanitize(req.body ?? {}, "update", req, arcContext);
|
|
1799
|
+
Object.assign(data, arcContext?._tenantFields);
|
|
1803
1800
|
const user = req.user;
|
|
1804
1801
|
const userId = getUserId(user);
|
|
1805
1802
|
if (userId) data.updatedBy = userId;
|
|
@@ -1808,6 +1805,7 @@ var BaseCrudController = class {
|
|
|
1808
1805
|
id,
|
|
1809
1806
|
existing
|
|
1810
1807
|
};
|
|
1808
|
+
const cas = this._writes?.update ? { fromClient: false } : this.expectedVersion(req, existing);
|
|
1811
1809
|
const item = await this.runHookedOpUntilResult(req, {
|
|
1812
1810
|
op: "update",
|
|
1813
1811
|
input: data,
|
|
@@ -1815,15 +1813,12 @@ var BaseCrudController = class {
|
|
|
1815
1813
|
}, async (processed) => this.runWritePersistence((repo, uow) => this._writes?.update ? this._writes.update(repoId, processed, this.mutationWriteContext(req, repoId, existing, repo, uow)) : repo.update(repoId, processed, {
|
|
1816
1814
|
user,
|
|
1817
1815
|
context: arcContext,
|
|
1818
|
-
...this.tenantRepoOptions(req)
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
* `null`-means-miss contract is the repository's, not a domain command's.
|
|
1825
|
-
* Re-read so the response still carries the document.
|
|
1826
|
-
*/
|
|
1816
|
+
...this.tenantRepoOptions(req),
|
|
1817
|
+
...cas.version !== void 0 ? { ifVersion: cas.version } : {}
|
|
1818
|
+
}))).catch((err) => {
|
|
1819
|
+
if (cas.fromClient && err?.code === "version_conflict") throw createDomainError(ARC_ERROR_CODES.PRECONDITION_FAILED, "The resource changed since it was read — re-read and retry.", 412);
|
|
1820
|
+
throw err;
|
|
1821
|
+
});
|
|
1827
1822
|
const resolved = this._writes?.update ? item ?? await this.repository.getById(repoId, {
|
|
1828
1823
|
user,
|
|
1829
1824
|
context: arcContext,
|
|
@@ -1831,10 +1826,12 @@ var BaseCrudController = class {
|
|
|
1831
1826
|
}) : item;
|
|
1832
1827
|
if (!resolved) this.throwNotFound("NOT_FOUND");
|
|
1833
1828
|
await this.runAfterHook(req, "update", resolved, hookMeta);
|
|
1829
|
+
const etag = this.etagHeaders(resolved);
|
|
1834
1830
|
return {
|
|
1835
1831
|
data: resolved,
|
|
1836
1832
|
status: 200,
|
|
1837
|
-
meta: { message: "Updated successfully" }
|
|
1833
|
+
meta: { message: "Updated successfully" },
|
|
1834
|
+
...etag ? { headers: etag } : {}
|
|
1838
1835
|
};
|
|
1839
1836
|
}
|
|
1840
1837
|
async delete(req) {
|
|
@@ -1868,12 +1865,9 @@ var BaseCrudController = class {
|
|
|
1868
1865
|
}
|
|
1869
1866
|
};
|
|
1870
1867
|
/**
|
|
1871
|
-
*
|
|
1872
|
-
*
|
|
1873
|
-
*
|
|
1874
|
-
* composition, zero per-instance cost. A duck-typed `configure()` is NOT this
|
|
1875
|
-
* proof: it shows an options channel exists, not that anything dispatches
|
|
1876
|
-
* `writes` out of it.
|
|
1868
|
+
* Marks that this class dispatches declared write verbs, so `defineResource` may
|
|
1869
|
+
* accept `writes` for controllers built on it (inherited by subclasses and mixins).
|
|
1870
|
+
* A duck-typed `configure()` is not proof that `writes` is dispatched.
|
|
1877
1871
|
*/
|
|
1878
1872
|
markWriteVerbCapable(BaseCrudController.prototype);
|
|
1879
1873
|
//#endregion
|
|
@@ -1905,22 +1899,18 @@ function BulkMixin(Base) {
|
|
|
1905
1899
|
const items = rawItems;
|
|
1906
1900
|
const arcContext = this.meta(req);
|
|
1907
1901
|
const user = req.user;
|
|
1908
|
-
const sanitizedItems = items.map((item) =>
|
|
1902
|
+
const sanitizedItems = items.map((item) => ({
|
|
1903
|
+
...this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext),
|
|
1904
|
+
...arcContext?._tenantFields
|
|
1905
|
+
}));
|
|
1909
1906
|
let scopedItems = sanitizedItems;
|
|
1910
|
-
if (this.tenantField) {
|
|
1911
|
-
const
|
|
1912
|
-
if (
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
const tenantField = this.tenantField;
|
|
1918
|
-
scopedItems = sanitizedItems.map((item) => ({
|
|
1919
|
-
...item,
|
|
1920
|
-
[tenantField]: orgId
|
|
1921
|
-
}));
|
|
1922
|
-
}
|
|
1923
|
-
}
|
|
1907
|
+
if (this.tenantField && arcContext?._scope) {
|
|
1908
|
+
const tenancy = resolveTenancy(arcContext, this.tenantField);
|
|
1909
|
+
if (tenancy.mode === "scoped") scopedItems = sanitizedItems.map((item) => ({
|
|
1910
|
+
...item,
|
|
1911
|
+
[tenancy.field]: tenancy.orgId
|
|
1912
|
+
}));
|
|
1913
|
+
else if (tenancy.reason !== "platform" && tenancy.reason !== "untenanted-resource") throw createError(403, "Organization context required to bulk-create resources", { code: "ORG_CONTEXT_REQUIRED" });
|
|
1924
1914
|
}
|
|
1925
1915
|
const created = await repo.createMany(scopedItems, {
|
|
1926
1916
|
user,
|
|
@@ -1947,28 +1937,26 @@ function BulkMixin(Base) {
|
|
|
1947
1937
|
/**
|
|
1948
1938
|
* Build a tenant-scoped filter for bulk update/delete.
|
|
1949
1939
|
*
|
|
1950
|
-
*
|
|
1940
|
+
* Same tenancy as `AccessControl.buildIdFilter` — both go through
|
|
1941
|
+
* `resolveTenancy`:
|
|
1951
1942
|
* - Always merge `_policyFilters` (from permission middleware)
|
|
1952
|
-
* -
|
|
1953
|
-
*
|
|
1954
|
-
* -
|
|
1943
|
+
* - A caller with an org (member, service, or an elevated admin who
|
|
1944
|
+
* selected one) → add org filter
|
|
1945
|
+
* - Elevated with NO org (platform view) → no org filter
|
|
1946
|
+
* - No org, or anonymous, on a tenant resource → deny
|
|
1955
1947
|
* - No scope at all (unit tests) → leave filter unchanged
|
|
1956
1948
|
*
|
|
1957
1949
|
* Returns the merged filter, or `null` when access must be denied.
|
|
1958
1950
|
*/
|
|
1959
1951
|
buildBulkFilter(userFilter, req) {
|
|
1960
1952
|
const arcContext = this.meta(req);
|
|
1961
|
-
|
|
1962
|
-
if (this.tenantField)
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
const orgId = getOrgId(scope);
|
|
1968
|
-
if (!orgId) return null;
|
|
1969
|
-
filter = conjoinPolicyFilters(filter, { [this.tenantField]: orgId });
|
|
1953
|
+
const filter = conjoinPolicyFilters(userFilter, arcContext?._policyFilters);
|
|
1954
|
+
if (!this.tenantField || !arcContext?._scope) return filter;
|
|
1955
|
+
const tenancy = resolveTenancy(arcContext, this.tenantField);
|
|
1956
|
+
switch (tenancy.mode) {
|
|
1957
|
+
case "scoped": return conjoinPolicyFilters(filter, { [tenancy.field]: tenancy.orgId });
|
|
1958
|
+
case "unscoped": return tenancy.reason === "platform" || tenancy.reason === "untenanted-resource" ? filter : null;
|
|
1970
1959
|
}
|
|
1971
|
-
return filter;
|
|
1972
1960
|
}
|
|
1973
1961
|
/**
|
|
1974
1962
|
* Sanitize a bulk update data payload through the same write-permission
|
|
@@ -2212,10 +2200,934 @@ function TreeMixin(Base) {
|
|
|
2212
2200
|
//#region src/core/BaseController.ts
|
|
2213
2201
|
/**
|
|
2214
2202
|
* Fully-composed controller: `BaseCrudController` + SoftDelete + Tree +
|
|
2215
|
-
* Slug + Bulk.
|
|
2216
|
-
* companion interface above gives every method full generic precision
|
|
2217
|
-
* on `TDoc` via declaration merging.
|
|
2203
|
+
* Slug + Bulk. The companion interface above types every method over `TDoc`.
|
|
2218
2204
|
*/
|
|
2219
2205
|
var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
|
|
2220
2206
|
//#endregion
|
|
2221
|
-
|
|
2207
|
+
//#region src/core/middlewares/fieldWrite.ts
|
|
2208
|
+
/**
|
|
2209
|
+
* Build a preHandler that enforces field-write permissions on the request body.
|
|
2210
|
+
*
|
|
2211
|
+
* Auto-CRUD's create/update routes get this for free via `BodySanitizer.sanitize()`
|
|
2212
|
+
* inside `BaseController`. Custom routes (`config.routes`, presets, action endpoints)
|
|
2213
|
+
* never went through that path — so a host that declared
|
|
2214
|
+
* `fields: { role: fields.writableBy(['admin']) }` and a custom
|
|
2215
|
+
* `POST /users/promote` happily accepted `{ role: 'admin' }` from any caller.
|
|
2216
|
+
*
|
|
2217
|
+
* This preHandler closes that gap. It applies ONLY the `applyFieldWritePermissions`
|
|
2218
|
+
* step — no `SYSTEM_FIELDS` strip, no `fieldRules` strip — because custom-route
|
|
2219
|
+
* bodies don't necessarily mirror the resource's adapter shape, and we'd
|
|
2220
|
+
* generate surprising 403s by enforcing rules tied to that shape.
|
|
2221
|
+
*
|
|
2222
|
+
* Returns `null` when the resource has no field permissions configured — the
|
|
2223
|
+
* caller should then skip wiring it into the preHandler chain.
|
|
2224
|
+
*/
|
|
2225
|
+
function buildFieldWritePreHandler(fieldPermissions, policy = DEFAULT_FIELD_WRITE_DENIAL_POLICY) {
|
|
2226
|
+
if (!fieldPermissions || Object.keys(fieldPermissions).length === 0) return null;
|
|
2227
|
+
return async (request, _reply) => {
|
|
2228
|
+
const body = request.body;
|
|
2229
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return;
|
|
2230
|
+
const scope = request.scope ?? PUBLIC_SCOPE;
|
|
2231
|
+
if (isElevated(scope)) return;
|
|
2232
|
+
const globalRoles = getUserRoles(request.user);
|
|
2233
|
+
const orgRoles = isMember(scope) ? scope.orgRoles : [];
|
|
2234
|
+
const effectiveRoles = resolveEffectiveRoles(globalRoles, orgRoles);
|
|
2235
|
+
const { body: filtered, deniedFields } = applyFieldWritePermissions(body, fieldPermissions, effectiveRoles);
|
|
2236
|
+
if (deniedFields.length > 0 && policy === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
|
|
2237
|
+
request.body = filtered;
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
/**
|
|
2241
|
+
* True for HTTP methods that carry a request body and should run the
|
|
2242
|
+
* field-write preHandler. GET/DELETE/HEAD/OPTIONS skip enforcement.
|
|
2243
|
+
*/
|
|
2244
|
+
function methodCarriesBody(method) {
|
|
2245
|
+
const m = method.toUpperCase();
|
|
2246
|
+
return m === "POST" || m === "PUT" || m === "PATCH";
|
|
2247
|
+
}
|
|
2248
|
+
//#endregion
|
|
2249
|
+
//#region src/core/createCrudRouter.ts
|
|
2250
|
+
/**
|
|
2251
|
+
* Fold a route's host-supplied `config` together with arc's derived one into the
|
|
2252
|
+
* SINGLE `config` key Fastify reads. Returns arc's object untouched when the host
|
|
2253
|
+
* supplied none, so the route options stay literally what was declared.
|
|
2254
|
+
*/
|
|
2255
|
+
function mergeRouteConfig(hostConfig, arcConfig) {
|
|
2256
|
+
if (!hostConfig) return arcConfig;
|
|
2257
|
+
return { config: {
|
|
2258
|
+
...hostConfig,
|
|
2259
|
+
...arcConfig.config ?? {}
|
|
2260
|
+
} };
|
|
2261
|
+
}
|
|
2262
|
+
/**
|
|
2263
|
+
* Wrap a raw handler so one that called `reply.send()` and resolved `undefined` returns the
|
|
2264
|
+
* reply: otherwise, while an async send hook (idempotency `onSend`, encryption `preSerialization`) is pending,
|
|
2265
|
+
* Fastify re-sends the `undefined` and the client gets an empty body.
|
|
2266
|
+
* Pinned by `tests/idempotency/raw-route-keyed-body.test.ts`.
|
|
2267
|
+
*/
|
|
2268
|
+
function keepRawReply(raw) {
|
|
2269
|
+
return async function rawRoute(request, reply) {
|
|
2270
|
+
let sendCalled = false;
|
|
2271
|
+
const send = reply.send;
|
|
2272
|
+
reply.send = function trackedSend(payload) {
|
|
2273
|
+
sendCalled = true;
|
|
2274
|
+
return send.call(this, payload);
|
|
2275
|
+
};
|
|
2276
|
+
try {
|
|
2277
|
+
const result = await raw.call(this, request, reply);
|
|
2278
|
+
return result === void 0 && (sendCalled || reply.sent) ? reply : result;
|
|
2279
|
+
} finally {
|
|
2280
|
+
reply.send = send;
|
|
2281
|
+
}
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
/** Mount custom routes (from presets or user-defined `routes`) on Fastify. */
|
|
2285
|
+
function createCustomRoutes(fastify, routes, controller, options) {
|
|
2286
|
+
const { tag, resourceName, arcDecorator, rateLimitConfig, pluginMw, pipeline, routeGuards, tenantScopeMw, fieldPermissions, onFieldWriteDenied, extensions } = options;
|
|
2287
|
+
for (const route of routes) {
|
|
2288
|
+
if ("raw" in route) throw new Error(`Route ${route.method} ${route.path}: \`raw\` was removed in arc 2.31 — the FIELD now carries the intent. Move the function to \`rawHandler\` (Fastify-native \`(request, reply)\`) or leave it in \`handler\` (arc pipeline, \`(ctx)\`), and delete the flag. If this route comes from a dependency, that package needs a release built against arc >=2.31.`);
|
|
2289
|
+
const routeWithRefs = route;
|
|
2290
|
+
const hasHandler = route.handler !== void 0 || route.rawHandler !== void 0;
|
|
2291
|
+
const hasControllerMethod = typeof routeWithRefs.controllerMethod === "function";
|
|
2292
|
+
if (hasHandler && hasControllerMethod) throw new Error(`Route ${route.method} ${route.path}: pass either \`handler\` or \`controllerMethod\`, not both. Prefer \`controllerMethod: (c: MyController) => c.method\` for typed handler refs (TS catches typos).`);
|
|
2293
|
+
if (!hasHandler && !hasControllerMethod) throw new Error(`Route ${route.method} ${route.path}: must declare one of \`handler\` (arc pipeline), \`rawHandler\` (Fastify-native), or \`controllerMethod: (c) => c.method\` (typed function-ref form).`);
|
|
2294
|
+
let resolvedHandler;
|
|
2295
|
+
if (hasControllerMethod) {
|
|
2296
|
+
if (!controller) throw new Error(`Route ${route.method} ${route.path}: \`controllerMethod\` requires a controller. Provide one via \`defineResource({ controller, … })\`, or use \`defineResource\` with an \`adapter\` so arc auto-creates a BaseController.`);
|
|
2297
|
+
const referenced = routeWithRefs.controllerMethod?.(controller);
|
|
2298
|
+
if (typeof referenced !== "function") throw new Error(`Route ${route.method} ${route.path}: \`controllerMethod\` did not return a function. Return the method itself: \`controllerMethod: (c) => c.myMethod\`.`);
|
|
2299
|
+
resolvedHandler = referenced.bind(controller);
|
|
2300
|
+
} else {
|
|
2301
|
+
if (route.rawHandler && route.handler) throw new Error(`Route ${route.method} ${route.path}: set either \`handler\` (arc pipeline) or \`rawHandler\` (Fastify-native), not both.`);
|
|
2302
|
+
resolvedHandler = route.rawHandler ?? route.handler;
|
|
2303
|
+
}
|
|
2304
|
+
const opName = route.operation ?? (typeof resolvedHandler === "string" ? resolvedHandler : `${route.method.toLowerCase()}${route.path.replace(/[/:]/g, "_")}`);
|
|
2305
|
+
const isRaw = route.rawHandler !== void 0;
|
|
2306
|
+
const wrapHandler = !isRaw;
|
|
2307
|
+
let handler;
|
|
2308
|
+
if (typeof resolvedHandler === "string") {
|
|
2309
|
+
if (!controller) throw new Error(`Route ${route.method} ${route.path}: string handler '${resolvedHandler}' requires a controller. Either provide a controller or use a function handler instead.`);
|
|
2310
|
+
const method = controller[resolvedHandler];
|
|
2311
|
+
if (typeof method !== "function") throw new Error(`Handler '${resolvedHandler}' not found on controller`);
|
|
2312
|
+
const boundMethod = method.bind(controller);
|
|
2313
|
+
if (wrapHandler) {
|
|
2314
|
+
const steps = resolvePipelineSteps(pipeline, opName);
|
|
2315
|
+
handler = steps.length > 0 ? buildPipelineHandler(boundMethod, steps, opName, resourceName) : createFastifyHandler(boundMethod);
|
|
2316
|
+
} else handler = keepRawReply(boundMethod);
|
|
2317
|
+
} else if (wrapHandler) {
|
|
2318
|
+
const steps = resolvePipelineSteps(pipeline, opName);
|
|
2319
|
+
handler = steps.length > 0 ? buildPipelineHandler(resolvedHandler, steps, opName, resourceName) : createFastifyHandler(resolvedHandler);
|
|
2320
|
+
} else handler = keepRawReply(resolvedHandler);
|
|
2321
|
+
const routeTags = route.tags ?? (tag ? [tag] : void 0);
|
|
2322
|
+
const convertedSchema = route.schema ? convertRouteSchema(route.schema) : void 0;
|
|
2323
|
+
const schema = {
|
|
2324
|
+
...routeTags ? { tags: routeTags } : {},
|
|
2325
|
+
...route.summary ? { summary: route.summary } : {},
|
|
2326
|
+
...route.description ? { description: route.description } : {},
|
|
2327
|
+
...convertedSchema ?? {}
|
|
2328
|
+
};
|
|
2329
|
+
const customPreHandlers = resolveRoutePreHandlers(route.preHandler, fastify, `${route.method} ${route.path}`);
|
|
2330
|
+
if (route.tenantScope === true) {
|
|
2331
|
+
if (!tenantScopeMw || tenantScopeMw.length === 0) throw new Error(`Route ${route.method} ${route.path}: \`tenantScope: true\` requires a multi-tenant preset. Add \`multiTenantPreset()\` to the resource's \`presets\`, or remove the \`tenantScope\` flag from this route.`);
|
|
2332
|
+
customPreHandlers.unshift(...tenantScopeMw);
|
|
2333
|
+
}
|
|
2334
|
+
if (!isRaw && route.fieldWrite !== false && methodCarriesBody(route.method)) {
|
|
2335
|
+
const fieldWriteMw = buildFieldWritePreHandler(fieldPermissions, onFieldWriteDenied);
|
|
2336
|
+
if (fieldWriteMw) customPreHandlers.push(fieldWriteMw);
|
|
2337
|
+
}
|
|
2338
|
+
const preAuthHandlers = route.preAuth ?? [];
|
|
2339
|
+
const hooks = buildRouteHooks({
|
|
2340
|
+
preAuth: preAuthHandlers,
|
|
2341
|
+
arcDecorator,
|
|
2342
|
+
authMw: buildAuthMiddleware(fastify, route.permissions),
|
|
2343
|
+
permissionMw: buildCrudPermissionMw(route.permissions, resourceName, opName),
|
|
2344
|
+
pluginMw: selectPluginMw(route.method, pluginMw),
|
|
2345
|
+
routeGuards,
|
|
2346
|
+
customMws: customPreHandlers
|
|
2347
|
+
});
|
|
2348
|
+
const isStream = route.streamResponse === true;
|
|
2349
|
+
tryRegisterRoute(fastify, {
|
|
2350
|
+
method: route.method,
|
|
2351
|
+
url: route.path,
|
|
2352
|
+
schema,
|
|
2353
|
+
...routeHookOptions(hooks),
|
|
2354
|
+
handler: isStream ? async function streamRoute(request, reply) {
|
|
2355
|
+
reply.raw.setHeader("Content-Type", "text/event-stream");
|
|
2356
|
+
reply.raw.setHeader("Cache-Control", "no-cache");
|
|
2357
|
+
reply.raw.setHeader("Connection", "keep-alive");
|
|
2358
|
+
const result = await handler.call(this, request, reply);
|
|
2359
|
+
if (isReadableStream(result)) {
|
|
2360
|
+
await pipeUIMessageStreamToReply(reply, result);
|
|
2361
|
+
return reply;
|
|
2362
|
+
}
|
|
2363
|
+
return result;
|
|
2364
|
+
} : handler,
|
|
2365
|
+
...mergeRouteConfig(route.config, buildRouteConfig(route.rateLimit !== void 0 ? buildRateLimitConfig(route.rateLimit) : rateLimitConfig, extensions, route.cors))
|
|
2366
|
+
}, {
|
|
2367
|
+
resourceName,
|
|
2368
|
+
op: opName,
|
|
2369
|
+
...route.rateLimit && typeof route.rateLimit === "object" ? { rateLimitBucket: `route:${route.method}:${route.path}` } : {}
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
/**
|
|
2374
|
+
* Create CRUD routes for a controller.
|
|
2375
|
+
*
|
|
2376
|
+
* @param fastify - Fastify instance with Arc decorators
|
|
2377
|
+
* @param controller - CRUD controller with handler methods (optional when
|
|
2378
|
+
* `disableDefaultRoutes: true` and only custom `routes`
|
|
2379
|
+
* are being registered)
|
|
2380
|
+
* @param options - Router configuration
|
|
2381
|
+
*/
|
|
2382
|
+
function createCrudRouter(fastify, controller, options = {}) {
|
|
2383
|
+
const { tag = "Resource", schemas = {}, permissions = {}, middlewares = {}, routeGuards = [], routes: customRoutes = [], disableDefaultRoutes = false, disabledRoutes = [], resourceName = "unknown", schemaOptions, rateLimit, pipe: pipeline, fields: fieldPermissions, onFieldWriteDenied, updateMethod = DEFAULT_UPDATE_METHOD, idField, extensions, idempotency } = options;
|
|
2384
|
+
const idempotentOps = new Set(idempotency?.required ?? []);
|
|
2385
|
+
for (const op of idempotentOps) if (disableDefaultRoutes || disabledRoutes.includes(op)) throw new Error(`[arc] Resource "${resourceName}" declares idempotency.required '${op}', but that built-in route is not mounted — drop it from idempotency.required.`);
|
|
2386
|
+
const rateLimitConfig = buildRateLimitConfig(rateLimit);
|
|
2387
|
+
const resourceHasQueryCache = fastify.hasDecorator("queryCache") && controller && typeof controller._cacheConfig !== "undefined" && controller._cacheConfig !== void 0;
|
|
2388
|
+
const pluginMw = resolveRouterPluginMw(fastify, Boolean(resourceHasQueryCache));
|
|
2389
|
+
const arcDecorator = buildArcDecorator({
|
|
2390
|
+
resourceName,
|
|
2391
|
+
schemaOptions,
|
|
2392
|
+
permissions,
|
|
2393
|
+
hooks: fastify.arc?.hooks,
|
|
2394
|
+
events: fastify.events,
|
|
2395
|
+
fields: fieldPermissions,
|
|
2396
|
+
idField
|
|
2397
|
+
});
|
|
2398
|
+
const mw = {
|
|
2399
|
+
list: middlewares.list ?? [],
|
|
2400
|
+
get: middlewares.get ?? [],
|
|
2401
|
+
create: middlewares.create ?? [],
|
|
2402
|
+
update: middlewares.update ?? [],
|
|
2403
|
+
delete: middlewares.delete ?? []
|
|
2404
|
+
};
|
|
2405
|
+
const tenantScopeMw = middlewares.tenantScope;
|
|
2406
|
+
const idParamsSchema = {
|
|
2407
|
+
type: "object",
|
|
2408
|
+
properties: { id: { type: "string" } },
|
|
2409
|
+
required: ["id"]
|
|
2410
|
+
};
|
|
2411
|
+
const defaultSchemas = getDefaultCrudSchemas();
|
|
2412
|
+
const buildSchema = (base, defaults, userSchema) => ({
|
|
2413
|
+
...defaults,
|
|
2414
|
+
...base,
|
|
2415
|
+
...userSchema ?? {}
|
|
2416
|
+
});
|
|
2417
|
+
if (!disableDefaultRoutes) {
|
|
2418
|
+
if (!controller) throw new Error("Controller is required when disableDefaultRoutes is not true. Provide a controller or use defineResource which auto-creates BaseController.");
|
|
2419
|
+
const handlers = buildCrudHandlers(controller, pipeline, resourceName);
|
|
2420
|
+
const crudTable = [
|
|
2421
|
+
{
|
|
2422
|
+
op: "list",
|
|
2423
|
+
method: "GET",
|
|
2424
|
+
url: "/",
|
|
2425
|
+
summary: `List ${tag}`,
|
|
2426
|
+
hasIdParams: false
|
|
2427
|
+
},
|
|
2428
|
+
{
|
|
2429
|
+
op: "get",
|
|
2430
|
+
method: "GET",
|
|
2431
|
+
url: "/:id",
|
|
2432
|
+
summary: `Get ${tag} by ID`,
|
|
2433
|
+
hasIdParams: true
|
|
2434
|
+
},
|
|
2435
|
+
{
|
|
2436
|
+
op: "create",
|
|
2437
|
+
method: "POST",
|
|
2438
|
+
url: "/",
|
|
2439
|
+
summary: `Create ${tag}`,
|
|
2440
|
+
hasIdParams: false
|
|
2441
|
+
},
|
|
2442
|
+
{
|
|
2443
|
+
op: "update",
|
|
2444
|
+
method: "PATCH",
|
|
2445
|
+
url: "/:id",
|
|
2446
|
+
summary: `Update ${tag}`,
|
|
2447
|
+
hasIdParams: true
|
|
2448
|
+
},
|
|
2449
|
+
{
|
|
2450
|
+
op: "delete",
|
|
2451
|
+
method: "DELETE",
|
|
2452
|
+
url: "/:id",
|
|
2453
|
+
summary: `Delete ${tag}`,
|
|
2454
|
+
hasIdParams: true
|
|
2455
|
+
}
|
|
2456
|
+
];
|
|
2457
|
+
for (const spec of crudTable) {
|
|
2458
|
+
if (disabledRoutes.includes(spec.op)) continue;
|
|
2459
|
+
const permission = permissions[spec.op];
|
|
2460
|
+
const hooks = buildRouteHooks({
|
|
2461
|
+
arcDecorator,
|
|
2462
|
+
authMw: buildAuthMiddleware(fastify, permission),
|
|
2463
|
+
permissionMw: buildCrudPermissionMw(permission, resourceName, spec.op),
|
|
2464
|
+
pluginMw: selectPluginMw(spec.method, pluginMw),
|
|
2465
|
+
routeGuards,
|
|
2466
|
+
customMws: mw[spec.op]
|
|
2467
|
+
});
|
|
2468
|
+
const methodsToRegister = spec.op === "update" ? updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod] : [spec.method];
|
|
2469
|
+
for (const method of methodsToRegister) {
|
|
2470
|
+
const summary = spec.op === "update" ? `${method === "PUT" ? "Replace" : "Update"} ${tag}` : spec.summary;
|
|
2471
|
+
tryRegisterRoute(fastify, {
|
|
2472
|
+
method,
|
|
2473
|
+
url: spec.url,
|
|
2474
|
+
schema: buildSchema({
|
|
2475
|
+
tags: [tag],
|
|
2476
|
+
summary,
|
|
2477
|
+
...spec.hasIdParams ? { params: idParamsSchema } : {}
|
|
2478
|
+
}, defaultSchemas[spec.op], schemas[spec.op]),
|
|
2479
|
+
...routeHookOptions(hooks),
|
|
2480
|
+
handler: handlers[spec.op],
|
|
2481
|
+
...buildRouteConfig(rateLimitConfig, extensions, void 0, idempotentOps.has(spec.op) ? { required: true } : void 0)
|
|
2482
|
+
}, {
|
|
2483
|
+
resourceName,
|
|
2484
|
+
op: spec.op
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
if (customRoutes.length > 0) createCustomRoutes(fastify, customRoutes, controller, {
|
|
2490
|
+
tag,
|
|
2491
|
+
resourceName,
|
|
2492
|
+
arcDecorator,
|
|
2493
|
+
rateLimitConfig,
|
|
2494
|
+
pluginMw,
|
|
2495
|
+
pipeline,
|
|
2496
|
+
routeGuards,
|
|
2497
|
+
tenantScopeMw,
|
|
2498
|
+
fieldPermissions,
|
|
2499
|
+
onFieldWriteDenied,
|
|
2500
|
+
extensions
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
function buildCrudHandlers(ctrl, pipeline, resourceName) {
|
|
2504
|
+
const standardHandlers = createCrudHandlers(ctrl);
|
|
2505
|
+
if (!pipeline) return standardHandlers;
|
|
2506
|
+
const wrapped = { ...standardHandlers };
|
|
2507
|
+
for (const op of CRUD_OPERATIONS) {
|
|
2508
|
+
const steps = resolvePipelineSteps(pipeline, op);
|
|
2509
|
+
if (steps.length === 0) continue;
|
|
2510
|
+
const method = ctrl[op].bind(ctrl);
|
|
2511
|
+
wrapped[op] = buildPipelineHandler(method, steps, op, resourceName);
|
|
2512
|
+
}
|
|
2513
|
+
return wrapped;
|
|
2514
|
+
}
|
|
2515
|
+
/**
|
|
2516
|
+
* Build a permission middleware from a PermissionCheck, for host routes
|
|
2517
|
+
* registered outside the resource system.
|
|
2518
|
+
*/
|
|
2519
|
+
function createPermissionMiddleware(permission, resourceName, action) {
|
|
2520
|
+
return buildCrudPermissionMw(permission, resourceName, action);
|
|
2521
|
+
}
|
|
2522
|
+
//#endregion
|
|
2523
|
+
//#region src/core/schemaOptions.ts
|
|
2524
|
+
/**
|
|
2525
|
+
* Inject the tenant-scoping field rule into `schemaOptions.fieldRules`:
|
|
2526
|
+
*
|
|
2527
|
+
* { [tenantField]: { systemManaged: true, preserveForElevated: true } }
|
|
2528
|
+
*
|
|
2529
|
+
* Why both flags: `systemManaged` tells `BodySanitizer` to strip the
|
|
2530
|
+
* field from inbound bodies (so member clients can't forge a target
|
|
2531
|
+
* tenant). `preserveForElevated` exempts elevated-admin scopes from the
|
|
2532
|
+
* strip, so platform admins without a pinned org can still pick a target
|
|
2533
|
+
* org via the request body (the only channel they have —
|
|
2534
|
+
* `BaseController.create` can't re-stamp from scope when scope has no
|
|
2535
|
+
* orgId).
|
|
2536
|
+
*
|
|
2537
|
+
* **Returns a new `RouteSchemaOptions`** — the input is never mutated.
|
|
2538
|
+
* Callers should assign the return value to whatever config slot they
|
|
2539
|
+
* read from downstream (always the `resolvedConfig`, never raw `config`).
|
|
2540
|
+
*
|
|
2541
|
+
* **No-op when:**
|
|
2542
|
+
* - `tenantField` is `false` (platform-universal resource)
|
|
2543
|
+
* - `tenantField` is undefined
|
|
2544
|
+
* - The caller already declared `fieldRules[tenantField].systemManaged`
|
|
2545
|
+
* (even as `false`) — explicit opt-outs are respected
|
|
2546
|
+
*
|
|
2547
|
+
* `preserveForElevated` defaults to `true` but is preserved verbatim
|
|
2548
|
+
* when the caller set it explicitly.
|
|
2549
|
+
*/
|
|
2550
|
+
function autoInjectTenantFieldRules(schemaOptions, tenantField) {
|
|
2551
|
+
if (tenantField === false || tenantField === void 0) return schemaOptions;
|
|
2552
|
+
const fieldName = tenantField || "organizationId";
|
|
2553
|
+
const existing = schemaOptions?.fieldRules ?? {};
|
|
2554
|
+
const existingRule = existing[fieldName];
|
|
2555
|
+
if (existingRule && existingRule.systemManaged !== void 0) return schemaOptions;
|
|
2556
|
+
return {
|
|
2557
|
+
...schemaOptions ?? {},
|
|
2558
|
+
fieldRules: {
|
|
2559
|
+
...existing,
|
|
2560
|
+
[fieldName]: {
|
|
2561
|
+
...existingRule ?? {},
|
|
2562
|
+
systemManaged: true,
|
|
2563
|
+
preserveForElevated: existingRule?.preserveForElevated ?? true
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
/**
|
|
2569
|
+
* Remove a field from a JSON Schema's `required[]` array. Leaves `properties`
|
|
2570
|
+
* intact so advanced callers can still send the value — the field just isn't
|
|
2571
|
+
* mandatory at validation time.
|
|
2572
|
+
*
|
|
2573
|
+
* Returns a fresh schema (no mutation). No-op when the schema is undefined,
|
|
2574
|
+
* lacks a `required[]`, or the field is already absent from it.
|
|
2575
|
+
*/
|
|
2576
|
+
function stripFromRequired(schema, fieldName) {
|
|
2577
|
+
if (!schema || typeof schema !== "object") return schema;
|
|
2578
|
+
const required = schema.required;
|
|
2579
|
+
if (!Array.isArray(required) || !required.includes(fieldName)) return schema;
|
|
2580
|
+
const filtered = required.filter((f) => f !== fieldName);
|
|
2581
|
+
const next = { ...schema };
|
|
2582
|
+
if (filtered.length > 0) next.required = filtered;
|
|
2583
|
+
else delete next.required;
|
|
2584
|
+
return next;
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* Drop `systemManaged` fields from `required[]` in generated body schemas.
|
|
2588
|
+
*
|
|
2589
|
+
* Arc populates those from scope / preset middleware / controller, so a client
|
|
2590
|
+
* never supplies them — but the DB schema may still mark them required, and the
|
|
2591
|
+
* adapter reflects that into the wire contract.
|
|
2592
|
+
*
|
|
2593
|
+
* THE GOTCHA THIS CLOSES: engines on `@classytic/primitives` default to
|
|
2594
|
+
* `tenant: { required: true }`, stamping `organizationId` as required. Fastify
|
|
2595
|
+
* preValidation runs BEFORE arc's preHandler chain, so the tenant-injection
|
|
2596
|
+
* hook never gets to run and the request dies on
|
|
2597
|
+
* `must have required property 'organizationId'` — even though the client sent
|
|
2598
|
+
* `x-organization-id` and arc had promised to inject it. The alternative was
|
|
2599
|
+
* `tenant: { required: false }` at every consumer site.
|
|
2600
|
+
*
|
|
2601
|
+
* Also covers `auditedPreset`'s `createdBy`/`updatedBy` and any host rule
|
|
2602
|
+
* marked `systemManaged`. Applies to create AND update, since update middleware
|
|
2603
|
+
* injects the same fields.
|
|
2604
|
+
*
|
|
2605
|
+
* `properties` is left INTACT — an elevated caller may still send these, and
|
|
2606
|
+
* `BodySanitizer` owns that runtime policy (`preserveForElevated`,
|
|
2607
|
+
* strip-vs-reject). No-op without `systemManaged` rules or generated schemas.
|
|
2608
|
+
*/
|
|
2609
|
+
function stripSystemManagedFromBodyRequired(schemas, schemaOptions) {
|
|
2610
|
+
if (!schemas) return schemas;
|
|
2611
|
+
const rules = schemaOptions?.fieldRules;
|
|
2612
|
+
if (!rules) return schemas;
|
|
2613
|
+
const systemManagedFields = Object.entries(rules).filter(([, rule]) => rule?.systemManaged === true).map(([field]) => field);
|
|
2614
|
+
if (systemManagedFields.length === 0) return schemas;
|
|
2615
|
+
const next = { ...schemas };
|
|
2616
|
+
let createBody = schemas.createBody;
|
|
2617
|
+
for (const field of systemManagedFields) createBody = stripFromRequired(createBody, field);
|
|
2618
|
+
if (createBody !== schemas.createBody) next.createBody = createBody;
|
|
2619
|
+
let updateBody = schemas.updateBody;
|
|
2620
|
+
for (const field of systemManagedFields) updateBody = stripFromRequired(updateBody, field);
|
|
2621
|
+
if (updateBody !== schemas.updateBody) next.updateBody = updateBody;
|
|
2622
|
+
return next;
|
|
2623
|
+
}
|
|
2624
|
+
//#endregion
|
|
2625
|
+
//#region src/core/defineResource/schemas.ts
|
|
2626
|
+
/**
|
|
2627
|
+
* OpenAPI schema resolution — Phase 7 of `defineResource()`.
|
|
2628
|
+
*
|
|
2629
|
+
* Pipeline (each step is a pure function over `OpenApiSchemas | undefined`):
|
|
2630
|
+
*
|
|
2631
|
+
* adapter.generateSchemas()
|
|
2632
|
+
* → stripSystemManagedFromBodyRequired (from `../schemaOptions.js`)
|
|
2633
|
+
* → cleanLegacyObjectIdParams (idField safety net)
|
|
2634
|
+
* → layerQueryParserListQuery (kit's listQuery JSON Schema)
|
|
2635
|
+
* → mergeUserOpenApiOverrides (per-resource overrides)
|
|
2636
|
+
* → convertOpenApiSchemas (Zod → JSON Schema if needed)
|
|
2637
|
+
*
|
|
2638
|
+
* Non-fatal: if any step throws, the orchestrator returns `undefined` so
|
|
2639
|
+
* the resource still boots — docs / introspection / MCP tool schemas
|
|
2640
|
+
* degrade visibly instead of silently drifting.
|
|
2641
|
+
*
|
|
2642
|
+
* Pulled out of `defineResource.ts` so the central function reads as
|
|
2643
|
+
* orchestration only; the schema mechanics live next to each other and
|
|
2644
|
+
* are easier to evolve in isolation.
|
|
2645
|
+
*/
|
|
2646
|
+
/**
|
|
2647
|
+
* Phase 7 orchestrator — runs the schema pipeline and returns the
|
|
2648
|
+
* registry metadata for the resource. Returns `undefined` (with a
|
|
2649
|
+
* structured warn log) if any step throws.
|
|
2650
|
+
*/
|
|
2651
|
+
function resolveOpenApiSchemas(resolvedConfig) {
|
|
2652
|
+
try {
|
|
2653
|
+
let openApiSchemas = generateAdapterSchemas(resolvedConfig);
|
|
2654
|
+
openApiSchemas = stripSystemManagedFromBodyRequired(openApiSchemas, resolvedConfig.schemaOptions);
|
|
2655
|
+
openApiSchemas = cleanLegacyObjectIdParams(openApiSchemas, resolvedConfig.idField);
|
|
2656
|
+
openApiSchemas = layerQueryParserListQuery(openApiSchemas, resolvedConfig.queryParser);
|
|
2657
|
+
openApiSchemas = applyResourcePaginationCaps(openApiSchemas, resolvedConfig);
|
|
2658
|
+
openApiSchemas = mergeUserOpenApiOverrides(openApiSchemas, resolvedConfig.openApiSchemas);
|
|
2659
|
+
if (openApiSchemas) openApiSchemas = convertOpenApiSchemas(openApiSchemas);
|
|
2660
|
+
return {
|
|
2661
|
+
module: resolvedConfig.module,
|
|
2662
|
+
openApiSchemas
|
|
2663
|
+
};
|
|
2664
|
+
} catch (err) {
|
|
2665
|
+
arcLog("defineResource").warn(`OpenAPI/MCP schema generation failed for resource "${resolvedConfig.name}": ${err instanceof Error ? err.message : String(err)}. Resource will boot without registry metadata — OpenAPI docs and MCP tool schemas will be missing.`);
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* Step 1 — delegate to the adapter's `generateSchemas`. Returns
|
|
2671
|
+
* `undefined` when the adapter doesn't implement the optional method.
|
|
2672
|
+
*/
|
|
2673
|
+
function generateAdapterSchemas(resolvedConfig) {
|
|
2674
|
+
if (!resolvedConfig.adapter?.generateSchemas) return void 0;
|
|
2675
|
+
const adapterContext = {
|
|
2676
|
+
idField: resolvedConfig.idField,
|
|
2677
|
+
resourceName: resolvedConfig.name
|
|
2678
|
+
};
|
|
2679
|
+
return resolvedConfig.adapter.generateSchemas(resolvedConfig.schemaOptions, adapterContext);
|
|
2680
|
+
}
|
|
2681
|
+
/**
|
|
2682
|
+
* Safety net: when `idField` is overridden to a non-default value (UUIDs,
|
|
2683
|
+
* slugs, ORD-2026-0001), strip any ObjectId pattern left on `params.id` by
|
|
2684
|
+
* legacy adapters or plugins that didn't honor `AdapterSchemaContext.idField`.
|
|
2685
|
+
* Custom IDs must not be rejected by AJV before BaseController runs the
|
|
2686
|
+
* actual lookup.
|
|
2687
|
+
*/
|
|
2688
|
+
function cleanLegacyObjectIdParams(openApiSchemas, idField) {
|
|
2689
|
+
if (!openApiSchemas || !idField || idField === "_id") return openApiSchemas;
|
|
2690
|
+
const params = withoutObjectIdPattern(openApiSchemas.params, `${idField} (custom ID field)`);
|
|
2691
|
+
return params === openApiSchemas.params ? openApiSchemas : {
|
|
2692
|
+
...openApiSchemas,
|
|
2693
|
+
params
|
|
2694
|
+
};
|
|
2695
|
+
}
|
|
2696
|
+
/**
|
|
2697
|
+
* `params` with the ObjectId pattern removed from `id` — the same object when there is none.
|
|
2698
|
+
* `GET /:id` on a resource with `lookupFields` uses it: the id may be a business number.
|
|
2699
|
+
*/
|
|
2700
|
+
function withoutObjectIdPattern(params, description) {
|
|
2701
|
+
if (!params || typeof params !== "object") return params;
|
|
2702
|
+
const properties = params.properties;
|
|
2703
|
+
const idProp = properties?.id;
|
|
2704
|
+
if (!idProp || typeof idProp !== "object") return params;
|
|
2705
|
+
const pattern = idProp.pattern;
|
|
2706
|
+
if (!(typeof pattern === "string" && (pattern === "^[0-9a-fA-F]{24}$" || pattern === "^[a-f\\d]{24}$" || pattern === "^[a-fA-F0-9]{24}$" || /^\^\[[a-fA-F0-9\\d]+\]\{24\}\$$/.test(pattern)))) return params;
|
|
2707
|
+
const cleanedId = { ...idProp };
|
|
2708
|
+
delete cleanedId.pattern;
|
|
2709
|
+
delete cleanedId.minLength;
|
|
2710
|
+
delete cleanedId.maxLength;
|
|
2711
|
+
if (!cleanedId.description) cleanedId.description = description;
|
|
2712
|
+
return {
|
|
2713
|
+
...params,
|
|
2714
|
+
properties: {
|
|
2715
|
+
...properties,
|
|
2716
|
+
id: cleanedId
|
|
2717
|
+
}
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
/**
|
|
2721
|
+
* Layer the query parser's `getQuerySchema()` output as `listQuery` so
|
|
2722
|
+
* the kit's filterable-fields surface flows into OpenAPI / MCP without
|
|
2723
|
+
* the user re-declaring it.
|
|
2724
|
+
*/
|
|
2725
|
+
function layerQueryParserListQuery(openApiSchemas, queryParser) {
|
|
2726
|
+
const qp = queryParser;
|
|
2727
|
+
if (!qp?.getQuerySchema) return openApiSchemas;
|
|
2728
|
+
const querySchema = qp.getQuerySchema();
|
|
2729
|
+
if (!querySchema) return openApiSchemas;
|
|
2730
|
+
return {
|
|
2731
|
+
...openApiSchemas,
|
|
2732
|
+
listQuery: querySchema
|
|
2733
|
+
};
|
|
2734
|
+
}
|
|
2735
|
+
/**
|
|
2736
|
+
* Apply resource-level `defaultLimit` / `maxLimit` (2.17.0) onto the
|
|
2737
|
+
* listQuery schema. Resource-level caps win over both the framework
|
|
2738
|
+
* default and the parser-derived value because they're the most-explicit
|
|
2739
|
+
* declaration — a host that wrote `defaultLimit: 1000` on the resource
|
|
2740
|
+
* means "this resource specifically returns up to 1000", regardless of
|
|
2741
|
+
* what the parser's default would have been. The `referenceData: true`
|
|
2742
|
+
* shorthand lifts both to 1000 so reference-data resources don't need
|
|
2743
|
+
* a custom queryParser just to get the caps right.
|
|
2744
|
+
*
|
|
2745
|
+
* Bootstraps a minimal listQuery slot when none exists (no queryParser
|
|
2746
|
+
* registered, no adapter-generated schema) so the AJV layer still gets
|
|
2747
|
+
* a `maximum:` and surfaces the cap in the 400 envelope via
|
|
2748
|
+
* `errorHandler.describePaginationCap()` instead of producing the
|
|
2749
|
+
* generic "Bad Request" we ate pre-2.17.0.
|
|
2750
|
+
*/
|
|
2751
|
+
function applyResourcePaginationCaps(openApiSchemas, resolvedConfig) {
|
|
2752
|
+
const cfg = resolvedConfig;
|
|
2753
|
+
if (cfg.defaultLimit === void 0 && cfg.maxLimit === void 0) return openApiSchemas;
|
|
2754
|
+
const existing = openApiSchemas?.listQuery;
|
|
2755
|
+
const existingProps = existing?.properties ?? {};
|
|
2756
|
+
const patchedLimit = { ...existingProps.limit ?? {
|
|
2757
|
+
type: "integer",
|
|
2758
|
+
minimum: 1
|
|
2759
|
+
} };
|
|
2760
|
+
if (cfg.maxLimit !== void 0) patchedLimit.maximum = cfg.maxLimit;
|
|
2761
|
+
if (cfg.defaultLimit !== void 0) patchedLimit.default = cfg.defaultLimit;
|
|
2762
|
+
return {
|
|
2763
|
+
...openApiSchemas,
|
|
2764
|
+
listQuery: {
|
|
2765
|
+
type: "object",
|
|
2766
|
+
...existing ?? {},
|
|
2767
|
+
properties: {
|
|
2768
|
+
...existingProps,
|
|
2769
|
+
limit: patchedLimit
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
};
|
|
2773
|
+
}
|
|
2774
|
+
/**
|
|
2775
|
+
* Apply per-resource `openApiSchemas` overrides on top of the kit's
|
|
2776
|
+
* generated schemas. Shallow merge by slot — users who want field-level
|
|
2777
|
+
* surgery should compose at the schema-options layer before this point.
|
|
2778
|
+
*/
|
|
2779
|
+
function mergeUserOpenApiOverrides(openApiSchemas, userOverrides) {
|
|
2780
|
+
if (!userOverrides) return openApiSchemas;
|
|
2781
|
+
return {
|
|
2782
|
+
...openApiSchemas,
|
|
2783
|
+
...userOverrides
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
//#endregion
|
|
2787
|
+
//#region src/core/defineResource/plugin.ts
|
|
2788
|
+
/**
|
|
2789
|
+
* Build the CRUD schema map from the adapter's `OpenApiSchemas`, then layer
|
|
2790
|
+
* `customSchemas` on top per slot (auto-gen always runs, so customising one
|
|
2791
|
+
* slot never disables the others). Returns `null` when neither contributes.
|
|
2792
|
+
*
|
|
2793
|
+
* **`params` cloning is load-bearing:** `get` / `delete` / `update` each own a
|
|
2794
|
+
* clone, so a downstream mutation on one slot cannot leak into the others.
|
|
2795
|
+
*/
|
|
2796
|
+
function buildGeneratedCrudSchemas(openApi, customSchemas) {
|
|
2797
|
+
const generated = {};
|
|
2798
|
+
if (openApi) {
|
|
2799
|
+
const { createBody, updateBody, params } = openApi;
|
|
2800
|
+
if (createBody) generated.create = { body: safeBody(createBody) };
|
|
2801
|
+
if (updateBody) {
|
|
2802
|
+
const patchBody = structuredClone(updateBody);
|
|
2803
|
+
stripRequiredDeep(patchBody);
|
|
2804
|
+
generated.update = { body: safeBody(patchBody) };
|
|
2805
|
+
if (params) generated.update.params = cloneShallow(params);
|
|
2806
|
+
}
|
|
2807
|
+
if (params) {
|
|
2808
|
+
generated.get = { params: cloneShallow(params) };
|
|
2809
|
+
generated.delete = { params: cloneShallow(params) };
|
|
2810
|
+
if (!generated.update) generated.update = { params: cloneShallow(params) };
|
|
2811
|
+
else if (!generated.update.params) generated.update.params = cloneShallow(params);
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
let schemas = Object.keys(generated).length > 0 ? generated : null;
|
|
2815
|
+
if (customSchemas && Object.keys(customSchemas).length > 0) {
|
|
2816
|
+
schemas = schemas ?? {};
|
|
2817
|
+
for (const [op, customSchema] of Object.entries(customSchemas)) {
|
|
2818
|
+
const key = op;
|
|
2819
|
+
const converted = convertRouteSchema(customSchema);
|
|
2820
|
+
schemas[key] = schemas[key] ? {
|
|
2821
|
+
...schemas[key],
|
|
2822
|
+
...converted
|
|
2823
|
+
} : converted;
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
return schemas;
|
|
2827
|
+
}
|
|
2828
|
+
/**
|
|
2829
|
+
* Normalize the listQuery JSON Schema for Fastify/AJV strict mode.
|
|
2830
|
+
*
|
|
2831
|
+
* `qs` turns bracket notation (`?name[contains]=foo`, `?tags[]=a`) into nested
|
|
2832
|
+
* objects/arrays that AJV rejects against adapters' `type: "string"` props. The
|
|
2833
|
+
* QueryParser owns filter validation, so every property collapses to `{}` except
|
|
2834
|
+
* integer `page` / `limit`; `limit.maximum` is preserved so the parser's cap holds.
|
|
2835
|
+
*/
|
|
2836
|
+
function normalizeListQuerySchema(listQuerySchema) {
|
|
2837
|
+
const NORMALIZED_PROPS = {
|
|
2838
|
+
page: {
|
|
2839
|
+
type: "integer",
|
|
2840
|
+
minimum: 1
|
|
2841
|
+
},
|
|
2842
|
+
limit: {
|
|
2843
|
+
type: "integer",
|
|
2844
|
+
minimum: 1
|
|
2845
|
+
},
|
|
2846
|
+
sort: {},
|
|
2847
|
+
search: {},
|
|
2848
|
+
select: {},
|
|
2849
|
+
after: {},
|
|
2850
|
+
populate: {},
|
|
2851
|
+
lookup: {},
|
|
2852
|
+
aggregate: {}
|
|
2853
|
+
};
|
|
2854
|
+
const props = listQuerySchema.properties;
|
|
2855
|
+
const normalizedProps = props ? { ...props } : void 0;
|
|
2856
|
+
if (normalizedProps) {
|
|
2857
|
+
const originalLimit = normalizedProps.limit;
|
|
2858
|
+
if (originalLimit?.maximum) NORMALIZED_PROPS.limit = {
|
|
2859
|
+
...NORMALIZED_PROPS.limit,
|
|
2860
|
+
maximum: originalLimit.maximum
|
|
2861
|
+
};
|
|
2862
|
+
for (const key of Object.keys(normalizedProps)) normalizedProps[key] = NORMALIZED_PROPS[key] ?? {};
|
|
2863
|
+
}
|
|
2864
|
+
const keysetProps = {
|
|
2865
|
+
mode: {
|
|
2866
|
+
type: "string",
|
|
2867
|
+
description: "Pagination mode: `offset` (default) or `keyset` (walk by `after` / `before`)."
|
|
2868
|
+
},
|
|
2869
|
+
before: {
|
|
2870
|
+
type: "string",
|
|
2871
|
+
description: "Cursor from a page's `prev` — the page before it."
|
|
2872
|
+
}
|
|
2873
|
+
};
|
|
2874
|
+
return {
|
|
2875
|
+
type: "object",
|
|
2876
|
+
...listQuerySchema,
|
|
2877
|
+
properties: {
|
|
2878
|
+
...keysetProps,
|
|
2879
|
+
...normalizedProps ?? listQuerySchema.properties ?? {}
|
|
2880
|
+
},
|
|
2881
|
+
additionalProperties: listQuerySchema.additionalProperties ?? true
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
/**
|
|
2885
|
+
* Deep-merge two JSON schema branches: arrays union (deduplicated), objects
|
|
2886
|
+
* recurse, primitives are overwritten.
|
|
2887
|
+
*/
|
|
2888
|
+
function deepMergeSchemas(base, override) {
|
|
2889
|
+
if (!override) return base;
|
|
2890
|
+
if (!base) return override;
|
|
2891
|
+
const result = { ...base };
|
|
2892
|
+
for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [.../* @__PURE__ */ new Set([...result[key], ...value])];
|
|
2893
|
+
else if (value && typeof value === "object" && !Array.isArray(value)) result[key] = deepMergeSchemas(result[key], value);
|
|
2894
|
+
else result[key] = value;
|
|
2895
|
+
return result;
|
|
2896
|
+
}
|
|
2897
|
+
function cloneShallow(value) {
|
|
2898
|
+
return { ...value };
|
|
2899
|
+
}
|
|
2900
|
+
/**
|
|
2901
|
+
* Delete every `required` from a JSON-schema subtree — PATCH means optional at
|
|
2902
|
+
* every depth (e.g. subdocument-array `items`), whatever the generator produced.
|
|
2903
|
+
*/
|
|
2904
|
+
function stripRequiredDeep(node) {
|
|
2905
|
+
if (Array.isArray(node)) {
|
|
2906
|
+
for (const item of node) stripRequiredDeep(item);
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
if (!node || typeof node !== "object") return;
|
|
2910
|
+
const obj = node;
|
|
2911
|
+
delete obj.required;
|
|
2912
|
+
for (const value of Object.values(obj)) stripRequiredDeep(value);
|
|
2913
|
+
}
|
|
2914
|
+
function safeBody(schema) {
|
|
2915
|
+
if (schema && typeof schema === "object" && schema.type === "object") return {
|
|
2916
|
+
additionalProperties: true,
|
|
2917
|
+
...schema
|
|
2918
|
+
};
|
|
2919
|
+
return schema;
|
|
2920
|
+
}
|
|
2921
|
+
/**
|
|
2922
|
+
* Normalize `ActionsMap` into the config `createActionRouter` expects.
|
|
2923
|
+
*
|
|
2924
|
+
* Permissions are resolved FAIL-CLOSED at boot via `actionPermissions.ts`: the
|
|
2925
|
+
* returned map carries every action's effective gate, and an action with none
|
|
2926
|
+
* throws. The resource-level gate uses slot `resourceActionPermissions`;
|
|
2927
|
+
* `globalAuth` stays reserved for direct `createActionRouter` callers.
|
|
2928
|
+
*/
|
|
2929
|
+
function normalizeActionsToRouterConfig(actions, resourceActionPermissions, tag, resourcePermissions, resourceName, log) {
|
|
2930
|
+
const handlers = {};
|
|
2931
|
+
const permissions = {};
|
|
2932
|
+
const schemas = {};
|
|
2933
|
+
const idLessActionNames = [];
|
|
2934
|
+
const handlerScopedActionNames = [];
|
|
2935
|
+
const idempotentActionNames = [];
|
|
2936
|
+
const handlerDedupOwners = {};
|
|
2937
|
+
for (const [name, entry] of Object.entries(actions)) {
|
|
2938
|
+
const explicit = typeof entry !== "function" && entry.permissions ? entry.permissions : void 0;
|
|
2939
|
+
if (typeof entry === "function") handlers[name] = entry;
|
|
2940
|
+
else {
|
|
2941
|
+
const def = entry;
|
|
2942
|
+
handlers[name] = def.handler;
|
|
2943
|
+
if (def.schema) schemas[name] = def.schema;
|
|
2944
|
+
if (def.id === false) idLessActionNames.push(name);
|
|
2945
|
+
if (def.idempotency?.required === true) idempotentActionNames.push(name);
|
|
2946
|
+
const problem = idempotencyConfigProblem({ idempotency: def.idempotency ? { ...def.idempotency } : void 0 });
|
|
2947
|
+
if (problem) throw new Error(`[Arc] Resource '${resourceName}': action '${name}' idempotency: ${problem}.`);
|
|
2948
|
+
if (def.idempotency?.dedup === "handler") handlerDedupOwners[name] = String(def.idempotency.owner);
|
|
2949
|
+
if (def.target !== void 0) {
|
|
2950
|
+
if (def.target.scope !== "handler" || def.target.reason.trim() === "") throw new Error(`[Arc] Resource '${resourceName}': action '${name}' declares \`target\` without \`scope: 'handler'\` and a non-empty \`reason\` — say why its target is not this resource's row.`);
|
|
2951
|
+
handlerScopedActionNames.push(name);
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
const effective = resolveActionPermission({
|
|
2955
|
+
action: entry,
|
|
2956
|
+
resourcePermissions,
|
|
2957
|
+
resourceActionPermissions,
|
|
2958
|
+
globalAuth: void 0
|
|
2959
|
+
});
|
|
2960
|
+
if (!explicit && !resourceActionPermissions && effective && effective === resourcePermissions?.update) log?.warn?.({
|
|
2961
|
+
resource: resourceName,
|
|
2962
|
+
action: name,
|
|
2963
|
+
fallback: "permissions.update"
|
|
2964
|
+
}, `[Arc] Action '${resourceName}.${name}' has no explicit permission — falling back to the resource's \`permissions.update\` gate. Declare \`actions.${name}.permissions\` (or resource \`actionPermissions\`) to silence this.`);
|
|
2965
|
+
if (!effective) throw new Error(`[Arc] Resource '${resourceName}': action '${name}' has no permission gate and the resource defines no \`permissions.update\` fallback. Declare one of:\n - \`actions.${name}.permissions: <PermissionCheck>\` (per-action)\n - \`actionPermissions: <PermissionCheck>\` (resource-wide)\n - \`permissions.update: <PermissionCheck>\` (inherited by actions)\nUse \`allowPublic()\` if you genuinely want the action unauthenticated.`);
|
|
2966
|
+
permissions[name] = effective;
|
|
2967
|
+
}
|
|
2968
|
+
return {
|
|
2969
|
+
tag,
|
|
2970
|
+
actions: handlers,
|
|
2971
|
+
actionPermissions: permissions,
|
|
2972
|
+
actionSchemas: schemas,
|
|
2973
|
+
idLessActionNames,
|
|
2974
|
+
handlerScopedActionNames,
|
|
2975
|
+
idempotentActionNames,
|
|
2976
|
+
handlerDedupOwners
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* The action-target fence: an id-bound action may act only on a row the caller could
|
|
2981
|
+
* `GET`. Built from the controller's access control; a tenant-scoped resource whose
|
|
2982
|
+
* controller cannot answer is refused at boot rather than mounted unfenced. HTTP and MCP
|
|
2983
|
+
* both call it, so the two surfaces fence the same rows.
|
|
2984
|
+
*/
|
|
2985
|
+
function resolveActionTargetFence(resource, actionConfig) {
|
|
2986
|
+
const exempt = /* @__PURE__ */ new Set([...actionConfig.idLessActionNames, ...actionConfig.handlerScopedActionNames]);
|
|
2987
|
+
const fenced = Object.keys(actionConfig.actions).filter((name) => !exempt.has(name));
|
|
2988
|
+
if (fenced.length === 0) return void 0;
|
|
2989
|
+
const ctrl = resource.controller;
|
|
2990
|
+
const check = ctrl?.isActionTargetVisible;
|
|
2991
|
+
if (!(typeof check === "function" && ctrl?.canFenceActionTargets?.() === true)) {
|
|
2992
|
+
if (typeof resource.tenantField === "string" || ctrl !== void 0 && resource.tenantField !== false) throw new Error(`[Arc] Resource '${resource.name}' is tenant-scoped (tenantField '${resource.tenantField === void 0 ? "organizationId (default)" : resource.tenantField}') and declares id-bound actions [${fenced.join(", ")}], but its controller cannot authorize an action target. Use a BaseController with a repository, declare \`tenantField: false\`, or declare \`target: { scope: 'handler', reason }\` on each action.`);
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
return (id, ctx) => check.call(ctrl, ctx, id);
|
|
2996
|
+
}
|
|
2997
|
+
/**
|
|
2998
|
+
* Build the FastifyPluginAsync that materialises a `ResourceDefinition` into
|
|
2999
|
+
* routes, hooks, registry entries, and cache invalidation rules. Can be
|
|
3000
|
+
* registered any number of times; shared-state writes happen once per host.
|
|
3001
|
+
*/
|
|
3002
|
+
function buildResourcePlugin(resource) {
|
|
3003
|
+
return async function resourcePlugin(fastify, opts) {
|
|
3004
|
+
await ensureSchemaConverter();
|
|
3005
|
+
const sharedRoot = fastify.server;
|
|
3006
|
+
const isFirstMount = !resource._sharedStateRegisteredOn.has(sharedRoot);
|
|
3007
|
+
if (isFirstMount) resource._sharedStateRegisteredOn.add(sharedRoot);
|
|
3008
|
+
if (isFirstMount && resource._deferredChecks?.length) for (const check of resource._deferredChecks) check();
|
|
3009
|
+
if (isFirstMount && resource._diagnostics?.length) for (const diagnostic of resource._diagnostics) {
|
|
3010
|
+
const level = diagnostic.severity === "info" ? "info" : diagnostic.severity === "error" ? "error" : "warn";
|
|
3011
|
+
fastify.log?.[level]?.(diagnostic.message);
|
|
3012
|
+
}
|
|
3013
|
+
const arc = fastify.arc;
|
|
3014
|
+
if (isFirstMount && arc?.registry && resource._registryMeta) arc.registry.register(resource, resource._registryMeta);
|
|
3015
|
+
if (isFirstMount && resource._pendingHooks.length > 0 && arc?.hooks) for (const hook of resource._pendingHooks) arc.hooks.register({
|
|
3016
|
+
resource: resource.name,
|
|
3017
|
+
operation: hook.operation,
|
|
3018
|
+
phase: hook.phase,
|
|
3019
|
+
handler: hook.handler,
|
|
3020
|
+
priority: hook.priority
|
|
3021
|
+
});
|
|
3022
|
+
if (isFirstMount && resource.cache?.invalidateOn) {
|
|
3023
|
+
if (fastify.registerCacheInvalidationRule) for (const [pattern, tags] of Object.entries(resource.cache.invalidateOn)) fastify.registerCacheInvalidationRule({
|
|
3024
|
+
pattern,
|
|
3025
|
+
tags
|
|
3026
|
+
});
|
|
3027
|
+
else fastify.log?.warn?.(`[Arc] Resource '${resource.name}' declares \`cache.invalidateOn\` but \`queryCachePlugin\` is not registered. Cache-invalidation rules are silently ignored. Either register the plugin (\`await fastify.register(queryCachePlugin, ...)\`) or remove the \`cache.invalidateOn\` config to clean up the resource definition.`);
|
|
3028
|
+
}
|
|
3029
|
+
if (isFirstMount && resource.audit) {
|
|
3030
|
+
if (!(fastify.hasDecorator("audit") && typeof fastify.audit?.create === "function" && fastify.audit?._noop !== true)) fastify.log?.warn?.(`[Arc] Resource '${resource.name}' declares \`audit: ${JSON.stringify(resource.audit)}\` but \`auditPlugin\` is not registered (or is registered with a noop logger). Audit events for this resource will NOT be recorded. Either register \`auditPlugin\` (\`await fastify.register(auditPlugin, { store: ... })\`) or remove the \`audit\` flag from the resource definition.`);
|
|
3031
|
+
}
|
|
3032
|
+
if (isFirstMount && resource.events && Object.keys(resource.events).length > 0) {
|
|
3033
|
+
const eventsDecor = fastify.events;
|
|
3034
|
+
if (!(eventsDecor != null && typeof eventsDecor.publish === "function")) {
|
|
3035
|
+
const eventNames = Object.keys(resource.events).join(", ");
|
|
3036
|
+
fastify.log?.warn?.(`[Arc] Resource '${resource.name}' declares events (${eventNames}) but \`eventPlugin\` is not registered. The events will silently NOT be emitted on CRUD mutations. Either register the plugin (\`await fastify.register(eventPlugin, { transport: ... })\`) or remove the \`events\` declaration from the resource.`);
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
if (opts?.arcMountRoutes === false) return;
|
|
3040
|
+
await fastify.register(async (instance) => {
|
|
3041
|
+
const typedInstance = instance;
|
|
3042
|
+
let schemas = buildGeneratedCrudSchemas(resource._registryMeta?.openApiSchemas, resource.customSchemas);
|
|
3043
|
+
const getParams = schemas?.get?.params;
|
|
3044
|
+
if (resource.lookupFields?.length && schemas?.get && getParams) schemas.get = {
|
|
3045
|
+
...schemas.get,
|
|
3046
|
+
params: withoutObjectIdPattern(getParams, `${resource.idField ?? "_id"} or ${resource.lookupFields.join(" / ")}`)
|
|
3047
|
+
};
|
|
3048
|
+
const listQuerySchema = resource._registryMeta?.openApiSchemas?.listQuery;
|
|
3049
|
+
if (listQuerySchema) {
|
|
3050
|
+
const normalizedSchema = normalizeListQuerySchema(listQuerySchema);
|
|
3051
|
+
schemas = schemas ?? {};
|
|
3052
|
+
schemas.list = schemas.list ? deepMergeSchemas({ querystring: normalizedSchema }, schemas.list) : { querystring: normalizedSchema };
|
|
3053
|
+
}
|
|
3054
|
+
createCrudRouter(typedInstance, resource.controller, {
|
|
3055
|
+
tag: resource.tag,
|
|
3056
|
+
schemas: schemas ?? void 0,
|
|
3057
|
+
permissions: resource.permissions,
|
|
3058
|
+
middlewares: resource.middlewares,
|
|
3059
|
+
routeGuards: resource.routeGuards,
|
|
3060
|
+
routes: resource.routes,
|
|
3061
|
+
disableDefaultRoutes: resource.disableDefaultRoutes,
|
|
3062
|
+
disabledRoutes: [...resource.disabledRoutes],
|
|
3063
|
+
resourceName: resource.name,
|
|
3064
|
+
schemaOptions: resource.schemaOptions,
|
|
3065
|
+
rateLimit: resource.rateLimit,
|
|
3066
|
+
idempotency: resource.idempotency,
|
|
3067
|
+
updateMethod: resource.updateMethod,
|
|
3068
|
+
pipe: resource.pipe,
|
|
3069
|
+
fields: resource.fields,
|
|
3070
|
+
onFieldWriteDenied: resource.onFieldWriteDenied,
|
|
3071
|
+
idField: resource.idField,
|
|
3072
|
+
extensions: resource.extensions
|
|
3073
|
+
});
|
|
3074
|
+
if (resource.actions && Object.keys(resource.actions).length > 0) {
|
|
3075
|
+
const { createActionRouter } = await import("./createActionRouter-xjqonMRO.mjs").then((n) => n.n);
|
|
3076
|
+
const actionConfig = normalizeActionsToRouterConfig(resource.actions, resource.actionPermissions, resource.tag, resource.permissions, resource.name, typedInstance.log);
|
|
3077
|
+
const actionTarget = resolveActionTargetFence(resource, actionConfig);
|
|
3078
|
+
createActionRouter(typedInstance, {
|
|
3079
|
+
...actionConfig,
|
|
3080
|
+
...actionTarget ? { isTargetVisible: (id, req) => actionTarget(id, createRequestContext(req)) } : {},
|
|
3081
|
+
auditActions: Boolean(resource.audit),
|
|
3082
|
+
resourceName: resource.name,
|
|
3083
|
+
fields: resource.fields,
|
|
3084
|
+
schemaOptions: resource.schemaOptions,
|
|
3085
|
+
idField: resource.idField,
|
|
3086
|
+
permissions: resource.permissions,
|
|
3087
|
+
routeGuards: resource.routeGuards,
|
|
3088
|
+
tenantMiddlewares: withoutTenantInjection(resource.middlewares?.tenantScope),
|
|
3089
|
+
pipeline: resource.pipe,
|
|
3090
|
+
rateLimit: resource.rateLimit,
|
|
3091
|
+
extensions: resource.extensions
|
|
3092
|
+
});
|
|
3093
|
+
}
|
|
3094
|
+
if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
|
|
3095
|
+
const [{ createAggregationRouter }, { adapterSupportsAggregate, ArcAggregationConfigError }] = await Promise.all([import("./createAggregationRouter-zRYWw_5D.mjs"), import("./validate-9yzHC1e2.mjs").then((n) => n.r)]);
|
|
3096
|
+
const repoForAgg = resource.controller?.repository ?? resource.repository;
|
|
3097
|
+
if (!adapterSupportsAggregate(repoForAgg)) {
|
|
3098
|
+
const undispatchable = Object.entries(resource.aggregations).filter(([, cfg]) => !cfg.materialized).map(([name]) => name);
|
|
3099
|
+
if (undispatchable.length > 0) throw new ArcAggregationConfigError(`Resource '${resource.name}' declares aggregations [${undispatchable.join(", ")}] but no repository implementing 'aggregate(req, options?)' is wired. Either (a) attach an adapter whose repo ships StandardRepo.aggregate (mongokit >= 3.13 / sqlitekit >= 0.3), (b) pass a custom controller exposing such a repository, or (c) declare 'materialized' on each aggregation so it dispatches through your own hook.`);
|
|
3100
|
+
}
|
|
3101
|
+
const buildOptions = (req) => {
|
|
3102
|
+
const ctrl = resource.controller;
|
|
3103
|
+
if (!ctrl?.tenantRepoOptions) return {};
|
|
3104
|
+
const ctx = createRequestContext(req);
|
|
3105
|
+
return ctrl.tenantRepoOptions(ctx);
|
|
3106
|
+
};
|
|
3107
|
+
createAggregationRouter(typedInstance, {
|
|
3108
|
+
tag: resource.tag,
|
|
3109
|
+
resourceName: resource.name,
|
|
3110
|
+
aggregations: resource.aggregations,
|
|
3111
|
+
fields: resource.fields,
|
|
3112
|
+
schemaOptions: resource.schemaOptions,
|
|
3113
|
+
permissions: resource.permissions,
|
|
3114
|
+
routeGuards: resource.routeGuards,
|
|
3115
|
+
repository: repoForAgg,
|
|
3116
|
+
buildOptions,
|
|
3117
|
+
middlewares: resource.middlewares?.aggregations
|
|
3118
|
+
});
|
|
3119
|
+
}
|
|
3120
|
+
if (resource.events && Object.keys(resource.events).length > 0) typedInstance.log?.debug?.(`Resource '${resource.name}' defined ${Object.keys(resource.events).length} events`);
|
|
3121
|
+
}, { prefix: resource.prefix });
|
|
3122
|
+
if (hasEvents(fastify)) try {
|
|
3123
|
+
await fastify.events.publish("arc.resource.registered", {
|
|
3124
|
+
resource: resource.name,
|
|
3125
|
+
prefix: resource.prefix,
|
|
3126
|
+
presets: resource._appliedPresets,
|
|
3127
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
3128
|
+
});
|
|
3129
|
+
} catch {}
|
|
3130
|
+
};
|
|
3131
|
+
}
|
|
3132
|
+
//#endregion
|
|
3133
|
+
export { toRepositoryFilter as C, nativePolicyFilter as S, pathTouchesBlocked as _, autoInjectTenantFieldRules as a, BodySanitizer as b, BaseController as c, SlugMixin as d, BulkMixin as f, isFieldReadable as g, collectReadBlockedFields as h, resolveOpenApiSchemas as i, TreeMixin as l, QueryResolver as m, normalizeActionsToRouterConfig as n, createCrudRouter as o, BaseCrudController as p, resolveActionTargetFence as r, createPermissionMiddleware as s, buildResourcePlugin as t, SoftDeleteMixin as u, detectOverriddenWriteSlots as v, AccessControl as x, isWriteVerbCapable as y };
|