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