@classytic/arc 2.41.0 → 2.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{EventTransport-CbUKDzZZ.d.mts → EventTransport-CiicKz2Z.d.mts} +1 -1
- package/dist/{EventTransport-n7qO5Cz3.mjs → EventTransport-tzA7CAB4.mjs} +22 -17
- package/dist/{HookSystem-CxvZBo77.mjs → HookSystem-B6UaKRbF.mjs} +2 -2
- package/dist/{QueryCache-DFA-QrU9.d.mts → QueryCache--5uAuYPW.d.mts} +7 -3
- package/dist/{ResourceRegistry-Cu7uTJp9.mjs → ResourceRegistry-CuCARu2B.mjs} +44 -13
- package/dist/actionPermissions-BotX1YOa.mjs +776 -0
- package/dist/audit/index.d.mts +43 -5
- package/dist/audit/index.mjs +48 -26
- package/dist/auth/audit.mjs +1 -1
- package/dist/auth/index.d.mts +43 -13
- package/dist/auth/index.mjs +130 -155
- package/dist/auth/redis-session.mjs +1 -1
- package/dist/{authorizationDecision-2YY_Kaj1.mjs → authorizationDecision-DQYrkl5f.mjs} +7 -102
- package/dist/{base-CxrV1LmG.d.mts → base-CZyixEQR.d.mts} +27 -5
- package/dist/boundedMap-BULzfXYU.mjs +57 -0
- package/dist/{buildHandler-CUhjGHGZ.mjs → buildHandler-CRQ8EcX5.mjs} +4 -4
- package/dist/cache/index.d.mts +22 -34
- package/dist/cache/index.mjs +4 -4
- package/dist/{caching-DsWDjIxx.mjs → caching-Cc2M1DwO.mjs} +3 -3
- package/dist/claimed-B10RTfkM.mjs +8 -0
- package/dist/cleanup/index.d.mts +6 -10
- package/dist/cleanup/index.mjs +27 -73
- package/dist/cli/commands/describe.d.mts +4 -2
- package/dist/cli/commands/describe.mjs +7 -4
- package/dist/cli/commands/docs.mjs +3 -3
- package/dist/cli/commands/doctor.mjs +1 -1
- package/dist/cli/commands/generate.mjs +2 -2
- package/dist/cli/commands/init.mjs +2 -2
- package/dist/cli/commands/introspect.mjs +2 -2
- package/dist/cli/index.mjs +3 -3
- package/dist/{constants-BxLgNFF7.mjs → constants-Cu7Mx7_U.mjs} +4 -2
- package/dist/context/index.d.mts +2 -5
- package/dist/context/index.mjs +18 -38
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +8 -8
- package/dist/{core-iNGqYerO.mjs → core-Cb0LjccA.mjs} +5 -4
- package/dist/{createActionRouter-BBlbPs3k.mjs → createActionRouter-xjqonMRO.mjs} +87 -39
- package/dist/{createAggregationRouter-Dv5SuK2x.mjs → createAggregationRouter-zRYWw_5D.mjs} +11 -8
- package/dist/{preloadResources-Bbp4QJ8V.mjs → createApp-D-953ZAq.mjs} +299 -390
- package/dist/{createMcpServer-CbDWkZ1T.d.mts → createMcpServer-C9cxS8zt.d.mts} +1 -1
- package/dist/{defineAggregation-UyeurGkp.mjs → defineAggregation-Bm8RjMbq.mjs} +1 -1
- package/dist/{defineResource-DzGT-2LP.mjs → defineResource-DIC7JZGU.mjs} +118 -1024
- package/dist/discovery/index.mjs +1 -1
- package/dist/docs/index.d.mts +10 -4
- package/dist/docs/index.mjs +4 -4
- package/dist/{elevation-BXCfVcNG.d.mts → elevation-BW2J3tNj.d.mts} +1 -2
- package/dist/{elevation-BF-vhGxY.mjs → elevation-DG8SrLN-.mjs} +41 -12
- package/dist/encryption/index.mjs +3 -3
- package/dist/{errorHandler-CIMkCOWm.mjs → errorHandler-BnfDviL1.mjs} +26 -5
- package/dist/{errors-C_YhELhe.mjs → errors-CtrywMHP.mjs} +58 -23
- package/dist/{errors-BGdVwP25.d.mts → errors-JYc9vwqE.d.mts} +9 -0
- package/dist/{eventPlugin-B52abJ8W.d.mts → eventPlugin-BtmA4-3P.d.mts} +65 -9
- package/dist/{eventPlugin-CmXaPWLv.mjs → eventPlugin-Q6BhzHzs.mjs} +82 -21
- package/dist/{subscribe-helpers-AVYPgqTl.mjs → eventTypes-Cdhm6ZlV.mjs} +151 -17
- package/dist/events/index.d.mts +33 -8
- package/dist/events/index.mjs +49 -53
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +139 -157
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +2 -2
- package/dist/factory/index.d.mts +32 -17
- package/dist/factory/index.mjs +213 -32
- package/dist/{fastify-augmentation-Cq2nHVaW.d.mts → fastify-augmentation-DhUJ-mvo.d.mts} +11 -25
- package/dist/{fields-I_EeRWND.d.mts → fields-BBSpf8pS.d.mts} +1 -1
- package/dist/{fields-BTyPLHK0.mjs → fields-ToPYpMqS.mjs} +1 -1
- package/dist/filter-merge-IpGywvym.mjs +99 -0
- package/dist/{generate-D_YmiCNf.mjs → generate-DRTkLlIl.mjs} +2 -2
- package/dist/{headers-CNECDE6q.mjs → headers-BbkIF6vA.mjs} +2 -2
- package/dist/health-D-Ft6JM6.mjs +22 -0
- package/dist/{health-CxQzbJmr.mjs → health-DqbsG-_w.mjs} +115 -55
- package/dist/hooks/index.d.mts +2 -2
- package/dist/hooks/index.mjs +2 -2
- package/dist/idempotency/index.d.mts +31 -7
- package/dist/idempotency/index.mjs +155 -98
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +48 -11
- package/dist/{index-BHdZD8zK.d.mts → index-Bxriq3so.d.mts} +84 -21
- package/dist/{preloadResources-CjS2OfpU.d.mts → index-C8zVGYsk.d.mts} +37 -38
- package/dist/{index-D4u76eQW.d.mts → index-CRoPtISf.d.mts} +23 -73
- package/dist/{index-CbJiYrfv.d.mts → index-Chgz3d0i.d.mts} +1 -1
- package/dist/index-JdbZAIsm.d.mts +510 -0
- package/dist/{index-DHbyPo5j.d.mts → index-mqaqZNpe.d.mts} +253 -254
- package/dist/index.d.mts +8 -8
- package/dist/index.mjs +11 -11
- package/dist/{init-Y2MlSN9n.mjs → init-B_oUYDXI.mjs} +1 -1
- package/dist/integrations/event-gateway.d.mts +8 -3
- package/dist/integrations/event-gateway.mjs +4 -4
- package/dist/integrations/index.d.mts +3 -3
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs/index.d.mts +1 -1
- package/dist/integrations/jobs/index.mjs +18 -5
- package/dist/integrations/mcp/index.d.mts +72 -59
- package/dist/integrations/mcp/index.mjs +19 -4
- package/dist/integrations/mcp/testing.d.mts +2 -2
- package/dist/integrations/mcp/testing.mjs +2 -2
- package/dist/integrations/streamline.d.mts +78 -136
- package/dist/integrations/streamline.mjs +116 -54
- package/dist/integrations/webhooks.d.mts +60 -120
- package/dist/integrations/webhooks.mjs +36 -80
- package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
- package/dist/integrations/websocket-pushref-redis.mjs +1 -1
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket-redis.mjs +1 -1
- package/dist/integrations/websocket.d.mts +3 -3
- package/dist/integrations/websocket.mjs +2 -2
- package/dist/{interface-DEWNOMza.d.mts → interface-C0395yUN.d.mts} +13 -1
- package/dist/{jsonBody-Cq9KYl2Z.mjs → jsonBody-B4YVGcql.mjs} +2 -2
- package/dist/{keys-Cmv4cBRa.mjs → keys-B2KtgMLJ.mjs} +7 -18
- package/dist/{loadResources-CblF7xt2.mjs → loadResources-Dn4lalw2.mjs} +3 -3
- package/dist/{loadResourcesFromEntry-99fIMC3p.mjs → loadResourcesFromEntry-3sLYbuse.mjs} +2 -2
- package/dist/logger/index.d.mts +1 -8
- package/dist/logger/index.mjs +3 -153
- package/dist/logger-DXjfIqjV.mjs +160 -0
- package/dist/{mcpPlugin-xF3Gw-HW.mjs → mcpPlugin-C08HalN5.mjs} +433 -170
- package/dist/{memory-CpDZ9NPw.mjs → memory-9Cp7611C.mjs} +36 -34
- package/dist/{metrics-BaKnq8ag.mjs → metrics-CWI11eRu.mjs} +39 -72
- package/dist/middleware/index.d.mts +2 -2
- package/dist/middleware/index.mjs +3 -3
- package/dist/migrations/index.mjs +2 -2
- package/dist/{module-BkQEesNQ.mjs → module-BSvOQC7E.mjs} +8 -8
- package/dist/multiTenant-cY0FkDiA.d.mts +104 -0
- package/dist/{multipartBody-C92aJ8am.mjs → multipartBody-CXM2a-Dy.mjs} +46 -7
- package/dist/{openapi-C3LFRzPq.mjs → openapi-DT_G4VhY.mjs} +48 -10
- package/dist/outbox-admin/index.d.mts +3 -3
- package/dist/outbox-admin/index.mjs +6 -18
- package/dist/{outbox-module-dsCyLxXH.mjs → outbox-module-Bjgzl6tg.mjs} +203 -33
- package/dist/{outbox-module-BHdUnuuK.d.mts → outbox-module-gZCHoE4H.d.mts} +79 -6
- package/dist/permissions/index.d.mts +5 -4
- package/dist/permissions/index.mjs +8 -6
- package/dist/{permissions-DiHXy8Ck.mjs → permissions-Cm0uYHvd.mjs} +149 -33
- package/dist/{pipe-CBVmUJ-j.mjs → pipe-BqeGT6UO.mjs} +2 -2
- package/dist/pipeline/index.d.mts +2 -2
- package/dist/pipeline/index.mjs +2 -2
- package/dist/plan-FVR6LP59.mjs +565 -0
- package/dist/{BaseController-DYJRZ0EJ.mjs → plugin-BccET1tL.mjs} +1256 -344
- package/dist/plugins/index.d.mts +14 -11
- package/dist/plugins/index.mjs +44 -22
- package/dist/plugins/response-cache.mjs +8 -14
- package/dist/plugins/tracing-entry.mjs +2 -2
- package/dist/{pluralize-2_U6QZQg.mjs → pluralize-CVIOcgMF.mjs} +1 -1
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +5 -5
- package/dist/presets/index.d.mts +26 -31
- package/dist/presets/index.mjs +4 -4
- package/dist/presets/multiTenant.d.mts +2 -107
- package/dist/presets/multiTenant.mjs +22 -162
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +2 -2
- package/dist/{presets-BdlugYTc.mjs → presets-Cpvj6fGO.mjs} +99 -107
- package/dist/{projection-Uvgp-EEj.mjs → projection-Ce4aQhQk.mjs} +2 -2
- package/dist/{pushref-registry-Dov9l4qE.d.mts → pushref-registry-UxCkoA4C.d.mts} +1 -1
- package/dist/{queryCachePlugin-BzqxconG.d.mts → queryCachePlugin-C2lZjTxk.d.mts} +1 -1
- package/dist/{queryCachePlugin-B5F09bYr.mjs → queryCachePlugin-CFWNH8aT.mjs} +12 -14
- package/dist/{rateLimitKey-CK-EU2L8.mjs → rateLimitKey-C4O-kPm0.mjs} +1 -1
- package/dist/{realtime-FnnD0n3Z.mjs → realtime-DmPE9S54.mjs} +25 -21
- package/dist/redis-stream-BMk-bpQS.d.mts +219 -0
- package/dist/{redis-Bzi1y3F9.d.mts → redis-vLvtcGy_.d.mts} +13 -24
- package/dist/redisShapes-CQksY67q.d.mts +55 -0
- package/dist/registry/index.d.mts +9 -4
- package/dist/registry/index.mjs +4 -4
- package/dist/{registry-D3y8VEJ1.mjs → registry-Bcz9G1bh.mjs} +10 -8
- package/dist/{renewingLease-B3VYh5aU.mjs → renewingLease-qODa7KJQ.mjs} +1 -1
- package/dist/{replyHelpers-DPHsdw-5.mjs → replyHelpers-CG_VtVgS.mjs} +2 -2
- package/dist/{requestContext-cSVKnYPk.mjs → requestContext-Dsu9P1bV.mjs} +1 -1
- package/dist/requirement-CbiLdQ1n.d.mts +22 -0
- package/dist/requirement-KDdoRcOP.mjs +35 -0
- package/dist/resourceRateLimit-Wf6d6FL9.d.mts +30 -0
- package/dist/resourceRateLimit-dSjVr1pk.mjs +180 -0
- package/dist/{rolldown-runtime-DjDuyglE.mjs → rolldown-runtime-D1w9Y1VJ.mjs} +1 -1
- package/dist/routeValidator-D36SW8qt.mjs +70 -0
- package/dist/{runtimeCapabilities-DSA_dH1_.mjs → runtimeCapabilities-0tccjRlj.mjs} +1 -1
- package/dist/{schedules-BktJb3Y3.mjs → schedules-C-OpmHBq.mjs} +46 -37
- package/dist/{schemaConverter-BLCSmNbr.mjs → schemaConverter-BORek3O-.mjs} +2 -2
- package/dist/{schemaIR-K16Bb5BX.mjs → schemaIR-C7Vbp71I.mjs} +2 -2
- package/dist/schemas/index.d.mts +10 -0
- package/dist/schemas/index.mjs +8 -3
- package/dist/scim/index.mjs +2 -2
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +6 -6
- package/dist/{scope-jSxuBTFM.mjs → scope-DQthe88b.mjs} +4 -4
- package/dist/shutdown-MqA81z_P.mjs +59 -0
- package/dist/{simpleEqualityMatcher-BiOnyflP.mjs → simpleEqualityMatcher-auW8QjXN.mjs} +1 -1
- package/dist/{sse-CH7Slxc1.mjs → sse-CYzQBVFe.mjs} +11 -15
- package/dist/{sseStream-CN-Lh1Tu.mjs → sseStream-B5_Afiqn.mjs} +2 -2
- package/dist/{store-helpers-9DX9-1j_.mjs → store-helpers-BOzk-CuX.mjs} +1 -1
- package/dist/{streaming-dvppnxsr.mjs → streaming-DjTQhZrN.mjs} +1 -1
- package/dist/subscriberIdentity-CW39uA8G.mjs +28 -0
- package/dist/tenancy--ed9eH_z.mjs +43 -0
- package/dist/tenantPreparation-BKon6Yox.mjs +67 -0
- package/dist/testing/cleanupStoreContract.mjs +1 -1
- package/dist/testing/index.d.mts +193 -175
- package/dist/testing/index.mjs +286 -212
- package/dist/testing/outboxStoreContract.mjs +137 -2
- package/dist/testing/requiredPorts.d.mts +17 -0
- package/dist/testing/requiredPorts.mjs +65 -0
- package/dist/testing/storageContract.mjs +1 -1
- package/dist/timing-qczZ0YC-.mjs +46 -0
- package/dist/{transactionContext-DCWMI6hV.mjs → transactionContext-DjFkrNG6.mjs} +1 -1
- package/dist/{typeGuards-D7tHFdH6.mjs → typeGuards-CySF12G-.mjs} +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/index.mjs +1 -1
- package/dist/types/storage.mjs +1 -1
- package/dist/{types-CWQn9-IC.d.mts → types-B4Aq4Sdj.d.mts} +22 -9
- package/dist/{types-DfVDvkdP.d.mts → types-BJ-2XqVF.d.mts} +189 -204
- package/dist/{types-CDy3-zw1.mjs → types-Bk9NOgOC.mjs} +4 -4
- package/dist/usage/index.d.mts +2 -3
- package/dist/usage/index.mjs +5 -5
- package/dist/{userHelpers-DDMOZ_Pm.mjs → userHelpers-aklawRaK.mjs} +1 -1
- package/dist/utils/index.d.mts +84 -223
- package/dist/utils/index.mjs +13 -12
- package/dist/{utils-B-zj_b__.mjs → utils-ec1oTo1n.mjs} +315 -404
- package/dist/{validate-R0G_JrnK.mjs → validate-9yzHC1e2.mjs} +6 -14
- package/dist/{versioning-D6JIK8Tk.d.mts → versioning-Bsb51gGT.d.mts} +28 -13
- package/dist/{versioning-DuVWcu-F.mjs → versioning-fhhWHV74.mjs} +4 -4
- package/dist/{websocket-BOXVggMf.d.mts → websocket-Cs4awVyD.d.mts} +1 -1
- package/dist/{workScope-BhsWde4k.mjs → workScope-zpPyge4t.mjs} +2 -2
- package/package.json +578 -574
- package/skills/arc/SKILL.md +13 -4
- package/skills/arc/references/auth.md +8 -5
- package/skills/arc/references/integrations.md +1 -1
- package/skills/arc/references/mcp.md +8 -6
- package/skills/arc/references/multi-tenancy.md +19 -14
- package/skills/arc/references/production.md +27 -10
- package/dist/actionPermissions-DPwPWPOV.mjs +0 -350
- package/dist/index-CFfLKMH4.d.mts +0 -290
- package/dist/interface-BIpgzbZl.mjs +0 -17
- package/dist/redis-stream-BPKscsTB.d.mts +0 -260
- package/dist/registerRoute-DRsSHPSM.mjs +0 -364
- package/dist/sync/changelog.d.mts +0 -3
- package/dist/sync/changelog.mjs +0 -3
|
@@ -1,339 +1,12 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import {
|
|
3
|
-
import { arcLog } from "./logger
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { y as getDefaultCrudSchemas } from "./utils-B-zj_b__.mjs";
|
|
9
|
-
import { a as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-BTyPLHK0.mjs";
|
|
10
|
-
import { n as convertRouteSchema, r as ensureSchemaConverter, t as convertOpenApiSchemas } from "./schemaConverter-BLCSmNbr.mjs";
|
|
11
|
-
import { i as pipeUIMessageStreamToReply, r as isReadableStream } from "./streaming-dvppnxsr.mjs";
|
|
12
|
-
import { a as createCrudHandlers, i as resolvePipelineSteps, o as createFastifyHandler, r as buildPipelineHandler, s as createRequestContext, t as resolveActionPermission } from "./actionPermissions-DPwPWPOV.mjs";
|
|
13
|
-
import { a as selectPluginMw, c as buildRouteHooks, d as buildAuthMiddleware, i as resolveRouterPluginMw, l as resolveRoutePreHandlers, n as buildRateLimitConfig, p as buildArcDecorator, r as buildRouteConfig, s as buildCrudPermissionMw, t as tryRegisterRoute, u as routeHookOptions } from "./registerRoute-DRsSHPSM.mjs";
|
|
14
|
-
import { t as hasEvents } from "./typeGuards-D7tHFdH6.mjs";
|
|
15
|
-
import { B as requireAuth } from "./permissions-DiHXy8Ck.mjs";
|
|
16
|
-
import { r as getAvailablePresets, t as applyPresets } from "./presets-BdlugYTc.mjs";
|
|
17
|
-
import { t as pluralize } from "./pluralize-2_U6QZQg.mjs";
|
|
1
|
+
/*! @classytic/arc v2.43.0 | MIT | https://classytic.com */
|
|
2
|
+
import { m as MUTATION_OPERATIONS, t as CRUD_OPERATIONS } from "./constants-Cu7Mx7_U.mjs";
|
|
3
|
+
import { t as arcLog } from "./logger-DXjfIqjV.mjs";
|
|
4
|
+
import { a as autoInjectTenantFieldRules, c as BaseController, i as resolveOpenApiSchemas, t as buildResourcePlugin, v as detectOverriddenWriteSlots, y as isWriteVerbCapable } from "./plugin-BccET1tL.mjs";
|
|
5
|
+
import { B as requireAuth } from "./permissions-Cm0uYHvd.mjs";
|
|
6
|
+
import { n as getAvailablePresets, t as applyPresets } from "./presets-Cpvj6fGO.mjs";
|
|
7
|
+
import { t as pluralize } from "./pluralize-CVIOcgMF.mjs";
|
|
18
8
|
import { isReadOnlyRepo } from "@classytic/repo-core/repository";
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Build a preHandler that enforces field-write permissions on the request body.
|
|
22
|
-
*
|
|
23
|
-
* Auto-CRUD's create/update routes get this for free via `BodySanitizer.sanitize()`
|
|
24
|
-
* inside `BaseController`. Custom routes (`config.routes`, presets, action endpoints)
|
|
25
|
-
* never went through that path — so a host that declared
|
|
26
|
-
* `fields: { role: fields.writableBy(['admin']) }` and a custom
|
|
27
|
-
* `POST /users/promote` happily accepted `{ role: 'admin' }` from any caller.
|
|
28
|
-
*
|
|
29
|
-
* This preHandler closes that gap. It applies ONLY the `applyFieldWritePermissions`
|
|
30
|
-
* step — no `SYSTEM_FIELDS` strip, no `fieldRules` strip — because custom-route
|
|
31
|
-
* bodies don't necessarily mirror the resource's adapter shape, and we'd
|
|
32
|
-
* generate surprising 403s by enforcing rules tied to that shape.
|
|
33
|
-
*
|
|
34
|
-
* Returns `null` when the resource has no field permissions configured — the
|
|
35
|
-
* caller should then skip wiring it into the preHandler chain.
|
|
36
|
-
*/
|
|
37
|
-
function buildFieldWritePreHandler(fieldPermissions, policy = DEFAULT_FIELD_WRITE_DENIAL_POLICY) {
|
|
38
|
-
if (!fieldPermissions || Object.keys(fieldPermissions).length === 0) return null;
|
|
39
|
-
return async (request, _reply) => {
|
|
40
|
-
const body = request.body;
|
|
41
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) return;
|
|
42
|
-
const scope = request.scope ?? PUBLIC_SCOPE;
|
|
43
|
-
if (isElevated(scope)) return;
|
|
44
|
-
const globalRoles = getUserRoles(request.user);
|
|
45
|
-
const orgRoles = isMember(scope) ? scope.orgRoles : [];
|
|
46
|
-
const effectiveRoles = resolveEffectiveRoles(globalRoles, orgRoles);
|
|
47
|
-
const { body: filtered, deniedFields } = applyFieldWritePermissions(body, fieldPermissions, effectiveRoles);
|
|
48
|
-
if (deniedFields.length > 0 && policy === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
|
|
49
|
-
request.body = filtered;
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* True for HTTP methods that carry a request body and should run the
|
|
54
|
-
* field-write preHandler. GET/DELETE/HEAD/OPTIONS skip enforcement.
|
|
55
|
-
*/
|
|
56
|
-
function methodCarriesBody(method) {
|
|
57
|
-
const m = method.toUpperCase();
|
|
58
|
-
return m === "POST" || m === "PUT" || m === "PATCH";
|
|
59
|
-
}
|
|
60
|
-
//#endregion
|
|
61
|
-
//#region src/core/createCrudRouter.ts
|
|
62
|
-
/**
|
|
63
|
-
* Fold a route's host-supplied `config` together with arc's derived one into the
|
|
64
|
-
* SINGLE `config` key Fastify reads. Returns arc's object untouched when the host
|
|
65
|
-
* supplied none, so the route options stay literally what was declared.
|
|
66
|
-
*/
|
|
67
|
-
function mergeRouteConfig(hostConfig, arcConfig) {
|
|
68
|
-
if (!hostConfig) return arcConfig;
|
|
69
|
-
return { config: {
|
|
70
|
-
...hostConfig,
|
|
71
|
-
...arcConfig.config ?? {}
|
|
72
|
-
} };
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Mount custom routes (from presets or user-defined `routes`) on Fastify.
|
|
76
|
-
* `wrapHandler` is derived inline from whether `rawHandler` was set.
|
|
77
|
-
*/
|
|
78
|
-
function createCustomRoutes(fastify, routes, controller, options) {
|
|
79
|
-
const { tag, resourceName, arcDecorator, rateLimitConfig, pluginMw, pipeline, routeGuards, tenantScopeMw, fieldPermissions, onFieldWriteDenied, extensions } = options;
|
|
80
|
-
for (const route of routes) {
|
|
81
|
-
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.`);
|
|
82
|
-
const routeWithRefs = route;
|
|
83
|
-
const hasHandler = route.handler !== void 0 || route.rawHandler !== void 0;
|
|
84
|
-
const hasControllerMethod = typeof routeWithRefs.controllerMethod === "function";
|
|
85
|
-
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).`);
|
|
86
|
-
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).`);
|
|
87
|
-
let resolvedHandler;
|
|
88
|
-
if (hasControllerMethod) {
|
|
89
|
-
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.`);
|
|
90
|
-
const referenced = routeWithRefs.controllerMethod?.(controller);
|
|
91
|
-
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\`.`);
|
|
92
|
-
resolvedHandler = referenced.bind(controller);
|
|
93
|
-
} else {
|
|
94
|
-
if (route.rawHandler && route.handler) throw new Error(`Route ${route.method} ${route.path}: set either \`handler\` (arc pipeline) or \`rawHandler\` (Fastify-native), not both.`);
|
|
95
|
-
resolvedHandler = route.rawHandler ?? route.handler;
|
|
96
|
-
}
|
|
97
|
-
const opName = route.operation ?? (typeof resolvedHandler === "string" ? resolvedHandler : `${route.method.toLowerCase()}${route.path.replace(/[/:]/g, "_")}`);
|
|
98
|
-
const isRaw = route.rawHandler !== void 0;
|
|
99
|
-
const wrapHandler = !isRaw;
|
|
100
|
-
let handler;
|
|
101
|
-
if (typeof resolvedHandler === "string") {
|
|
102
|
-
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.`);
|
|
103
|
-
const method = controller[resolvedHandler];
|
|
104
|
-
if (typeof method !== "function") throw new Error(`Handler '${resolvedHandler}' not found on controller`);
|
|
105
|
-
const boundMethod = method.bind(controller);
|
|
106
|
-
if (wrapHandler) {
|
|
107
|
-
const steps = resolvePipelineSteps(pipeline, opName);
|
|
108
|
-
handler = steps.length > 0 ? buildPipelineHandler(boundMethod, steps, opName, resourceName) : createFastifyHandler(boundMethod);
|
|
109
|
-
} else handler = boundMethod;
|
|
110
|
-
} else if (wrapHandler) {
|
|
111
|
-
const steps = resolvePipelineSteps(pipeline, opName);
|
|
112
|
-
handler = steps.length > 0 ? buildPipelineHandler(resolvedHandler, steps, opName, resourceName) : createFastifyHandler(resolvedHandler);
|
|
113
|
-
} else handler = resolvedHandler;
|
|
114
|
-
const routeTags = route.tags ?? (tag ? [tag] : void 0);
|
|
115
|
-
const convertedSchema = route.schema ? convertRouteSchema(route.schema) : void 0;
|
|
116
|
-
const schema = {
|
|
117
|
-
...routeTags ? { tags: routeTags } : {},
|
|
118
|
-
...route.summary ? { summary: route.summary } : {},
|
|
119
|
-
...route.description ? { description: route.description } : {},
|
|
120
|
-
...convertedSchema ?? {}
|
|
121
|
-
};
|
|
122
|
-
const customPreHandlers = resolveRoutePreHandlers(route.preHandler, fastify, `${route.method} ${route.path}`);
|
|
123
|
-
if (route.tenantScope === true) {
|
|
124
|
-
if (!tenantScopeMw || tenantScopeMw.length === 0) throw new Error(`Route ${route.method} ${route.path}: \`tenantScope: true\` requires a multi-tenant preset. Add \`multiTenantPreset()\` (or \`flexibleMultiTenantPreset()\`) to the resource's \`presets\`, or remove the \`tenantScope\` flag from this route.`);
|
|
125
|
-
customPreHandlers.unshift(...tenantScopeMw);
|
|
126
|
-
}
|
|
127
|
-
if (!isRaw && route.fieldWrite !== false && methodCarriesBody(route.method)) {
|
|
128
|
-
const fieldWriteMw = buildFieldWritePreHandler(fieldPermissions, onFieldWriteDenied);
|
|
129
|
-
if (fieldWriteMw) customPreHandlers.push(fieldWriteMw);
|
|
130
|
-
}
|
|
131
|
-
const preAuthHandlers = route.preAuth ?? [];
|
|
132
|
-
const hooks = buildRouteHooks({
|
|
133
|
-
preAuth: preAuthHandlers,
|
|
134
|
-
arcDecorator,
|
|
135
|
-
authMw: buildAuthMiddleware(fastify, route.permissions),
|
|
136
|
-
permissionMw: buildCrudPermissionMw(route.permissions, resourceName, opName),
|
|
137
|
-
pluginMw: selectPluginMw(route.method, pluginMw),
|
|
138
|
-
routeGuards,
|
|
139
|
-
customMws: customPreHandlers
|
|
140
|
-
});
|
|
141
|
-
const isStream = route.streamResponse === true;
|
|
142
|
-
tryRegisterRoute(fastify, {
|
|
143
|
-
method: route.method,
|
|
144
|
-
url: route.path,
|
|
145
|
-
schema,
|
|
146
|
-
...routeHookOptions(hooks),
|
|
147
|
-
handler: isStream ? async function streamRoute(request, reply) {
|
|
148
|
-
reply.raw.setHeader("Content-Type", "text/event-stream");
|
|
149
|
-
reply.raw.setHeader("Cache-Control", "no-cache");
|
|
150
|
-
reply.raw.setHeader("Connection", "keep-alive");
|
|
151
|
-
const result = await handler.call(this, request, reply);
|
|
152
|
-
if (isReadableStream(result)) {
|
|
153
|
-
await pipeUIMessageStreamToReply(reply, result);
|
|
154
|
-
return reply;
|
|
155
|
-
}
|
|
156
|
-
return result;
|
|
157
|
-
} : handler,
|
|
158
|
-
/**
|
|
159
|
-
* ONE `config` object, merging the host's `route.config` with arc's own
|
|
160
|
-
* derived keys (per-route rate limit, cors, `arcExtensions`).
|
|
161
|
-
*
|
|
162
|
-
* It has to be merged HERE because Fastify does not merge for us: it
|
|
163
|
-
* builds the context from a single `opts.config`
|
|
164
|
-
* (`{ ...opts.config, url, method }` — lib/route.js), so two spreads
|
|
165
|
-
* writing `config` means the later one silently replaces the earlier.
|
|
166
|
-
* Emitting them separately dropped `route.config` on every resource
|
|
167
|
-
* that declared extensions, a rate limit or per-route cors — which is
|
|
168
|
-
* the same silent-absence bug `config` was added to fix, just narrowed
|
|
169
|
-
* to the resources most likely to have it.
|
|
170
|
-
*
|
|
171
|
-
* Arc's derived keys win on collision: they come from the declared
|
|
172
|
-
* `route.rateLimit` / `route.cors` / `extensions` fields, so letting a
|
|
173
|
-
* hand-written `config.rateLimit` override them would give one setting
|
|
174
|
-
* two sources that can disagree.
|
|
175
|
-
*/
|
|
176
|
-
...mergeRouteConfig(route.config, buildRouteConfig(route.rateLimit !== void 0 ? buildRateLimitConfig(route.rateLimit) : rateLimitConfig, extensions, route.cors))
|
|
177
|
-
}, {
|
|
178
|
-
resourceName,
|
|
179
|
-
op: opName
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
/**
|
|
184
|
-
* Create CRUD routes for a controller.
|
|
185
|
-
*
|
|
186
|
-
* @param fastify - Fastify instance with Arc decorators
|
|
187
|
-
* @param controller - CRUD controller with handler methods (optional when
|
|
188
|
-
* `disableDefaultRoutes: true` and only custom `routes`
|
|
189
|
-
* are being registered)
|
|
190
|
-
* @param options - Router configuration
|
|
191
|
-
*/
|
|
192
|
-
function createCrudRouter(fastify, controller, options = {}) {
|
|
193
|
-
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 } = options;
|
|
194
|
-
const rateLimitConfig = buildRateLimitConfig(rateLimit);
|
|
195
|
-
const resourceHasQueryCache = fastify.hasDecorator("queryCache") && controller && typeof controller._cacheConfig !== "undefined" && controller._cacheConfig !== void 0;
|
|
196
|
-
const pluginMw = resolveRouterPluginMw(fastify, Boolean(resourceHasQueryCache));
|
|
197
|
-
const arcDecorator = buildArcDecorator({
|
|
198
|
-
resourceName,
|
|
199
|
-
schemaOptions,
|
|
200
|
-
permissions,
|
|
201
|
-
hooks: fastify.arc?.hooks,
|
|
202
|
-
events: fastify.events,
|
|
203
|
-
fields: fieldPermissions,
|
|
204
|
-
idField
|
|
205
|
-
});
|
|
206
|
-
const mw = {
|
|
207
|
-
list: middlewares.list ?? [],
|
|
208
|
-
get: middlewares.get ?? [],
|
|
209
|
-
create: middlewares.create ?? [],
|
|
210
|
-
update: middlewares.update ?? [],
|
|
211
|
-
delete: middlewares.delete ?? []
|
|
212
|
-
};
|
|
213
|
-
const tenantScopeMw = middlewares.tenantScope;
|
|
214
|
-
const idParamsSchema = {
|
|
215
|
-
type: "object",
|
|
216
|
-
properties: { id: { type: "string" } },
|
|
217
|
-
required: ["id"]
|
|
218
|
-
};
|
|
219
|
-
const defaultSchemas = getDefaultCrudSchemas();
|
|
220
|
-
/**
|
|
221
|
-
* Merge: base (tags/summary) → defaults (response/querystring) → user overrides.
|
|
222
|
-
* User-provided schemas always win; defaults enable fast-json-stringify when
|
|
223
|
-
* no user schema is set.
|
|
224
|
-
*/
|
|
225
|
-
const buildSchema = (base, defaults, userSchema) => ({
|
|
226
|
-
...defaults,
|
|
227
|
-
...base,
|
|
228
|
-
...userSchema ?? {}
|
|
229
|
-
});
|
|
230
|
-
if (!disableDefaultRoutes) {
|
|
231
|
-
if (!controller) throw new Error("Controller is required when disableDefaultRoutes is not true. Provide a controller or use defineResource which auto-creates BaseController.");
|
|
232
|
-
const handlers = buildCrudHandlers(controller, pipeline, resourceName);
|
|
233
|
-
const crudTable = [
|
|
234
|
-
{
|
|
235
|
-
op: "list",
|
|
236
|
-
method: "GET",
|
|
237
|
-
url: "/",
|
|
238
|
-
summary: `List ${tag}`,
|
|
239
|
-
hasIdParams: false
|
|
240
|
-
},
|
|
241
|
-
{
|
|
242
|
-
op: "get",
|
|
243
|
-
method: "GET",
|
|
244
|
-
url: "/:id",
|
|
245
|
-
summary: `Get ${tag} by ID`,
|
|
246
|
-
hasIdParams: true
|
|
247
|
-
},
|
|
248
|
-
{
|
|
249
|
-
op: "create",
|
|
250
|
-
method: "POST",
|
|
251
|
-
url: "/",
|
|
252
|
-
summary: `Create ${tag}`,
|
|
253
|
-
hasIdParams: false
|
|
254
|
-
},
|
|
255
|
-
{
|
|
256
|
-
op: "update",
|
|
257
|
-
method: "PATCH",
|
|
258
|
-
url: "/:id",
|
|
259
|
-
summary: `Update ${tag}`,
|
|
260
|
-
hasIdParams: true
|
|
261
|
-
},
|
|
262
|
-
{
|
|
263
|
-
op: "delete",
|
|
264
|
-
method: "DELETE",
|
|
265
|
-
url: "/:id",
|
|
266
|
-
summary: `Delete ${tag}`,
|
|
267
|
-
hasIdParams: true
|
|
268
|
-
}
|
|
269
|
-
];
|
|
270
|
-
for (const spec of crudTable) {
|
|
271
|
-
if (disabledRoutes.includes(spec.op)) continue;
|
|
272
|
-
const permission = permissions[spec.op];
|
|
273
|
-
const hooks = buildRouteHooks({
|
|
274
|
-
arcDecorator,
|
|
275
|
-
authMw: buildAuthMiddleware(fastify, permission),
|
|
276
|
-
permissionMw: buildCrudPermissionMw(permission, resourceName, spec.op),
|
|
277
|
-
pluginMw: selectPluginMw(spec.method, pluginMw),
|
|
278
|
-
routeGuards,
|
|
279
|
-
customMws: mw[spec.op]
|
|
280
|
-
});
|
|
281
|
-
const methodsToRegister = spec.op === "update" ? updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod] : [spec.method];
|
|
282
|
-
for (const method of methodsToRegister) {
|
|
283
|
-
const summary = spec.op === "update" ? `${method === "PUT" ? "Replace" : "Update"} ${tag}` : spec.summary;
|
|
284
|
-
tryRegisterRoute(fastify, {
|
|
285
|
-
method,
|
|
286
|
-
url: spec.url,
|
|
287
|
-
schema: buildSchema({
|
|
288
|
-
tags: [tag],
|
|
289
|
-
summary,
|
|
290
|
-
...spec.hasIdParams ? { params: idParamsSchema } : {}
|
|
291
|
-
}, defaultSchemas[spec.op], schemas[spec.op]),
|
|
292
|
-
...routeHookOptions(hooks),
|
|
293
|
-
handler: handlers[spec.op],
|
|
294
|
-
...buildRouteConfig(rateLimitConfig, extensions)
|
|
295
|
-
}, {
|
|
296
|
-
resourceName,
|
|
297
|
-
op: spec.op
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
if (customRoutes.length > 0) createCustomRoutes(fastify, customRoutes, controller, {
|
|
303
|
-
tag,
|
|
304
|
-
resourceName,
|
|
305
|
-
arcDecorator,
|
|
306
|
-
rateLimitConfig,
|
|
307
|
-
pluginMw,
|
|
308
|
-
pipeline,
|
|
309
|
-
routeGuards,
|
|
310
|
-
tenantScopeMw,
|
|
311
|
-
fieldPermissions,
|
|
312
|
-
onFieldWriteDenied,
|
|
313
|
-
extensions
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
function buildCrudHandlers(ctrl, pipeline, resourceName) {
|
|
317
|
-
const standardHandlers = createCrudHandlers(ctrl);
|
|
318
|
-
if (!pipeline) return standardHandlers;
|
|
319
|
-
const wrapped = { ...standardHandlers };
|
|
320
|
-
for (const op of CRUD_OPERATIONS) {
|
|
321
|
-
const steps = resolvePipelineSteps(pipeline, op);
|
|
322
|
-
if (steps.length === 0) continue;
|
|
323
|
-
const method = ctrl[op].bind(ctrl);
|
|
324
|
-
wrapped[op] = buildPipelineHandler(method, steps, op, resourceName);
|
|
325
|
-
}
|
|
326
|
-
return wrapped;
|
|
327
|
-
}
|
|
328
|
-
/**
|
|
329
|
-
* Build a permission middleware from a PermissionCheck — useful when hosts
|
|
330
|
-
* register their own routes outside the resource system but still want to
|
|
331
|
-
* evaluate permissions through the shared applicator.
|
|
332
|
-
*/
|
|
333
|
-
function createPermissionMiddleware(permission, resourceName, action) {
|
|
334
|
-
return buildCrudPermissionMw(permission, resourceName, action);
|
|
335
|
-
}
|
|
336
|
-
//#endregion
|
|
9
|
+
import { readEnv } from "@classytic/primitives/environment";
|
|
337
10
|
//#region src/core/defineResource/writeVerbs.ts
|
|
338
11
|
/**
|
|
339
12
|
* Phase 4b — write-verb registration policy.
|
|
@@ -406,34 +79,13 @@ function warnOnWriteMethodOverride(controller, resolvedConfig) {
|
|
|
406
79
|
}
|
|
407
80
|
//#endregion
|
|
408
81
|
//#region src/core/defineResource/controller.ts
|
|
409
|
-
/**
|
|
410
|
-
* Phase 4 — pick (or auto-create) the resource's controller.
|
|
411
|
-
*
|
|
412
|
-
* Three branches:
|
|
413
|
-
* 1. User-supplied controller → forward `queryParser` (duck-typed)
|
|
414
|
-
* and warn on dropped resource-level options.
|
|
415
|
-
* 2. No controller, has CRUD routes, has a repository → auto-build
|
|
416
|
-
* a `BaseController` with every resource-level knob threaded
|
|
417
|
-
* through (tenantField, schemaOptions, idField, defaultSort,
|
|
418
|
-
* cache, onFieldWriteDenied, presetFields).
|
|
419
|
-
* 3. Otherwise → `undefined` (custom-routes-only resource).
|
|
420
|
-
*
|
|
421
|
-
* The warns are load-bearing DX: silently dropping `queryParser`,
|
|
422
|
-
* `schemaOptions`, etc. on a custom controller produces 90-minute
|
|
423
|
-
* "why don't my filters work" debugs. Each warn names the resource,
|
|
424
|
-
* lists the dropped options, and points at the canonical fix. All
|
|
425
|
-
* warns honour `ARC_SUPPRESS_WARNINGS=1` via `arcLog()`.
|
|
426
|
-
*/
|
|
427
82
|
/** CRUD routes refused over a read-only repository. */
|
|
428
83
|
const READ_ONLY_REFUSED_ROUTES = [
|
|
429
84
|
"create",
|
|
430
85
|
"update",
|
|
431
86
|
"delete"
|
|
432
87
|
];
|
|
433
|
-
/**
|
|
434
|
-
* Resolve the controller for the resource. See module docstring for
|
|
435
|
-
* branch semantics.
|
|
436
|
-
*/
|
|
88
|
+
/** Resolve the controller for the resource (branches in the module docstring). */
|
|
437
89
|
function resolveOrAutoCreateController(resolvedConfig, adapter, repository, hasCrudRoutes) {
|
|
438
90
|
const userController = resolvedConfig.controller;
|
|
439
91
|
if (resolvedConfig.transactional === true) {
|
|
@@ -445,6 +97,7 @@ function resolveOrAutoCreateController(resolvedConfig, adapter, repository, hasC
|
|
|
445
97
|
if (enabledWrites.length > 0) throw new Error(`Resource "${resolvedConfig.name}" mounts write routes (${enabledWrites.join(", ")}) over a READ-ONLY repository — its rows are owned by another component that enforces invariants generic CRUD would bypass (Better Auth hashes passwords, revokes sessions, and cascades org membership on every write it performs). Disable the write routes (\`disabledRoutes: ['create', 'update', 'delete']\`), or if raw writes are genuinely required, build the repository with the kit's explicit \`unsafeWritable: true\` opt-in.`);
|
|
446
98
|
}
|
|
447
99
|
if (userController) {
|
|
100
|
+
bindControllerResourceName(userController, resolvedConfig.name);
|
|
448
101
|
threadQueryParser(userController, resolvedConfig);
|
|
449
102
|
enforceWriteVerbReachability(userController, resolvedConfig);
|
|
450
103
|
threadConfigureLifecycle(userController, resolvedConfig, adapter);
|
|
@@ -457,22 +110,12 @@ function resolveOrAutoCreateController(resolvedConfig, adapter, repository, hasC
|
|
|
457
110
|
return buildBaseController(resolvedConfig, adapter, repository);
|
|
458
111
|
}
|
|
459
112
|
/**
|
|
460
|
-
* Forward resource-level options into a user-supplied controller via
|
|
461
|
-
*
|
|
462
|
-
* gap where `tenantField` / `schemaOptions` / `idField` / `defaultSort`
|
|
463
|
-
* / `cache` / `onFieldWriteDenied` had no path into a host controller
|
|
464
|
-
* unless the host remembered to forward them through `super(repo,
|
|
465
|
-
* { ... })`. `BaseController` / `BaseCrudController` ship `configure()`;
|
|
466
|
-
* custom controllers can opt in by adding the method.
|
|
113
|
+
* Forward resource-level options into a user-supplied controller via the
|
|
114
|
+
* duck-typed `configure(opts)` hook.
|
|
467
115
|
*
|
|
468
|
-
* **
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
* because the model's organizationId path doesn't exist) would clobber
|
|
472
|
-
* the matching value the host already set in the controller's
|
|
473
|
-
* constructor (e.g. `new BaseController(repo, { tenantField:
|
|
474
|
-
* "companyId" })`). Resource-level explicit values still win — the
|
|
475
|
-
* snapshot captures what the user typed before any inference runs.
|
|
116
|
+
* **Gated by `_declaredKeys`**: only keys the user literally passed are
|
|
117
|
+
* forwarded, so arc-inferred values (e.g. `tenantField: false` from adapter
|
|
118
|
+
* inference) never clobber what the host set in the controller's constructor.
|
|
476
119
|
*/
|
|
477
120
|
function threadConfigureLifecycle(controller, resolvedConfig, adapter) {
|
|
478
121
|
const ctrl = controller;
|
|
@@ -482,20 +125,16 @@ function threadConfigureLifecycle(controller, resolvedConfig, adapter) {
|
|
|
482
125
|
const opts = {};
|
|
483
126
|
if (resolvedConfig.tenantField !== void 0 && wasDeclared("tenantField")) opts.tenantField = resolvedConfig.tenantField;
|
|
484
127
|
if (resolvedConfig.schemaOptions !== void 0 && wasDeclared("schemaOptions")) opts.schemaOptions = resolvedConfig.schemaOptions;
|
|
128
|
+
if (resolvedConfig.lookupFields !== void 0 && wasDeclared("lookupFields")) opts.lookupFields = resolvedConfig.lookupFields;
|
|
485
129
|
if (resolvedConfig.idField !== void 0 && wasDeclared("idField")) opts.idField = resolvedConfig.idField;
|
|
486
130
|
if (resolvedConfig.defaultSort !== void 0 && wasDeclared("defaultSort")) opts.defaultSort = resolvedConfig.defaultSort;
|
|
487
131
|
if (resolvedConfig.cache !== void 0 && wasDeclared("cache")) opts.cache = resolvedConfig.cache;
|
|
488
132
|
if (resolvedConfig.onFieldWriteDenied !== void 0 && wasDeclared("onFieldWriteDenied")) opts.onFieldWriteDenied = resolvedConfig.onFieldWriteDenied;
|
|
489
133
|
if (resolvedConfig.onImmutableWrite !== void 0 && wasDeclared("onImmutableWrite")) opts.onImmutableWrite = resolvedConfig.onImmutableWrite;
|
|
490
134
|
if (resolvedConfig.queryParser !== void 0 && wasDeclared("queryParser")) opts.queryParser = resolvedConfig.queryParser;
|
|
491
|
-
/**
|
|
492
|
-
* NOT gated on `_declaredKeys`: `writes` is never inferred or injected by a
|
|
493
|
-
* preset, so its presence IS the declaration. Gating it would mean a
|
|
494
|
-
* resource that declares write verbs alongside its own controller silently
|
|
495
|
-
* kept generic CRUD — the exact bypass the seam exists to close.
|
|
496
|
-
*/
|
|
497
135
|
if (resolvedConfig.writes !== void 0) opts.writes = resolvedConfig.writes;
|
|
498
136
|
if (resolvedConfig.transactional !== void 0) opts.transactional = resolvedConfig.transactional;
|
|
137
|
+
if (resolvedConfig.concurrency !== void 0) opts.concurrency = resolvedConfig.concurrency;
|
|
499
138
|
if (adapter?.matchesFilter !== void 0) opts.matchesFilter = adapter.matchesFilter;
|
|
500
139
|
if (resolvedConfig._controllerOptions) opts.presetFields = {
|
|
501
140
|
slugField: resolvedConfig._controllerOptions.slugField,
|
|
@@ -505,18 +144,17 @@ function threadConfigureLifecycle(controller, resolvedConfig, adapter) {
|
|
|
505
144
|
ctrl.configure(opts);
|
|
506
145
|
}
|
|
507
146
|
/**
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
* fix-it message in the error.
|
|
147
|
+
* Bind the resource name into a supplied controller (duck-typed `bindResourceName`), so a
|
|
148
|
+
* subclass that never passed `resourceName` to `super()` still runs its hooks, audit and events.
|
|
149
|
+
*/
|
|
150
|
+
function bindControllerResourceName(controller, name) {
|
|
151
|
+
const ctrl = controller;
|
|
152
|
+
if (typeof ctrl.bindResourceName === "function") ctrl.bindResourceName(name);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Forward a resource-level `queryParser` into a user-supplied controller via
|
|
156
|
+
* duck-typed `setQueryParser`. Throws when the controller can't accept it —
|
|
157
|
+
* a shadowed parser silently drifts filter semantics from the OpenAPI schema.
|
|
520
158
|
*/
|
|
521
159
|
function threadQueryParser(controller, resolvedConfig) {
|
|
522
160
|
if (!resolvedConfig.queryParser) return;
|
|
@@ -528,11 +166,8 @@ function threadQueryParser(controller, resolvedConfig) {
|
|
|
528
166
|
throw new Error(`Resource "${resolvedConfig.name}" declares a custom \`queryParser\` but its controller does not expose \`setQueryParser(qp)\`. The parser would be silently dropped, drifting \`[contains]\` / \`[like]\` semantics away from the OpenAPI schema. Extend \`BaseController\` / \`BaseCrudController\` (both implement \`setQueryParser\`) OR add a \`setQueryParser(qp)\` method to your custom controller that actually wires the parser into the controller's query resolution path. (arc 2.15.0 hardened this to a registration-time throw — pre-2.15 it was a warn.)`);
|
|
529
167
|
}
|
|
530
168
|
/**
|
|
531
|
-
* Warn when
|
|
532
|
-
* resource-level options
|
|
533
|
-
* path. The user *can* fix this by forwarding through `super(repo,
|
|
534
|
-
* { ... })`, so the warn names the dropped options + the canonical
|
|
535
|
-
* fix.
|
|
169
|
+
* Warn when a custom controller without `configure()` can't receive
|
|
170
|
+
* resource-level options the user declared.
|
|
536
171
|
*/
|
|
537
172
|
function warnOnDroppedAuthorOptions(resolvedConfig) {
|
|
538
173
|
if (typeof resolvedConfig.controller?.configure === "function") return;
|
|
@@ -550,11 +185,9 @@ function warnOnDroppedAuthorOptions(resolvedConfig) {
|
|
|
550
185
|
arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" declares a custom controller AND resource-level option(s) [${dropped.join(", ")}]. Arc only threads these when it auto-builds the controller — when you pass your own, they are dropped silently and the controller falls back to its own defaults (e.g. tenantField → 'organizationId'). Either implement \`configure(opts)\` on the controller (arc 2.15+ canonical) or forward them via \`super(repo, { ... })\`. Same root cause as the \`queryParser\` warn above.`);
|
|
551
186
|
}
|
|
552
187
|
/**
|
|
553
|
-
* Warn when a preset injected `_controllerOptions`
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
* bad advice. The fix is either drop the preset or extend
|
|
557
|
-
* `BaseController` so the auto-build path runs.
|
|
188
|
+
* Warn when a preset injected `_controllerOptions` but the user supplied their
|
|
189
|
+
* own controller. The user didn't declare these, so the fix is dropping the
|
|
190
|
+
* preset or extending `BaseController` — not "forward them".
|
|
558
191
|
*/
|
|
559
192
|
function warnOnDroppedPresetOptions(resolvedConfig) {
|
|
560
193
|
if (resolvedConfig._controllerOptions === void 0) return;
|
|
@@ -564,10 +197,8 @@ function warnOnDroppedPresetOptions(resolvedConfig) {
|
|
|
564
197
|
arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" applies a preset that injects controller field(s) [${presetFields.join(", ") || "preset metadata"}] (e.g. slugLookup / softDelete / parent), but the resource also declares a custom controller. Preset metadata only reaches arc's auto-built BaseController — your custom controller will not see \`slugField\`/\`parentField\`/etc. Either (a) drop the preset on this resource (\`presets: [...]\` without it), or (b) extend \`BaseController\` / \`BaseCrudController\` so arc auto-builds the controller and threads the preset fields automatically.`);
|
|
565
198
|
}
|
|
566
199
|
/**
|
|
567
|
-
* Auto-build a `BaseController` with every resource-level
|
|
568
|
-
*
|
|
569
|
-
* `BaseController.QueryResolver` and Fastify validation stay in sync
|
|
570
|
-
* with the parser's configured limit.
|
|
200
|
+
* Auto-build a `BaseController` with every resource-level option. `maxLimit`
|
|
201
|
+
* comes from the parser's schema so QueryResolver and Fastify validation agree.
|
|
571
202
|
*/
|
|
572
203
|
function buildBaseController(resolvedConfig, adapter, repository) {
|
|
573
204
|
const qp = resolvedConfig.queryParser;
|
|
@@ -586,32 +217,32 @@ function buildBaseController(resolvedConfig, adapter, repository) {
|
|
|
586
217
|
...resourceConfigDefaultLimit !== void 0 ? { defaultLimit: resourceConfigDefaultLimit } : {},
|
|
587
218
|
tenantField: resolvedConfig.tenantField,
|
|
588
219
|
idField: resolvedConfig.idField,
|
|
220
|
+
...resolvedConfig.lookupFields ? { lookupFields: resolvedConfig.lookupFields } : {},
|
|
589
221
|
...resolvedConfig.defaultSort !== void 0 ? { defaultSort: resolvedConfig.defaultSort } : {},
|
|
590
222
|
matchesFilter: adapter?.matchesFilter,
|
|
591
223
|
cache: resolvedConfig.cache,
|
|
592
224
|
onFieldWriteDenied: resolvedConfig.onFieldWriteDenied,
|
|
593
225
|
onImmutableWrite: resolvedConfig.onImmutableWrite,
|
|
594
|
-
/**
|
|
595
|
-
* Domain commands for the write slots. Threaded here rather than
|
|
596
|
-
* subclassing: the pipeline stays arc's and the command stays the
|
|
597
|
-
* kernel's, so neither has to be re-implemented to reach the other.
|
|
598
|
-
*/
|
|
599
226
|
writes: resolvedConfig.writes,
|
|
600
227
|
transactional: resolvedConfig.transactional === true,
|
|
228
|
+
...resolvedConfig.concurrency ? { concurrency: resolvedConfig.concurrency } : {},
|
|
601
229
|
presetFields: resolvedConfig._controllerOptions ? {
|
|
602
230
|
slugField: resolvedConfig._controllerOptions.slugField,
|
|
603
231
|
parentField: resolvedConfig._controllerOptions.parentField
|
|
604
232
|
} : void 0
|
|
605
233
|
});
|
|
606
234
|
}
|
|
235
|
+
/** Deferred like the transaction check: `concurrency` needs the kit's CAS on `ifVersion`. */
|
|
236
|
+
function assertConcurrencyCapability(repository, resourceName) {
|
|
237
|
+
const capabilities = repository?.capabilities;
|
|
238
|
+
if (!capabilities || capabilities.optimisticConcurrency === true) return;
|
|
239
|
+
throw new Error(`Resource "${resourceName}" declares \`concurrency\` but its repository reports \`capabilities.optimisticConcurrency: ${String(capabilities.optimisticConcurrency)}\` — an ETag it cannot enforce would promise a guard that is not there. Use a kit with CAS writes (mongokit), or remove the option.`);
|
|
240
|
+
}
|
|
607
241
|
/**
|
|
608
|
-
* The DEFERRED half of the `transactional: true` gate — run at
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
* repository with NO capability descriptor (hand-written, custom kit) passes
|
|
613
|
-
* — it never promised one, and the define-time presence check already covered
|
|
614
|
-
* the only signal it offers.
|
|
242
|
+
* The DEFERRED half of the `transactional: true` gate — run at registration,
|
|
243
|
+
* after `beforeBoot()` connects. Fail-closed: anything but a confirmed `true`
|
|
244
|
+
* refuses the boot. A repository with NO capability descriptor passes (it never
|
|
245
|
+
* promised one; the define-time presence check covered it).
|
|
615
246
|
*/
|
|
616
247
|
function assertTransactionCapability(repository, resourceName) {
|
|
617
248
|
const capabilities = repository?.capabilities;
|
|
@@ -873,108 +504,6 @@ function resolveCrudAllowList(config) {
|
|
|
873
504
|
};
|
|
874
505
|
}
|
|
875
506
|
//#endregion
|
|
876
|
-
//#region src/core/schemaOptions.ts
|
|
877
|
-
/**
|
|
878
|
-
* Inject the tenant-scoping field rule into `schemaOptions.fieldRules`:
|
|
879
|
-
*
|
|
880
|
-
* { [tenantField]: { systemManaged: true, preserveForElevated: true } }
|
|
881
|
-
*
|
|
882
|
-
* Why both flags: `systemManaged` tells `BodySanitizer` to strip the
|
|
883
|
-
* field from inbound bodies (so member clients can't forge a target
|
|
884
|
-
* tenant). `preserveForElevated` exempts elevated-admin scopes from the
|
|
885
|
-
* strip, so platform admins without a pinned org can still pick a target
|
|
886
|
-
* org via the request body (the only channel they have —
|
|
887
|
-
* `BaseController.create` can't re-stamp from scope when scope has no
|
|
888
|
-
* orgId).
|
|
889
|
-
*
|
|
890
|
-
* **Returns a new `RouteSchemaOptions`** — the input is never mutated.
|
|
891
|
-
* Callers should assign the return value to whatever config slot they
|
|
892
|
-
* read from downstream (always the `resolvedConfig`, never raw `config`).
|
|
893
|
-
*
|
|
894
|
-
* **No-op when:**
|
|
895
|
-
* - `tenantField` is `false` (platform-universal resource)
|
|
896
|
-
* - `tenantField` is undefined
|
|
897
|
-
* - The caller already declared `fieldRules[tenantField].systemManaged`
|
|
898
|
-
* (even as `false`) — explicit opt-outs are respected
|
|
899
|
-
*
|
|
900
|
-
* `preserveForElevated` defaults to `true` but is preserved verbatim
|
|
901
|
-
* when the caller set it explicitly.
|
|
902
|
-
*/
|
|
903
|
-
function autoInjectTenantFieldRules(schemaOptions, tenantField) {
|
|
904
|
-
if (tenantField === false || tenantField === void 0) return schemaOptions;
|
|
905
|
-
const fieldName = tenantField || "organizationId";
|
|
906
|
-
const existing = schemaOptions?.fieldRules ?? {};
|
|
907
|
-
const existingRule = existing[fieldName];
|
|
908
|
-
if (existingRule && existingRule.systemManaged !== void 0) return schemaOptions;
|
|
909
|
-
return {
|
|
910
|
-
...schemaOptions ?? {},
|
|
911
|
-
fieldRules: {
|
|
912
|
-
...existing,
|
|
913
|
-
[fieldName]: {
|
|
914
|
-
...existingRule ?? {},
|
|
915
|
-
systemManaged: true,
|
|
916
|
-
preserveForElevated: existingRule?.preserveForElevated ?? true
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
};
|
|
920
|
-
}
|
|
921
|
-
/**
|
|
922
|
-
* Remove a field from a JSON Schema's `required[]` array. Leaves `properties`
|
|
923
|
-
* intact so advanced callers can still send the value — the field just isn't
|
|
924
|
-
* mandatory at validation time.
|
|
925
|
-
*
|
|
926
|
-
* Returns a fresh schema (no mutation). No-op when the schema is undefined,
|
|
927
|
-
* lacks a `required[]`, or the field is already absent from it.
|
|
928
|
-
*/
|
|
929
|
-
function stripFromRequired(schema, fieldName) {
|
|
930
|
-
if (!schema || typeof schema !== "object") return schema;
|
|
931
|
-
const required = schema.required;
|
|
932
|
-
if (!Array.isArray(required) || !required.includes(fieldName)) return schema;
|
|
933
|
-
const filtered = required.filter((f) => f !== fieldName);
|
|
934
|
-
const next = { ...schema };
|
|
935
|
-
if (filtered.length > 0) next.required = filtered;
|
|
936
|
-
else delete next.required;
|
|
937
|
-
return next;
|
|
938
|
-
}
|
|
939
|
-
/**
|
|
940
|
-
* Drop `systemManaged` fields from `required[]` in generated body schemas.
|
|
941
|
-
*
|
|
942
|
-
* Arc populates those from scope / preset middleware / controller, so a client
|
|
943
|
-
* never supplies them — but the DB schema may still mark them required, and the
|
|
944
|
-
* adapter reflects that into the wire contract.
|
|
945
|
-
*
|
|
946
|
-
* THE GOTCHA THIS CLOSES: engines on `@classytic/primitives` default to
|
|
947
|
-
* `tenant: { required: true }`, stamping `organizationId` as required. Fastify
|
|
948
|
-
* preValidation runs BEFORE arc's preHandler chain, so the tenant-injection
|
|
949
|
-
* hook never gets to run and the request dies on
|
|
950
|
-
* `must have required property 'organizationId'` — even though the client sent
|
|
951
|
-
* `x-organization-id` and arc had promised to inject it. The alternative was
|
|
952
|
-
* `tenant: { required: false }` at every consumer site.
|
|
953
|
-
*
|
|
954
|
-
* Also covers `auditedPreset`'s `createdBy`/`updatedBy` and any host rule
|
|
955
|
-
* marked `systemManaged`. Applies to create AND update, since update middleware
|
|
956
|
-
* injects the same fields.
|
|
957
|
-
*
|
|
958
|
-
* `properties` is left INTACT — an elevated caller may still send these, and
|
|
959
|
-
* `BodySanitizer` owns that runtime policy (`preserveForElevated`,
|
|
960
|
-
* strip-vs-reject). No-op without `systemManaged` rules or generated schemas.
|
|
961
|
-
*/
|
|
962
|
-
function stripSystemManagedFromBodyRequired(schemas, schemaOptions) {
|
|
963
|
-
if (!schemas) return schemas;
|
|
964
|
-
const rules = schemaOptions?.fieldRules;
|
|
965
|
-
if (!rules) return schemas;
|
|
966
|
-
const systemManagedFields = Object.entries(rules).filter(([, rule]) => rule?.systemManaged === true).map(([field]) => field);
|
|
967
|
-
if (systemManagedFields.length === 0) return schemas;
|
|
968
|
-
const next = { ...schemas };
|
|
969
|
-
let createBody = schemas.createBody;
|
|
970
|
-
for (const field of systemManagedFields) createBody = stripFromRequired(createBody, field);
|
|
971
|
-
if (createBody !== schemas.createBody) next.createBody = createBody;
|
|
972
|
-
let updateBody = schemas.updateBody;
|
|
973
|
-
for (const field of systemManagedFields) updateBody = stripFromRequired(updateBody, field);
|
|
974
|
-
if (updateBody !== schemas.updateBody) next.updateBody = updateBody;
|
|
975
|
-
return next;
|
|
976
|
-
}
|
|
977
|
-
//#endregion
|
|
978
507
|
//#region src/core/defineResource/presets.ts
|
|
979
508
|
/**
|
|
980
509
|
* Phase 3 — apply presets + auto-inject tenant-field schema rules.
|
|
@@ -1132,352 +661,6 @@ function assertTenantFieldUsable(resourceName, tenantField, strategyType) {
|
|
|
1132
661
|
if (tenantField === false) throw new Error(`[Arc/Cascade] Resource '${resourceName}' declares onTenantDelete (strategy: ${strategyType}) but \`tenantField: false\`. Company-wide resources can't be cascaded by org — set a real \`tenantField\` or remove the \`onTenantDelete\` declaration.`);
|
|
1133
662
|
}
|
|
1134
663
|
//#endregion
|
|
1135
|
-
//#region src/core/defineResource/plugin.ts
|
|
1136
|
-
/**
|
|
1137
|
-
* Build the CRUD schema map from the adapter's `OpenApiSchemas` plus
|
|
1138
|
-
* any `customSchemas` overrides on the resource.
|
|
1139
|
-
*
|
|
1140
|
-
* Returns `null` when neither input has anything to contribute, so
|
|
1141
|
-
* the caller can pass `undefined` straight to `createCrudRouter`.
|
|
1142
|
-
*
|
|
1143
|
-
* **Per-slot layering (post-2.12 DX fix).** Adapter auto-gen runs
|
|
1144
|
-
* unconditionally — declaring one custom slot (e.g. a richer
|
|
1145
|
-
* `create.body`) no longer wholesale-disables generated `get`,
|
|
1146
|
-
* `update`, `delete`, and `params` schemas. The pre-fix branch
|
|
1147
|
-
* skipped auto-gen entirely whenever `customSchemas` had any entry,
|
|
1148
|
-
* which silently flipped four slots from "auto-derived from the
|
|
1149
|
-
* adapter's schema generator" to "Fastify default" the moment a
|
|
1150
|
-
* host customised one. Now: auto-gen first, then deep-merge
|
|
1151
|
-
* customSchemas on top per slot.
|
|
1152
|
-
*
|
|
1153
|
-
* **`params` cloning is load-bearing.** Three CRUD slots (`get`,
|
|
1154
|
-
* `delete`, `update`) need a `params` schema. The previous inline
|
|
1155
|
-
* code shared the same reference across all three, so a downstream
|
|
1156
|
-
* mutation (e.g. attaching a vendor `description` for OpenAPI
|
|
1157
|
-
* tooling) leaked across operations. Each slot now owns its own
|
|
1158
|
-
* shallow clone.
|
|
1159
|
-
*/
|
|
1160
|
-
function buildGeneratedCrudSchemas(openApi, customSchemas) {
|
|
1161
|
-
const generated = {};
|
|
1162
|
-
if (openApi) {
|
|
1163
|
-
const { createBody, updateBody, params } = openApi;
|
|
1164
|
-
if (createBody) generated.create = { body: safeBody(createBody) };
|
|
1165
|
-
if (updateBody) {
|
|
1166
|
-
const patchBody = structuredClone(updateBody);
|
|
1167
|
-
stripRequiredDeep(patchBody);
|
|
1168
|
-
generated.update = { body: safeBody(patchBody) };
|
|
1169
|
-
if (params) generated.update.params = cloneShallow(params);
|
|
1170
|
-
}
|
|
1171
|
-
if (params) {
|
|
1172
|
-
generated.get = { params: cloneShallow(params) };
|
|
1173
|
-
generated.delete = { params: cloneShallow(params) };
|
|
1174
|
-
if (!generated.update) generated.update = { params: cloneShallow(params) };
|
|
1175
|
-
else if (!generated.update.params) generated.update.params = cloneShallow(params);
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
let schemas = Object.keys(generated).length > 0 ? generated : null;
|
|
1179
|
-
if (customSchemas && Object.keys(customSchemas).length > 0) {
|
|
1180
|
-
schemas = schemas ?? {};
|
|
1181
|
-
for (const [op, customSchema] of Object.entries(customSchemas)) {
|
|
1182
|
-
const key = op;
|
|
1183
|
-
const converted = convertRouteSchema(customSchema);
|
|
1184
|
-
schemas[key] = schemas[key] ? {
|
|
1185
|
-
...schemas[key],
|
|
1186
|
-
...converted
|
|
1187
|
-
} : converted;
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
return schemas;
|
|
1191
|
-
}
|
|
1192
|
-
/**
|
|
1193
|
-
* Normalize the listQuery JSON Schema for Fastify/AJV strict-mode use.
|
|
1194
|
-
*
|
|
1195
|
-
* The `qs` parser turns bracket notation into nested objects/arrays:
|
|
1196
|
-
* `?name[contains]=foo` → `{ name: { contains: "foo" } }`
|
|
1197
|
-
* `?tags[]=a&tags[]=b` → `{ tags: ["a", "b"] }`
|
|
1198
|
-
* `?populate[author][select]=name` → deeply nested object
|
|
1199
|
-
*
|
|
1200
|
-
* AJV rejects these against the OpenAPI-friendly `type: "string"`
|
|
1201
|
-
* declarations adapters generate. The QueryParser is the source of truth
|
|
1202
|
-
* for filter validation/coercion, so we replace each property with a
|
|
1203
|
-
* minimal AJV-strict-mode-clean shape: numeric pagination keys keep
|
|
1204
|
-
* `type: "integer"` (so `minimum`/`maximum` don't trigger AJV warnings),
|
|
1205
|
-
* everything else collapses to `{}`.
|
|
1206
|
-
*
|
|
1207
|
-
* The original `limit.maximum` is preserved so the parser's configured
|
|
1208
|
-
* max stays effective at the AJV layer.
|
|
1209
|
-
*/
|
|
1210
|
-
function normalizeListQuerySchema(listQuerySchema) {
|
|
1211
|
-
const NORMALIZED_PROPS = {
|
|
1212
|
-
page: {
|
|
1213
|
-
type: "integer",
|
|
1214
|
-
minimum: 1
|
|
1215
|
-
},
|
|
1216
|
-
limit: {
|
|
1217
|
-
type: "integer",
|
|
1218
|
-
minimum: 1
|
|
1219
|
-
},
|
|
1220
|
-
sort: {},
|
|
1221
|
-
search: {},
|
|
1222
|
-
select: {},
|
|
1223
|
-
after: {},
|
|
1224
|
-
populate: {},
|
|
1225
|
-
lookup: {},
|
|
1226
|
-
aggregate: {}
|
|
1227
|
-
};
|
|
1228
|
-
const props = listQuerySchema.properties;
|
|
1229
|
-
const normalizedProps = props ? { ...props } : void 0;
|
|
1230
|
-
if (normalizedProps) {
|
|
1231
|
-
const originalLimit = normalizedProps.limit;
|
|
1232
|
-
if (originalLimit?.maximum) NORMALIZED_PROPS.limit = {
|
|
1233
|
-
...NORMALIZED_PROPS.limit,
|
|
1234
|
-
maximum: originalLimit.maximum
|
|
1235
|
-
};
|
|
1236
|
-
for (const key of Object.keys(normalizedProps)) normalizedProps[key] = NORMALIZED_PROPS[key] ?? {};
|
|
1237
|
-
}
|
|
1238
|
-
return {
|
|
1239
|
-
type: "object",
|
|
1240
|
-
...listQuerySchema,
|
|
1241
|
-
...normalizedProps ? { properties: normalizedProps } : {},
|
|
1242
|
-
additionalProperties: listQuerySchema.additionalProperties ?? true
|
|
1243
|
-
};
|
|
1244
|
-
}
|
|
1245
|
-
/**
|
|
1246
|
-
* Merge two JSON schema branches deeply. Arrays are unioned with
|
|
1247
|
-
* deduplication (so combined `required` lists don't duplicate field
|
|
1248
|
-
* names); plain-object keys recurse; primitives are overwritten.
|
|
1249
|
-
*
|
|
1250
|
-
* Exported for the action/CRUD router-config code paths that need to
|
|
1251
|
-
* compose user overrides on top of generated schemas.
|
|
1252
|
-
*/
|
|
1253
|
-
function deepMergeSchemas(base, override) {
|
|
1254
|
-
if (!override) return base;
|
|
1255
|
-
if (!base) return override;
|
|
1256
|
-
const result = { ...base };
|
|
1257
|
-
for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [.../* @__PURE__ */ new Set([...result[key], ...value])];
|
|
1258
|
-
else if (value && typeof value === "object" && !Array.isArray(value)) result[key] = deepMergeSchemas(result[key], value);
|
|
1259
|
-
else result[key] = value;
|
|
1260
|
-
return result;
|
|
1261
|
-
}
|
|
1262
|
-
function cloneShallow(value) {
|
|
1263
|
-
return { ...value };
|
|
1264
|
-
}
|
|
1265
|
-
/**
|
|
1266
|
-
* Delete every `required` from a JSON-schema subtree — PATCH means optional at
|
|
1267
|
-
* every depth, not just the root.
|
|
1268
|
-
*
|
|
1269
|
-
* A generator may legitimately return an `updateBody` whose nested
|
|
1270
|
-
* subdocument-array `items` carries its own `required`. Stripping only the
|
|
1271
|
-
* root left that intact, so a PATCH adding one item to a subdocument array was
|
|
1272
|
-
* rejected by AJV for a field the caller had no way to know it must resend.
|
|
1273
|
-
* Recursing here enforces PATCH semantics on whatever a generator produces,
|
|
1274
|
-
* rather than trusting each to recurse correctly.
|
|
1275
|
-
*/
|
|
1276
|
-
function stripRequiredDeep(node) {
|
|
1277
|
-
if (Array.isArray(node)) {
|
|
1278
|
-
for (const item of node) stripRequiredDeep(item);
|
|
1279
|
-
return;
|
|
1280
|
-
}
|
|
1281
|
-
if (!node || typeof node !== "object") return;
|
|
1282
|
-
const obj = node;
|
|
1283
|
-
delete obj.required;
|
|
1284
|
-
for (const value of Object.values(obj)) stripRequiredDeep(value);
|
|
1285
|
-
}
|
|
1286
|
-
function safeBody(schema) {
|
|
1287
|
-
if (schema && typeof schema === "object" && schema.type === "object") return {
|
|
1288
|
-
additionalProperties: true,
|
|
1289
|
-
...schema
|
|
1290
|
-
};
|
|
1291
|
-
return schema;
|
|
1292
|
-
}
|
|
1293
|
-
/**
|
|
1294
|
-
* Normalize `ActionsMap` into the `ActionRouterConfig` shape that
|
|
1295
|
-
* `createActionRouter` expects.
|
|
1296
|
-
*
|
|
1297
|
-
* **Permission fallback chain (fail-closed, v2.10.5):** delegated to the
|
|
1298
|
-
* shared resolver in `actionPermissions.ts`. The resource-level gate
|
|
1299
|
-
* goes into the resolver's slot 2 (`resourceActionPermissions`) — its
|
|
1300
|
-
* semantic home — leaving slot 3 (`globalAuth`) reserved for direct
|
|
1301
|
-
* `createActionRouter` callers that genuinely have a router-wide gate.
|
|
1302
|
-
*
|
|
1303
|
-
* The returned `actionPermissions` map is FULLY RESOLVED per action.
|
|
1304
|
-
* Earlier versions returned a sparse map plus a `globalAuth` field that
|
|
1305
|
-
* `createActionRouter` flattened at request time via `?? globalAuth`.
|
|
1306
|
-
* That conflated two different layers in the resolver chain (slot 2 vs.
|
|
1307
|
-
* slot 3). Boot-time resolution closes the drift: every action that
|
|
1308
|
-
* survives this pass has its effective gate baked into the map, and
|
|
1309
|
-
* `createActionRouter`'s request-time `?? globalAuth` becomes a no-op
|
|
1310
|
-
* for the defineResource path.
|
|
1311
|
-
*/
|
|
1312
|
-
function normalizeActionsToRouterConfig(actions, resourceActionPermissions, tag, resourcePermissions, resourceName, log) {
|
|
1313
|
-
const handlers = {};
|
|
1314
|
-
const permissions = {};
|
|
1315
|
-
const schemas = {};
|
|
1316
|
-
const idLessActionNames = [];
|
|
1317
|
-
for (const [name, entry] of Object.entries(actions)) {
|
|
1318
|
-
const explicit = typeof entry !== "function" && entry.permissions ? entry.permissions : void 0;
|
|
1319
|
-
if (typeof entry === "function") handlers[name] = entry;
|
|
1320
|
-
else {
|
|
1321
|
-
const def = entry;
|
|
1322
|
-
handlers[name] = def.handler;
|
|
1323
|
-
if (def.schema) schemas[name] = def.schema;
|
|
1324
|
-
if (def.id === false) idLessActionNames.push(name);
|
|
1325
|
-
}
|
|
1326
|
-
const effective = resolveActionPermission({
|
|
1327
|
-
action: entry,
|
|
1328
|
-
resourcePermissions,
|
|
1329
|
-
resourceActionPermissions,
|
|
1330
|
-
globalAuth: void 0
|
|
1331
|
-
});
|
|
1332
|
-
if (!explicit && !resourceActionPermissions && effective && effective === resourcePermissions?.update) log?.warn?.({
|
|
1333
|
-
resource: resourceName,
|
|
1334
|
-
action: name,
|
|
1335
|
-
fallback: "permissions.update"
|
|
1336
|
-
}, `[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.`);
|
|
1337
|
-
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.`);
|
|
1338
|
-
permissions[name] = effective;
|
|
1339
|
-
}
|
|
1340
|
-
return {
|
|
1341
|
-
tag,
|
|
1342
|
-
actions: handlers,
|
|
1343
|
-
actionPermissions: permissions,
|
|
1344
|
-
actionSchemas: schemas,
|
|
1345
|
-
idLessActionNames
|
|
1346
|
-
};
|
|
1347
|
-
}
|
|
1348
|
-
/**
|
|
1349
|
-
* Build the FastifyPluginAsync that materialises a `ResourceDefinition`
|
|
1350
|
-
* into routes, hooks, registry entries, and cache invalidation rules.
|
|
1351
|
-
*
|
|
1352
|
-
* Called once per `ResourceDefinition.toPlugin()`. The returned plugin
|
|
1353
|
-
* function captures `resource` in its closure and can be `app.register`-ed
|
|
1354
|
-
* any number of times — shared-state writes are idempotent per host
|
|
1355
|
-
* Fastify instance via `resource._sharedStateRegisteredOn`.
|
|
1356
|
-
*/
|
|
1357
|
-
function buildResourcePlugin(resource) {
|
|
1358
|
-
return async function resourcePlugin(fastify, opts) {
|
|
1359
|
-
await ensureSchemaConverter();
|
|
1360
|
-
const sharedRoot = fastify.server;
|
|
1361
|
-
const isFirstMount = !resource._sharedStateRegisteredOn.has(sharedRoot);
|
|
1362
|
-
if (isFirstMount) resource._sharedStateRegisteredOn.add(sharedRoot);
|
|
1363
|
-
if (isFirstMount && resource._deferredChecks?.length) for (const check of resource._deferredChecks) check();
|
|
1364
|
-
if (isFirstMount && resource._diagnostics?.length) for (const diagnostic of resource._diagnostics) {
|
|
1365
|
-
const level = diagnostic.severity === "info" ? "info" : diagnostic.severity === "error" ? "error" : "warn";
|
|
1366
|
-
fastify.log?.[level]?.(diagnostic.message);
|
|
1367
|
-
}
|
|
1368
|
-
const arc = fastify.arc;
|
|
1369
|
-
if (isFirstMount && arc?.registry && resource._registryMeta) try {
|
|
1370
|
-
arc.registry.register(resource, resource._registryMeta);
|
|
1371
|
-
} catch (err) {
|
|
1372
|
-
fastify.log?.warn?.(`Failed to register resource '${resource.name}' in registry: ${err instanceof Error ? err.message : err}`);
|
|
1373
|
-
}
|
|
1374
|
-
if (isFirstMount && resource._pendingHooks.length > 0 && arc?.hooks) for (const hook of resource._pendingHooks) arc.hooks.register({
|
|
1375
|
-
resource: resource.name,
|
|
1376
|
-
operation: hook.operation,
|
|
1377
|
-
phase: hook.phase,
|
|
1378
|
-
handler: hook.handler,
|
|
1379
|
-
priority: hook.priority
|
|
1380
|
-
});
|
|
1381
|
-
if (isFirstMount && resource.cache?.invalidateOn) {
|
|
1382
|
-
if (fastify.registerCacheInvalidationRule) for (const [pattern, tags] of Object.entries(resource.cache.invalidateOn)) fastify.registerCacheInvalidationRule({
|
|
1383
|
-
pattern,
|
|
1384
|
-
tags
|
|
1385
|
-
});
|
|
1386
|
-
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.`);
|
|
1387
|
-
}
|
|
1388
|
-
if (isFirstMount && resource.audit) {
|
|
1389
|
-
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.`);
|
|
1390
|
-
}
|
|
1391
|
-
if (isFirstMount && resource.events && Object.keys(resource.events).length > 0) {
|
|
1392
|
-
const eventsDecor = fastify.events;
|
|
1393
|
-
if (!(eventsDecor != null && typeof eventsDecor.publish === "function")) {
|
|
1394
|
-
const eventNames = Object.keys(resource.events).join(", ");
|
|
1395
|
-
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.`);
|
|
1396
|
-
}
|
|
1397
|
-
}
|
|
1398
|
-
if (opts?.arcMountRoutes === false) return;
|
|
1399
|
-
await fastify.register(async (instance) => {
|
|
1400
|
-
const typedInstance = instance;
|
|
1401
|
-
let schemas = buildGeneratedCrudSchemas(resource._registryMeta?.openApiSchemas, resource.customSchemas);
|
|
1402
|
-
const listQuerySchema = resource._registryMeta?.openApiSchemas?.listQuery;
|
|
1403
|
-
if (listQuerySchema) {
|
|
1404
|
-
const normalizedSchema = normalizeListQuerySchema(listQuerySchema);
|
|
1405
|
-
schemas = schemas ?? {};
|
|
1406
|
-
schemas.list = schemas.list ? deepMergeSchemas({ querystring: normalizedSchema }, schemas.list) : { querystring: normalizedSchema };
|
|
1407
|
-
}
|
|
1408
|
-
createCrudRouter(typedInstance, resource.controller, {
|
|
1409
|
-
tag: resource.tag,
|
|
1410
|
-
schemas: schemas ?? void 0,
|
|
1411
|
-
permissions: resource.permissions,
|
|
1412
|
-
middlewares: resource.middlewares,
|
|
1413
|
-
routeGuards: resource.routeGuards,
|
|
1414
|
-
routes: resource.routes,
|
|
1415
|
-
disableDefaultRoutes: resource.disableDefaultRoutes,
|
|
1416
|
-
disabledRoutes: [...resource.disabledRoutes],
|
|
1417
|
-
resourceName: resource.name,
|
|
1418
|
-
schemaOptions: resource.schemaOptions,
|
|
1419
|
-
rateLimit: resource.rateLimit,
|
|
1420
|
-
updateMethod: resource.updateMethod,
|
|
1421
|
-
pipe: resource.pipe,
|
|
1422
|
-
fields: resource.fields,
|
|
1423
|
-
onFieldWriteDenied: resource.onFieldWriteDenied,
|
|
1424
|
-
idField: resource.idField,
|
|
1425
|
-
extensions: resource.extensions
|
|
1426
|
-
});
|
|
1427
|
-
if (resource.actions && Object.keys(resource.actions).length > 0) {
|
|
1428
|
-
const { createActionRouter } = await import("./createActionRouter-BBlbPs3k.mjs").then((n) => n.n);
|
|
1429
|
-
createActionRouter(typedInstance, {
|
|
1430
|
-
...normalizeActionsToRouterConfig(resource.actions, resource.actionPermissions, resource.tag, resource.permissions, resource.name, typedInstance.log),
|
|
1431
|
-
resourceName: resource.name,
|
|
1432
|
-
fields: resource.fields,
|
|
1433
|
-
schemaOptions: resource.schemaOptions,
|
|
1434
|
-
idField: resource.idField,
|
|
1435
|
-
permissions: resource.permissions,
|
|
1436
|
-
routeGuards: resource.routeGuards,
|
|
1437
|
-
pipeline: resource.pipe,
|
|
1438
|
-
rateLimit: resource.rateLimit,
|
|
1439
|
-
extensions: resource.extensions
|
|
1440
|
-
});
|
|
1441
|
-
}
|
|
1442
|
-
if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
|
|
1443
|
-
const [{ createAggregationRouter }, { adapterSupportsAggregate, ArcAggregationConfigError }] = await Promise.all([import("./createAggregationRouter-Dv5SuK2x.mjs"), import("./validate-R0G_JrnK.mjs").then((n) => n.r)]);
|
|
1444
|
-
const repoForAgg = resource.controller?.repository ?? resource.repository;
|
|
1445
|
-
if (!adapterSupportsAggregate(repoForAgg)) {
|
|
1446
|
-
const undispatchable = Object.entries(resource.aggregations).filter(([, cfg]) => !cfg.materialized).map(([name]) => name);
|
|
1447
|
-
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.`);
|
|
1448
|
-
}
|
|
1449
|
-
const buildOptions = (req) => {
|
|
1450
|
-
const ctrl = resource.controller;
|
|
1451
|
-
if (!ctrl?.tenantRepoOptions) return {};
|
|
1452
|
-
const ctx = createRequestContext(req);
|
|
1453
|
-
return ctrl.tenantRepoOptions(ctx);
|
|
1454
|
-
};
|
|
1455
|
-
createAggregationRouter(typedInstance, {
|
|
1456
|
-
tag: resource.tag,
|
|
1457
|
-
resourceName: resource.name,
|
|
1458
|
-
aggregations: resource.aggregations,
|
|
1459
|
-
fields: resource.fields,
|
|
1460
|
-
schemaOptions: resource.schemaOptions,
|
|
1461
|
-
permissions: resource.permissions,
|
|
1462
|
-
routeGuards: resource.routeGuards,
|
|
1463
|
-
repository: repoForAgg,
|
|
1464
|
-
buildOptions,
|
|
1465
|
-
middlewares: resource.middlewares?.aggregations
|
|
1466
|
-
});
|
|
1467
|
-
}
|
|
1468
|
-
if (resource.events && Object.keys(resource.events).length > 0) typedInstance.log?.debug?.(`Resource '${resource.name}' defined ${Object.keys(resource.events).length} events`);
|
|
1469
|
-
}, { prefix: resource.prefix });
|
|
1470
|
-
if (hasEvents(fastify)) try {
|
|
1471
|
-
await fastify.events.publish("arc.resource.registered", {
|
|
1472
|
-
resource: resource.name,
|
|
1473
|
-
prefix: resource.prefix,
|
|
1474
|
-
presets: resource._appliedPresets,
|
|
1475
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1476
|
-
});
|
|
1477
|
-
} catch {}
|
|
1478
|
-
};
|
|
1479
|
-
}
|
|
1480
|
-
//#endregion
|
|
1481
664
|
//#region src/core/defineResource/ResourceDefinition.ts
|
|
1482
665
|
var ResourceDefinition = class {
|
|
1483
666
|
name;
|
|
@@ -1500,6 +683,8 @@ var ResourceDefinition = class {
|
|
|
1500
683
|
aggregations;
|
|
1501
684
|
events;
|
|
1502
685
|
rateLimit;
|
|
686
|
+
idempotency;
|
|
687
|
+
lookupFields;
|
|
1503
688
|
audit;
|
|
1504
689
|
updateMethod;
|
|
1505
690
|
pipe;
|
|
@@ -1533,6 +718,12 @@ var ResourceDefinition = class {
|
|
|
1533
718
|
*/
|
|
1534
719
|
resolvedTenantPurge;
|
|
1535
720
|
idField;
|
|
721
|
+
/**
|
|
722
|
+
* The optimistic-concurrency version field, resolved (`concurrency: {}` → `'__v'`);
|
|
723
|
+
* `undefined` when the resource did not opt in. Published in the resource manifest
|
|
724
|
+
* so a client sends `If-Match` without repeating the field name.
|
|
725
|
+
*/
|
|
726
|
+
versionField;
|
|
1536
727
|
queryParser;
|
|
1537
728
|
_appliedPresets;
|
|
1538
729
|
_pendingHooks;
|
|
@@ -1621,6 +812,8 @@ var ResourceDefinition = class {
|
|
|
1621
812
|
this.actionPermissions = config.actionPermissions;
|
|
1622
813
|
this.aggregations = config.aggregations;
|
|
1623
814
|
this.rateLimit = config.rateLimit;
|
|
815
|
+
this.idempotency = config.idempotency;
|
|
816
|
+
this.lookupFields = config.lookupFields;
|
|
1624
817
|
this.audit = config.audit;
|
|
1625
818
|
this.updateMethod = config.updateMethod;
|
|
1626
819
|
this.pipe = config.pipe;
|
|
@@ -1638,6 +831,7 @@ var ResourceDefinition = class {
|
|
|
1638
831
|
onTenantDelete: this.onTenantDelete
|
|
1639
832
|
});
|
|
1640
833
|
this.idField = config.idField;
|
|
834
|
+
if (config.concurrency) this.versionField = config.concurrency.versionField ?? "__v";
|
|
1641
835
|
this.queryParser = config.queryParser;
|
|
1642
836
|
this._appliedPresets = config._appliedPresets ?? [];
|
|
1643
837
|
this._pendingHooks = config._pendingHooks ?? [];
|
|
@@ -1754,161 +948,6 @@ function freezeActions(actions) {
|
|
|
1754
948
|
return Object.freeze(frozen);
|
|
1755
949
|
}
|
|
1756
950
|
//#endregion
|
|
1757
|
-
//#region src/core/defineResource/schemas.ts
|
|
1758
|
-
/**
|
|
1759
|
-
* OpenAPI schema resolution — Phase 7 of `defineResource()`.
|
|
1760
|
-
*
|
|
1761
|
-
* Pipeline (each step is a pure function over `OpenApiSchemas | undefined`):
|
|
1762
|
-
*
|
|
1763
|
-
* adapter.generateSchemas()
|
|
1764
|
-
* → stripSystemManagedFromBodyRequired (from `../schemaOptions.js`)
|
|
1765
|
-
* → cleanLegacyObjectIdParams (idField safety net)
|
|
1766
|
-
* → layerQueryParserListQuery (kit's listQuery JSON Schema)
|
|
1767
|
-
* → mergeUserOpenApiOverrides (per-resource overrides)
|
|
1768
|
-
* → convertOpenApiSchemas (Zod → JSON Schema if needed)
|
|
1769
|
-
*
|
|
1770
|
-
* Non-fatal: if any step throws, the orchestrator returns `undefined` so
|
|
1771
|
-
* the resource still boots — docs / introspection / MCP tool schemas
|
|
1772
|
-
* degrade visibly instead of silently drifting.
|
|
1773
|
-
*
|
|
1774
|
-
* Pulled out of `defineResource.ts` so the central function reads as
|
|
1775
|
-
* orchestration only; the schema mechanics live next to each other and
|
|
1776
|
-
* are easier to evolve in isolation.
|
|
1777
|
-
*/
|
|
1778
|
-
/**
|
|
1779
|
-
* Phase 7 orchestrator — runs the schema pipeline and returns the
|
|
1780
|
-
* registry metadata for the resource. Returns `undefined` (with a
|
|
1781
|
-
* structured warn log) if any step throws.
|
|
1782
|
-
*/
|
|
1783
|
-
function resolveOpenApiSchemas(resolvedConfig) {
|
|
1784
|
-
try {
|
|
1785
|
-
let openApiSchemas = generateAdapterSchemas(resolvedConfig);
|
|
1786
|
-
openApiSchemas = stripSystemManagedFromBodyRequired(openApiSchemas, resolvedConfig.schemaOptions);
|
|
1787
|
-
openApiSchemas = cleanLegacyObjectIdParams(openApiSchemas, resolvedConfig.idField);
|
|
1788
|
-
openApiSchemas = layerQueryParserListQuery(openApiSchemas, resolvedConfig.queryParser);
|
|
1789
|
-
openApiSchemas = applyResourcePaginationCaps(openApiSchemas, resolvedConfig);
|
|
1790
|
-
openApiSchemas = mergeUserOpenApiOverrides(openApiSchemas, resolvedConfig.openApiSchemas);
|
|
1791
|
-
if (openApiSchemas) openApiSchemas = convertOpenApiSchemas(openApiSchemas);
|
|
1792
|
-
return {
|
|
1793
|
-
module: resolvedConfig.module,
|
|
1794
|
-
openApiSchemas
|
|
1795
|
-
};
|
|
1796
|
-
} catch (err) {
|
|
1797
|
-
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.`);
|
|
1798
|
-
return;
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
/**
|
|
1802
|
-
* Step 1 — delegate to the adapter's `generateSchemas`. Returns
|
|
1803
|
-
* `undefined` when the adapter doesn't implement the optional method.
|
|
1804
|
-
*/
|
|
1805
|
-
function generateAdapterSchemas(resolvedConfig) {
|
|
1806
|
-
if (!resolvedConfig.adapter?.generateSchemas) return void 0;
|
|
1807
|
-
const adapterContext = {
|
|
1808
|
-
idField: resolvedConfig.idField,
|
|
1809
|
-
resourceName: resolvedConfig.name
|
|
1810
|
-
};
|
|
1811
|
-
return resolvedConfig.adapter.generateSchemas(resolvedConfig.schemaOptions, adapterContext);
|
|
1812
|
-
}
|
|
1813
|
-
/**
|
|
1814
|
-
* Safety net: when `idField` is overridden to a non-default value (UUIDs,
|
|
1815
|
-
* slugs, ORD-2026-0001), strip any ObjectId pattern left on `params.id` by
|
|
1816
|
-
* legacy adapters or plugins that didn't honor `AdapterSchemaContext.idField`.
|
|
1817
|
-
* Custom IDs must not be rejected by AJV before BaseController runs the
|
|
1818
|
-
* actual lookup.
|
|
1819
|
-
*/
|
|
1820
|
-
function cleanLegacyObjectIdParams(openApiSchemas, idField) {
|
|
1821
|
-
if (!openApiSchemas || !idField || idField === "_id") return openApiSchemas;
|
|
1822
|
-
const params = openApiSchemas.params;
|
|
1823
|
-
if (!params || typeof params !== "object") return openApiSchemas;
|
|
1824
|
-
const properties = params.properties;
|
|
1825
|
-
const idProp = properties?.id;
|
|
1826
|
-
if (!idProp || typeof idProp !== "object") return openApiSchemas;
|
|
1827
|
-
const pattern = idProp.pattern;
|
|
1828
|
-
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 openApiSchemas;
|
|
1829
|
-
const cleanedId = { ...idProp };
|
|
1830
|
-
delete cleanedId.pattern;
|
|
1831
|
-
delete cleanedId.minLength;
|
|
1832
|
-
delete cleanedId.maxLength;
|
|
1833
|
-
if (!cleanedId.description) cleanedId.description = `${idField} (custom ID field)`;
|
|
1834
|
-
return {
|
|
1835
|
-
...openApiSchemas,
|
|
1836
|
-
params: {
|
|
1837
|
-
...params,
|
|
1838
|
-
properties: {
|
|
1839
|
-
...properties,
|
|
1840
|
-
id: cleanedId
|
|
1841
|
-
}
|
|
1842
|
-
}
|
|
1843
|
-
};
|
|
1844
|
-
}
|
|
1845
|
-
/**
|
|
1846
|
-
* Layer the query parser's `getQuerySchema()` output as `listQuery` so
|
|
1847
|
-
* the kit's filterable-fields surface flows into OpenAPI / MCP without
|
|
1848
|
-
* the user re-declaring it.
|
|
1849
|
-
*/
|
|
1850
|
-
function layerQueryParserListQuery(openApiSchemas, queryParser) {
|
|
1851
|
-
const qp = queryParser;
|
|
1852
|
-
if (!qp?.getQuerySchema) return openApiSchemas;
|
|
1853
|
-
const querySchema = qp.getQuerySchema();
|
|
1854
|
-
if (!querySchema) return openApiSchemas;
|
|
1855
|
-
return {
|
|
1856
|
-
...openApiSchemas,
|
|
1857
|
-
listQuery: querySchema
|
|
1858
|
-
};
|
|
1859
|
-
}
|
|
1860
|
-
/**
|
|
1861
|
-
* Apply resource-level `defaultLimit` / `maxLimit` (2.17.0) onto the
|
|
1862
|
-
* listQuery schema. Resource-level caps win over both the framework
|
|
1863
|
-
* default and the parser-derived value because they're the most-explicit
|
|
1864
|
-
* declaration — a host that wrote `defaultLimit: 1000` on the resource
|
|
1865
|
-
* means "this resource specifically returns up to 1000", regardless of
|
|
1866
|
-
* what the parser's default would have been. The `referenceData: true`
|
|
1867
|
-
* shorthand lifts both to 1000 so reference-data resources don't need
|
|
1868
|
-
* a custom queryParser just to get the caps right.
|
|
1869
|
-
*
|
|
1870
|
-
* Bootstraps a minimal listQuery slot when none exists (no queryParser
|
|
1871
|
-
* registered, no adapter-generated schema) so the AJV layer still gets
|
|
1872
|
-
* a `maximum:` and surfaces the cap in the 400 envelope via
|
|
1873
|
-
* `errorHandler.describePaginationCap()` instead of producing the
|
|
1874
|
-
* generic "Bad Request" we ate pre-2.17.0.
|
|
1875
|
-
*/
|
|
1876
|
-
function applyResourcePaginationCaps(openApiSchemas, resolvedConfig) {
|
|
1877
|
-
const cfg = resolvedConfig;
|
|
1878
|
-
if (cfg.defaultLimit === void 0 && cfg.maxLimit === void 0) return openApiSchemas;
|
|
1879
|
-
const existing = openApiSchemas?.listQuery;
|
|
1880
|
-
const existingProps = existing?.properties ?? {};
|
|
1881
|
-
const patchedLimit = { ...existingProps.limit ?? {
|
|
1882
|
-
type: "integer",
|
|
1883
|
-
minimum: 1
|
|
1884
|
-
} };
|
|
1885
|
-
if (cfg.maxLimit !== void 0) patchedLimit.maximum = cfg.maxLimit;
|
|
1886
|
-
if (cfg.defaultLimit !== void 0) patchedLimit.default = cfg.defaultLimit;
|
|
1887
|
-
return {
|
|
1888
|
-
...openApiSchemas,
|
|
1889
|
-
listQuery: {
|
|
1890
|
-
type: "object",
|
|
1891
|
-
...existing ?? {},
|
|
1892
|
-
properties: {
|
|
1893
|
-
...existingProps,
|
|
1894
|
-
limit: patchedLimit
|
|
1895
|
-
}
|
|
1896
|
-
}
|
|
1897
|
-
};
|
|
1898
|
-
}
|
|
1899
|
-
/**
|
|
1900
|
-
* Apply per-resource `openApiSchemas` overrides on top of the kit's
|
|
1901
|
-
* generated schemas. Shallow merge by slot — users who want field-level
|
|
1902
|
-
* surgery should compose at the schema-options layer before this point.
|
|
1903
|
-
*/
|
|
1904
|
-
function mergeUserOpenApiOverrides(openApiSchemas, userOverrides) {
|
|
1905
|
-
if (!userOverrides) return openApiSchemas;
|
|
1906
|
-
return {
|
|
1907
|
-
...openApiSchemas,
|
|
1908
|
-
...userOverrides
|
|
1909
|
-
};
|
|
1910
|
-
}
|
|
1911
|
-
//#endregion
|
|
1912
951
|
//#region src/core/validateResourceConfig.ts
|
|
1913
952
|
/**
|
|
1914
953
|
* Resource Configuration Validator
|
|
@@ -2268,11 +1307,26 @@ const CRUD_OP_NAMES = /* @__PURE__ */ new Set([
|
|
|
2268
1307
|
* resulting `ResourceDefinition` for the plugin layer to flush through
|
|
2269
1308
|
* `fastify.log.warn` on first mount.
|
|
2270
1309
|
*/
|
|
2271
|
-
|
|
2272
|
-
|
|
1310
|
+
/**
|
|
1311
|
+
* The checks NO flag may skip — `skipValidation` / `customRoutesOnly` skip structural and advisory
|
|
1312
|
+
* checks only. A route without a permission would otherwise mount PUBLIC.
|
|
1313
|
+
*/
|
|
1314
|
+
function validateSecurityInvariants(config) {
|
|
2273
1315
|
validatePermissionsShape(config);
|
|
2274
1316
|
validateCustomRoutePermissions(config);
|
|
2275
1317
|
validateActionsShape(config);
|
|
1318
|
+
validateLookupPolicies(config);
|
|
1319
|
+
}
|
|
1320
|
+
/** Every allowed join names the server scope of its joined side — a collection name alone authorizes nothing. */
|
|
1321
|
+
function validateLookupPolicies(config) {
|
|
1322
|
+
const allowed = config.schemaOptions?.query?.allowedLookups;
|
|
1323
|
+
if (allowed === void 0) return;
|
|
1324
|
+
if (Array.isArray(allowed) || typeof allowed !== "object" || allowed === null) throw new Error(`[Arc] Resource '${config.name}': schemaOptions.query.allowedLookups must map each collection to { scope: ({ scope }) => filter } — the joined side is not tenant-scoped by arc, so a name list authorizes nothing.`);
|
|
1325
|
+
for (const [collection, policy] of Object.entries(allowed)) if (typeof policy?.scope !== "function") throw new Error(`[Arc] Resource '${config.name}': allowedLookups.${collection} needs a scope function — the filter on its joined rows.`);
|
|
1326
|
+
}
|
|
1327
|
+
function validateDefineResourceConfig(config) {
|
|
1328
|
+
assertValidConfig(config, { skipControllerCheck: true });
|
|
1329
|
+
validateSecurityInvariants(config);
|
|
2276
1330
|
validateRouteCapabilities(config);
|
|
2277
1331
|
return [
|
|
2278
1332
|
...collectRedundantFieldRuleDiagnostics(config),
|
|
@@ -2449,15 +1503,49 @@ function validateActionsShape(config) {
|
|
|
2449
1503
|
}
|
|
2450
1504
|
//#endregion
|
|
2451
1505
|
//#region src/core/defineResource.ts
|
|
1506
|
+
/**
|
|
1507
|
+
* Resource Definition — database-agnostic single source of truth.
|
|
1508
|
+
*
|
|
1509
|
+
* `defineResource()` is the main entry point for arc resources. It
|
|
1510
|
+
* runs a fixed seven-phase pipeline that produces a fully-validated
|
|
1511
|
+
* `ResourceDefinition`:
|
|
1512
|
+
*
|
|
1513
|
+
* 1. validate — fail-fast structural checks
|
|
1514
|
+
* 2. resolveIdField — auto-derive `idField` from repository
|
|
1515
|
+
* 3. applyPresetsAndAutoInject — clone + apply presets + tenant rules
|
|
1516
|
+
* 4. resolveController — reuse user controller or auto-build
|
|
1517
|
+
* 5. buildResource — construct ResourceDefinition + verify
|
|
1518
|
+
* 6. wireHooks — preset + inline `config.hooks`
|
|
1519
|
+
* 7. resolveOpenApiSchemas — adapter → parser listQuery → user
|
|
1520
|
+
*
|
|
1521
|
+
* Each phase has its own module under `./defineResource/`. This file
|
|
1522
|
+
* is the orchestrator only — it threads `resolvedConfig` between
|
|
1523
|
+
* phases and delegates every responsibility. The `ResourceDefinition`
|
|
1524
|
+
* class itself lives in `./defineResource/ResourceDefinition.ts`.
|
|
1525
|
+
*
|
|
1526
|
+
* The adapter is the only kit-specific part — every kit's factory plugs in
|
|
1527
|
+
* identically.
|
|
1528
|
+
*
|
|
1529
|
+
* @example
|
|
1530
|
+
* ```typescript
|
|
1531
|
+
* export default defineResource({
|
|
1532
|
+
* name: 'product',
|
|
1533
|
+
* adapter: createMongooseAdapter({ model: ProductModel, repository: repo }),
|
|
1534
|
+
* presets: ['softDelete', 'slugLookup'],
|
|
1535
|
+
* permissions: { list: allowPublic(), create: requireRoles(['admin']) },
|
|
1536
|
+
* });
|
|
1537
|
+
* ```
|
|
1538
|
+
*/
|
|
2452
1539
|
function defineResource(config) {
|
|
2453
1540
|
const normalisedConfig = normalizeResourceConfig(config);
|
|
2454
1541
|
let diagnostics = [];
|
|
2455
1542
|
if (!normalisedConfig.skipValidation) diagnostics = validateDefineResourceConfig(normalisedConfig);
|
|
1543
|
+
else validateSecurityInvariants(normalisedConfig);
|
|
2456
1544
|
const repository = normalisedConfig.adapter?.repository;
|
|
2457
1545
|
const configWithId = resolveIdField(normalisedConfig, repository);
|
|
2458
1546
|
const resolvedConfig = applyPresetsAndAutoInject(configWithId);
|
|
2459
1547
|
const hasCrudRoutes = computeHasCrudRoutes(resolvedConfig);
|
|
2460
|
-
const strict = resolvedConfig.strictPermissions ?? process.env.ARC_STRICT_PERMISSIONS
|
|
1548
|
+
const strict = resolvedConfig.strictPermissions ?? readEnv(process.env).bool("ARC_STRICT_PERMISSIONS", true);
|
|
2461
1549
|
diagnostics = diagnostics.concat(collectUngatedCrudDiagnostics(resolvedConfig, strict));
|
|
2462
1550
|
const fatal = diagnostics.filter((d) => d.severity === "error");
|
|
2463
1551
|
if (fatal.length > 0) throw new Error(fatal.map((d) => d.message).join("\n\n"));
|
|
@@ -2475,12 +1563,18 @@ function defineResource(config) {
|
|
|
2475
1563
|
const registryMeta = resolveOpenApiSchemas(narrowedConfig);
|
|
2476
1564
|
if (registryMeta) resource._registryMeta = registryMeta;
|
|
2477
1565
|
}
|
|
1566
|
+
const deferred = [];
|
|
2478
1567
|
if (resolvedConfig.transactional === true) {
|
|
2479
1568
|
const name = resolvedConfig.name;
|
|
2480
|
-
|
|
1569
|
+
deferred.push(() => assertTransactionCapability(repository, name));
|
|
1570
|
+
}
|
|
1571
|
+
if (resolvedConfig.concurrency) {
|
|
1572
|
+
const name = resolvedConfig.name;
|
|
1573
|
+
deferred.push(() => assertConcurrencyCapability(repository, name));
|
|
2481
1574
|
}
|
|
1575
|
+
if (deferred.length > 0) resource._deferredChecks = deferred;
|
|
2482
1576
|
if (diagnostics.length > 0) resource._diagnostics = diagnostics;
|
|
2483
1577
|
return resource;
|
|
2484
1578
|
}
|
|
2485
1579
|
//#endregion
|
|
2486
|
-
export { ResourceDefinition as a, validateResourceConfig as i, assertValidConfig as n,
|
|
1580
|
+
export { ResourceDefinition as a, validateResourceConfig as i, assertValidConfig as n, formatValidationErrors as r, defineResource as t };
|