@classytic/arc 2.32.0 → 2.33.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/README.md +1 -1
- package/bin/arc.js +58 -17
- package/dist/{BaseController-Dk34dxhI.mjs → BaseController-CaQC5EXX.mjs} +293 -33
- package/dist/{EventTransport-2uVV34WU.d.mts → EventTransport-BEVP50ka.d.mts} +16 -0
- package/dist/{EventTransport-BV10D0oG.mjs → EventTransport-tq4CEB-q.mjs} +39 -2
- package/dist/{HookSystem-vimw5D7Z.mjs → HookSystem-DqXkMXkB.mjs} +2 -2
- package/dist/{ResourceRegistry-BVr7PgA0.mjs → ResourceRegistry-DoALUlOO.mjs} +3 -2
- package/dist/{actionPermissions-Dti7ebyv.mjs → actionPermissions-CUeCmpUd.mjs} +1 -1
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +2 -2
- package/dist/auth/audit.mjs +1 -1
- package/dist/auth/index.d.mts +2 -2
- package/dist/auth/index.mjs +39 -7
- package/dist/auth/redis-session.mjs +1 -1
- package/dist/{authorizationDecision-__T3EJDA.mjs → authorizationDecision-B3mP_QTy.mjs} +2 -2
- package/dist/{types-B7XeLEgK.d.mts → base-Csjc2oF-.d.mts} +74 -74
- package/dist/{buildHandler-Dn7rzUJo.mjs → buildHandler-DLE9xX-V.mjs} +4 -4
- package/dist/cache/index.mjs +4 -4
- package/dist/{caching-DFJqHyFs.mjs → caching-BcGmvnHh.mjs} +2 -2
- package/dist/cleanup/index.d.mts +17 -5
- package/dist/cleanup/index.mjs +240 -7
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/describe.mjs +3 -3
- 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-J99LGTJI.mjs → constants-BxCoxmiN.mjs} +1 -1
- package/dist/context/index.mjs +3 -3
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +7 -7
- package/dist/{core-BwwmNSp7.mjs → core-dxhcHhUD.mjs} +3 -3
- package/dist/{createActionRouter-DkJZWo0m.mjs → createActionRouter-DVpoA0do.mjs} +8 -8
- package/dist/{createAggregationRouter-B1exOVD8.mjs → createAggregationRouter-B_B48JZ-.mjs} +7 -7
- package/dist/{createApp-Bv0m-Q6_.mjs → createApp-CLGegqiG.mjs} +128 -24
- package/dist/{defineAggregation-gX4No1gt.mjs → defineAggregation-Nv-DwfOf.mjs} +1 -1
- package/dist/{defineResource-XFPPUu2c.mjs → defineResource-DR9vsUYy.mjs} +297 -23
- package/dist/discovery/index.mjs +1 -1
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +3 -3
- package/dist/{elevation-DNXXxgjl.mjs → elevation-B0tlY8y1.mjs} +4 -4
- package/dist/encryption/index.mjs +3 -3
- package/dist/{errorHandler-B2AZUJUj.mjs → errorHandler-C7xBqHsI.mjs} +42 -5
- package/dist/{errorHandler-CpYUJmPA.d.mts → errorHandler-DI5AN2b5.d.mts} +14 -3
- package/dist/{errors-Bh1KK5ps.mjs → errors-Cd_yy7aW.mjs} +1 -1
- package/dist/{eventPlugin-GF5nnBIq.d.mts → eventPlugin-DSNEZWfV.d.mts} +29 -1
- package/dist/{eventPlugin-Dk_TyXeE.mjs → eventPlugin-Dar8qRaj.mjs} +65 -9
- package/dist/events/index.d.mts +5 -351
- package/dist/events/index.mjs +5 -780
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +1 -1
- package/dist/factory/index.d.mts +20 -5
- package/dist/factory/index.mjs +7 -7
- package/dist/{fastify-augmentation-BppG5bMT.d.mts → fastify-augmentation-DVsTAy6X.d.mts} +3 -3
- package/dist/{fields-DEi65pGj.mjs → fields-CojeUkS4.mjs} +1 -1
- package/dist/{generate-CRSG0jTv.mjs → generate-DiB0nHPR.mjs} +2 -2
- package/dist/{headers-BXjQYbeC.mjs → headers-CveNO9gR.mjs} +2 -2
- package/dist/{health-5XnTa2uo.mjs → health-CasG2A28.mjs} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +2 -2
- package/dist/idempotency/index.d.mts +45 -1
- package/dist/idempotency/index.mjs +91 -9
- package/dist/idempotency/redis.mjs +1 -1
- package/dist/{index-zq6VrjaB.d.mts → index-B-DvUzsr.d.mts} +12 -2
- package/dist/{index-CpzxqePp.d.mts → index-BcR2bFZQ.d.mts} +707 -373
- package/dist/{index-DXlHwvuE.d.mts → index-Brin0ODU.d.mts} +4 -4
- package/dist/{index-ZWhLTp_s.d.mts → index-C5NnMEKF.d.mts} +1 -1
- package/dist/{index-C-481Zf2.d.mts → index-ugr1tQlW.d.mts} +17 -1
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +11 -11
- package/dist/{init-BbfP8aky.mjs → init-mdJ-v4Xp.mjs} +18 -12
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +2 -2
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs/index.d.mts +1 -1
- package/dist/integrations/jobs/index.mjs +99 -97
- package/dist/integrations/mcp/index.d.mts +12 -3
- package/dist/integrations/mcp/index.mjs +2 -2
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +2 -2
- package/dist/integrations/streamline.mjs +3 -3
- package/dist/integrations/webhooks.d.mts +124 -1
- package/dist/integrations/webhooks.mjs +180 -2
- package/dist/integrations/websocket-pushref-redis.mjs +1 -1
- package/dist/integrations/websocket-redis.mjs +1 -1
- package/dist/integrations/websocket.mjs +1 -1
- package/dist/{interface-BP4IJCBO.mjs → interface-DznJrm9R.mjs} +1 -1
- package/dist/{jsonBody-DDSR2HFl.mjs → jsonBody-CfGH-GxO.mjs} +2 -2
- package/dist/{keys-B-HBTGXl.mjs → keys-WeKvY1dE.mjs} +1 -1
- package/dist/{loadResources-DisEAmkm.mjs → loadResources-Bj-aSsXi.mjs} +2 -2
- package/dist/{loadResourcesFromEntry-D1MJp13f.mjs → loadResourcesFromEntry-DwaxNcE2.mjs} +2 -2
- package/dist/logger/index.mjs +1 -1
- package/dist/{mcpPlugin-CpfVpKHE.mjs → mcpPlugin-aJg5TqHi.mjs} +60 -21
- package/dist/{memory-DsuWnj4T.mjs → memory-qYSB7rMQ.mjs} +2 -2
- package/dist/{metrics-DdQL8syd.mjs → metrics-D7uchLjN.mjs} +2 -2
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +3 -3
- package/dist/migrations/index.mjs +2 -2
- package/dist/{module-Br81am6m.mjs → module-CwKsgZXp.mjs} +1 -1
- package/dist/{multipartBody-BnkBLN7v.mjs → multipartBody-4w-S3-PU.mjs} +1 -1
- package/dist/{openapi-DIRzvxC0.mjs → openapi-BN5cQaO2.mjs} +6 -6
- package/dist/outbox-admin/index.d.mts +2 -2
- package/dist/outbox-admin/index.mjs +4 -4
- package/dist/outbox-module-CXB79bvt.mjs +839 -0
- package/dist/outbox-module-D-VFq37T.d.mts +368 -0
- package/dist/permissions/index.d.mts +2 -2
- package/dist/permissions/index.mjs +5 -5
- package/dist/{permissions-Dl_m0NCe.mjs → permissions-BAfwgBxc.mjs} +25 -6
- package/dist/{pipe-ELHCP3zj.mjs → pipe-BzzoEhHj.mjs} +2 -2
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +2 -2
- package/dist/{pipeline-CPA-jx0M.mjs → pipeline-q69DZzgG.mjs} +6 -6
- package/dist/plugins/index.d.mts +6 -6
- package/dist/plugins/index.mjs +16 -16
- package/dist/plugins/response-cache.mjs +2 -2
- package/dist/plugins/tracing-entry.mjs +2 -2
- package/dist/{pluralize-C2ZkKbsn.mjs → pluralize-B7xf8qZF.mjs} +1 -1
- package/dist/{preloadResources-CDs87fTw.mjs → preloadResources-BkyqeTDz.mjs} +1 -1
- package/dist/{preloadResources-T1hmlTEZ.d.mts → preloadResources-g07ldtzh.d.mts} +30 -7
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +5 -5
- package/dist/presets/index.d.mts +2 -2
- package/dist/presets/index.mjs +2 -2
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +3 -3
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +2 -2
- package/dist/{presets-Dntd_pUO.mjs → presets-D-h0uWtP.mjs} +4 -4
- package/dist/projection-BKM0Ui-s.mjs +42 -0
- package/dist/{queryCachePlugin-Dhps-Ldt.mjs → queryCachePlugin-DhsZ0ZIc.mjs} +49 -7
- package/dist/{rateLimit-C7mmAGs3.mjs → rateLimit-D4-ASwsI.mjs} +2 -2
- package/dist/{rateLimitKey-CccEFBfk.mjs → rateLimitKey-XngqVNEV.mjs} +1 -1
- package/dist/{realtime-DVvVZZFz.mjs → realtime-CTkv3O59.mjs} +9 -9
- package/dist/{redis-stream-qN6A-RFv.d.mts → redis-stream-mwLWlfHG.d.mts} +1 -1
- package/dist/{registerRoute-B_QrjYZS.mjs → registerRoute-DNnd3vkz.mjs} +4 -4
- package/dist/registry/index.d.mts +3 -3
- package/dist/registry/index.mjs +3 -3
- package/dist/{registry-DqUmzA9W.mjs → registry-CLxCR5LF.mjs} +2 -2
- package/dist/{renewingLease-BpP9wwj7.mjs → renewingLease-BnWtbA2U.mjs} +1 -1
- package/dist/{replyHelpers-DS-P0b32.mjs → replyHelpers-BSBVCwL3.mjs} +2 -2
- package/dist/{repositoryFilter-DotlxG3L.mjs → repositoryFilter-DKny7PDn.mjs} +1 -1
- package/dist/{requestContext-B6vQF-sy.mjs → requestContext-uYE35JxI.mjs} +1 -1
- package/dist/{rolldown-runtime-BhflRrBs.mjs → rolldown-runtime-DNg-KNCk.mjs} +1 -1
- package/dist/runtimeCapabilities-Dm88DaTW.mjs +49 -0
- package/dist/{schedules-C2Q3uv2d.mjs → schedules-CAcZgia0.mjs} +3 -3
- package/dist/{schemaConverter-Crjvczi_.mjs → schemaConverter-XH1RZRyu.mjs} +1 -1
- package/dist/{schemaIR-CnyKK_1j.mjs → schemaIR-CDvclAew.mjs} +2 -2
- package/dist/schemas/index.mjs +1 -1
- package/dist/scim/index.mjs +2 -2
- package/dist/scope/index.mjs +5 -5
- package/dist/{scope-afz_Ieek.mjs → scope-BaWcqBvr.mjs} +4 -4
- package/dist/{simpleEqualityMatcher-C4KiMWUa.mjs → simpleEqualityMatcher-Se8VrsaP.mjs} +1 -1
- package/dist/{sse-B9P4Y-UI.mjs → sse-Cf0jUHsh.mjs} +5 -5
- package/dist/{sseStream-B3zb_d1h.mjs → sseStream-Bvpi-UXQ.mjs} +2 -2
- package/dist/{store-helpers-DmEMHlPQ.mjs → store-helpers-B88p99Z2.mjs} +1 -1
- package/dist/{streaming-DPZdQmDz.mjs → streaming-DsT1B5zJ.mjs} +1 -1
- package/dist/{subscribe-helpers-sp5xTDVA.mjs → subscribe-helpers-CLIOHsOK.mjs} +2 -2
- package/dist/sync/changelog.mjs +1 -1
- package/dist/testing/cleanupStoreContract.d.mts +1 -1
- package/dist/testing/cleanupStoreContract.mjs +3 -1
- package/dist/testing/index.d.mts +24 -12
- package/dist/testing/index.mjs +24 -12
- package/dist/testing/outboxStoreContract.mjs +1 -1
- package/dist/testing/storageContract.mjs +1 -1
- package/dist/{transactionContext-BvLKvOIL.mjs → transactionContext-BYtWnwAS.mjs} +1 -1
- package/dist/{typeGuards-B_5-hKjJ.mjs → typeGuards-jjKJDN13.mjs} +1 -1
- package/dist/types/index.d.mts +3 -3
- package/dist/types/index.mjs +1 -1
- package/dist/types/storage.mjs +1 -1
- package/dist/{types-CT5vu6M0.d.mts → types-BcTumAz_.d.mts} +41 -7
- package/dist/{types-CwcUQ610.d.mts → types-BfC83KsP.d.mts} +15 -1
- package/dist/{types-DXYX7dIR.mjs → types-CoqIRrkY.mjs} +4 -4
- package/dist/{types-C4QE8qnm.d.mts → types-DVLddu2q.d.mts} +144 -1
- package/dist/usage/index.d.mts +1 -1
- package/dist/usage/index.mjs +0 -0
- package/dist/{userHelpers-BNsOXzOd.mjs → userHelpers-CWF5pbZD.mjs} +1 -1
- package/dist/utils/index.d.mts +57 -5
- package/dist/utils/index.mjs +12 -11
- package/dist/{utils-BtOha_Sp.mjs → utils-h1V_sT-t.mjs} +38 -7
- package/dist/{validate-PMCPR9El.mjs → validate-CdQlz52F.mjs} +2 -2
- package/dist/{versioning-D_rhHfZm.d.mts → versioning-DEs-_hFi.d.mts} +1 -1
- package/dist/{versioning-CKv2jz_U.mjs → versioning-rZbyLVD-.mjs} +3 -3
- package/package.json +17 -16
- package/skills/arc/SKILL.md +96 -4
- package/skills/arc/references/api-reference.md +19 -0
- package/skills/arc/references/production.md +2 -2
- package/dist/projection-CjHhM5W1.mjs +0 -18
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-
|
|
1
|
+
/*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
|
|
2
|
+
import { p as MUTATION_OPERATIONS, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-BxCoxmiN.mjs";
|
|
3
3
|
import { arcLog } from "./logger/index.mjs";
|
|
4
|
-
import { r as ForbiddenError } from "./errors-
|
|
5
|
-
import { n as getUserRoles } from "./userHelpers-
|
|
6
|
-
import { b as isElevated, n as PUBLIC_SCOPE, x as isMember } from "./types-
|
|
7
|
-
import { d as DEFAULT_FIELD_WRITE_DENIAL_POLICY, t as BaseController } from "./BaseController-
|
|
8
|
-
import { a as buildRouteHooks, c as buildAuthMiddleware, i as selectPluginMw, n as buildRouteConfig, o as resolveRoutePreHandlers, r as resolveRouterPluginMw, s as routeHookOptions, t as buildRateLimitConfig, u as buildArcDecorator } from "./rateLimit-
|
|
9
|
-
import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-
|
|
10
|
-
import { r as buildCrudPermissionMw, t as tryRegisterRoute } from "./registerRoute-
|
|
11
|
-
import { c as getDefaultCrudSchemas } from "./utils-
|
|
12
|
-
import { n as convertRouteSchema, r as ensureSchemaConverter, t as convertOpenApiSchemas } from "./schemaConverter-
|
|
13
|
-
import { i as pipeUIMessageStreamToReply, r as isReadableStream } from "./streaming-
|
|
14
|
-
import { t as hasEvents } from "./typeGuards-
|
|
15
|
-
import { a as createFastifyHandler, i as createCrudHandlers, n as buildPipelineHandler, o as createRequestContext, r as resolvePipelineSteps } from "./pipeline-
|
|
16
|
-
import { B as requireAuth } from "./permissions-
|
|
17
|
-
import { r as getAvailablePresets, t as applyPresets } from "./presets-
|
|
18
|
-
import { t as pluralize } from "./pluralize-
|
|
19
|
-
import { t as resolveActionPermission } from "./actionPermissions-
|
|
4
|
+
import { r as ForbiddenError } from "./errors-Cd_yy7aW.mjs";
|
|
5
|
+
import { n as getUserRoles } from "./userHelpers-CWF5pbZD.mjs";
|
|
6
|
+
import { b as isElevated, n as PUBLIC_SCOPE, x as isMember } from "./types-CoqIRrkY.mjs";
|
|
7
|
+
import { d as isWriteVerbCapable, p as DEFAULT_FIELD_WRITE_DENIAL_POLICY, t as BaseController, u as detectOverriddenWriteSlots } from "./BaseController-CaQC5EXX.mjs";
|
|
8
|
+
import { a as buildRouteHooks, c as buildAuthMiddleware, i as selectPluginMw, n as buildRouteConfig, o as resolveRoutePreHandlers, r as resolveRouterPluginMw, s as routeHookOptions, t as buildRateLimitConfig, u as buildArcDecorator } from "./rateLimit-D4-ASwsI.mjs";
|
|
9
|
+
import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-CojeUkS4.mjs";
|
|
10
|
+
import { r as buildCrudPermissionMw, t as tryRegisterRoute } from "./registerRoute-DNnd3vkz.mjs";
|
|
11
|
+
import { c as getDefaultCrudSchemas } from "./utils-h1V_sT-t.mjs";
|
|
12
|
+
import { n as convertRouteSchema, r as ensureSchemaConverter, t as convertOpenApiSchemas } from "./schemaConverter-XH1RZRyu.mjs";
|
|
13
|
+
import { i as pipeUIMessageStreamToReply, r as isReadableStream } from "./streaming-DsT1B5zJ.mjs";
|
|
14
|
+
import { t as hasEvents } from "./typeGuards-jjKJDN13.mjs";
|
|
15
|
+
import { a as createFastifyHandler, i as createCrudHandlers, n as buildPipelineHandler, o as createRequestContext, r as resolvePipelineSteps } from "./pipeline-q69DZzgG.mjs";
|
|
16
|
+
import { B as requireAuth } from "./permissions-BAfwgBxc.mjs";
|
|
17
|
+
import { r as getAvailablePresets, t as applyPresets } from "./presets-D-h0uWtP.mjs";
|
|
18
|
+
import { t as pluralize } from "./pluralize-B7xf8qZF.mjs";
|
|
19
|
+
import { t as resolveActionPermission } from "./actionPermissions-CUeCmpUd.mjs";
|
|
20
|
+
import { isReadOnlyRepo } from "@classytic/repo-core/repository";
|
|
20
21
|
//#region src/core/middlewares/fieldWrite.ts
|
|
21
22
|
/**
|
|
22
23
|
* Build a preHandler that enforces field-write permissions on the request body.
|
|
@@ -61,6 +62,18 @@ function methodCarriesBody(method) {
|
|
|
61
62
|
//#endregion
|
|
62
63
|
//#region src/core/createCrudRouter.ts
|
|
63
64
|
/**
|
|
65
|
+
* Fold a route's host-supplied `config` together with arc's derived one into the
|
|
66
|
+
* SINGLE `config` key Fastify reads. Returns arc's object untouched when the host
|
|
67
|
+
* supplied none, so the route options stay literally what was declared.
|
|
68
|
+
*/
|
|
69
|
+
function mergeRouteConfig(hostConfig, arcConfig) {
|
|
70
|
+
if (!hostConfig) return arcConfig;
|
|
71
|
+
return { config: {
|
|
72
|
+
...hostConfig,
|
|
73
|
+
...arcConfig.config ?? {}
|
|
74
|
+
} };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
64
77
|
* Mount custom routes (from presets or user-defined `routes`) on Fastify.
|
|
65
78
|
* `wrapHandler` is derived inline from whether `rawHandler` was set.
|
|
66
79
|
*/
|
|
@@ -144,7 +157,25 @@ function createCustomRoutes(fastify, routes, controller, options) {
|
|
|
144
157
|
}
|
|
145
158
|
return result;
|
|
146
159
|
} : handler,
|
|
147
|
-
|
|
160
|
+
/**
|
|
161
|
+
* ONE `config` object, merging the host's `route.config` with arc's own
|
|
162
|
+
* derived keys (per-route rate limit, cors, `arcExtensions`).
|
|
163
|
+
*
|
|
164
|
+
* It has to be merged HERE because Fastify does not merge for us: it
|
|
165
|
+
* builds the context from a single `opts.config`
|
|
166
|
+
* (`{ ...opts.config, url, method }` — lib/route.js), so two spreads
|
|
167
|
+
* writing `config` means the later one silently replaces the earlier.
|
|
168
|
+
* Emitting them separately dropped `route.config` on every resource
|
|
169
|
+
* that declared extensions, a rate limit or per-route cors — which is
|
|
170
|
+
* the same silent-absence bug `config` was added to fix, just narrowed
|
|
171
|
+
* to the resources most likely to have it.
|
|
172
|
+
*
|
|
173
|
+
* Arc's derived keys win on collision: they come from the declared
|
|
174
|
+
* `route.rateLimit` / `route.cors` / `extensions` fields, so letting a
|
|
175
|
+
* hand-written `config.rateLimit` override them would give one setting
|
|
176
|
+
* two sources that can disagree.
|
|
177
|
+
*/
|
|
178
|
+
...mergeRouteConfig(route.config, buildRouteConfig(route.rateLimit !== void 0 ? buildRateLimitConfig(route.rateLimit) : rateLimitConfig, extensions, route.cors))
|
|
148
179
|
}, {
|
|
149
180
|
resourceName,
|
|
150
181
|
op: opName
|
|
@@ -305,6 +336,77 @@ function createPermissionMiddleware(permission, resourceName, action) {
|
|
|
305
336
|
return buildCrudPermissionMw(permission, resourceName, action);
|
|
306
337
|
}
|
|
307
338
|
//#endregion
|
|
339
|
+
//#region src/core/defineResource/writeVerbs.ts
|
|
340
|
+
/**
|
|
341
|
+
* Phase 4b — write-verb registration policy.
|
|
342
|
+
*
|
|
343
|
+
* A declared command that never executes is the defect `writes` exists to
|
|
344
|
+
* close, one layer up: the resource READS as though the kernel's guarded verb
|
|
345
|
+
* owns the slot while generic CRUD serves the route. So reachability is
|
|
346
|
+
* enforced at REGISTRATION, fail-loud (same posture as `threadQueryParser`):
|
|
347
|
+
*
|
|
348
|
+
* 1. the controller must DISPATCH `_writes` — proven by a prototype
|
|
349
|
+
* capability mark, not by a duck-typed `configure()`, which shows an
|
|
350
|
+
* options channel exists and not that anything reads `writes` from it;
|
|
351
|
+
* 2. no declared verb's slot may be OVERRIDDEN — route dispatch calls the
|
|
352
|
+
* override, so the verb behind it is dead code. Holds even for overrides
|
|
353
|
+
* that delegate to `super`: unprovable statically, so the combination is
|
|
354
|
+
* forbidden. Wrap behaviour with `hooks` instead.
|
|
355
|
+
*
|
|
356
|
+
* Config-SHAPE validation (unknown keys, non-function entries, disabled slots)
|
|
357
|
+
* needs no controller and lives in `validateResourceConfig`.
|
|
358
|
+
*/
|
|
359
|
+
/**
|
|
360
|
+
* The write slots, as a runtime list. `MUTATION_OPERATIONS` is the canonical
|
|
361
|
+
* constant; `satisfies` pins it to the `WriteVerbKey` type so the two cannot
|
|
362
|
+
* drift without a compile error.
|
|
363
|
+
*/
|
|
364
|
+
const WRITE_VERBS = MUTATION_OPERATIONS;
|
|
365
|
+
/** The verbs a resource actually declared (function-valued entries only). */
|
|
366
|
+
function declaredWriteVerbs(writes) {
|
|
367
|
+
if (!writes) return [];
|
|
368
|
+
return WRITE_VERBS.filter((op) => typeof writes[op] === "function");
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Boot-fatal reachability check for declared write verbs on a HOST-SUPPLIED
|
|
372
|
+
* controller. Auto-built controllers never call this — they are capable and
|
|
373
|
+
* un-overridden by construction.
|
|
374
|
+
*
|
|
375
|
+
* Throws before any option threading, so a rejected resource has no partial
|
|
376
|
+
* side effects to reason about.
|
|
377
|
+
*/
|
|
378
|
+
function enforceWriteVerbReachability(controller, resolvedConfig) {
|
|
379
|
+
const declared = declaredWriteVerbs(resolvedConfig.writes);
|
|
380
|
+
if (declared.length === 0) return;
|
|
381
|
+
if (typeof controller !== "object" || controller === null || !isWriteVerbCapable(controller)) throw new Error(`Resource "${resolvedConfig.name}" declares write verb(s) [${declared.join(", ")}] but its controller is not built on arc's write pipeline, so the verbs would be silently dropped and generic CRUD would serve those routes — the exact bypass \`writes\` exists to close. Extend \`BaseController\` / \`BaseCrudController\` (their write methods dispatch declared verbs), or omit \`controller\` and let arc auto-build one, or move the commands into the controller itself and remove \`writes\`.`);
|
|
382
|
+
const collisions = detectOverriddenWriteSlots(controller, declared);
|
|
383
|
+
if (collisions.length > 0) throw new Error(`Resource "${resolvedConfig.name}" declares write verb(s) [${collisions.join(", ")}] AND overrides the controller method(s) for the same slot(s). Route dispatch calls the override, so the declared verb can never execute — it is dead code that reads as if it guards the route. Keep exactly one owner per slot: remove the override and let the verb own persistence (wrap request-level behaviour with \`hooks\` — before/around/after run around the verb), or remove the \`writes\` entry and let the override own the whole pipeline.`);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Warn when a controller OVERRIDES a write method while the resource declares
|
|
387
|
+
* field rules only arc's pipeline enforces.
|
|
388
|
+
*
|
|
389
|
+
* Measured: a resource with eight `systemManaged` fields overrode `update` to
|
|
390
|
+
* reach its kernel's guarded verb; `PATCH` then wrote `status: "posted"` and a
|
|
391
|
+
* forged `number` straight through, answering 200. The override was added to
|
|
392
|
+
* close one hole and opened a wider one.
|
|
393
|
+
*
|
|
394
|
+
* Scoped so it stays worth reading: write-verb-capable controllers only,
|
|
395
|
+
* BODY-BEARING ops only (`delete` carries no body for the sanitizer), and only
|
|
396
|
+
* when protection is actually declared. A slot WITH a verb never reaches here —
|
|
397
|
+
* `enforceWriteVerbReachability` already threw.
|
|
398
|
+
*/
|
|
399
|
+
function warnOnWriteMethodOverride(controller, resolvedConfig) {
|
|
400
|
+
if (typeof controller !== "object" || controller === null || !isWriteVerbCapable(controller)) return;
|
|
401
|
+
const fieldRules = resolvedConfig.schemaOptions?.fieldRules;
|
|
402
|
+
const protectedFields = fieldRules ? Object.keys(fieldRules) : [];
|
|
403
|
+
const hasFieldPermissions = resolvedConfig.fields !== void 0 && Object.keys(resolvedConfig.fields).length > 0;
|
|
404
|
+
if (protectedFields.length === 0 && !hasFieldPermissions) return;
|
|
405
|
+
const overridden = detectOverriddenWriteSlots(controller, ["create", "update"]);
|
|
406
|
+
if (overridden.length === 0) return;
|
|
407
|
+
arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" overrides controller method(s) [${overridden.join(", ")}] AND declares field protection (${protectedFields.length > 0 ? `fieldRules: ${protectedFields.join(", ")}` : "fields"}). An override replaces arc's entire write pipeline — body sanitization against those field rules, tenant injection, createdBy/updatedBy stamping and the before/around/after hooks all stop running for that op, so the declared protection is NOT enforced. If the override exists to reach a domain command, declare it as a write verb instead (\`writes: { ${overridden[0]}: (…) => engine.<verb>(…) } \`) — arc keeps the pipeline and calls your command in place of the repository. If the override genuinely needs to replace the pipeline, call \`this.bodySanitizer.sanitize(body, op, req, ctx)\` inside it.`);
|
|
408
|
+
}
|
|
409
|
+
//#endregion
|
|
308
410
|
//#region src/core/defineResource/controller.ts
|
|
309
411
|
/**
|
|
310
412
|
* Phase 4 — pick (or auto-create) the resource's controller.
|
|
@@ -324,17 +426,33 @@ function createPermissionMiddleware(permission, resourceName, action) {
|
|
|
324
426
|
* lists the dropped options, and points at the canonical fix. All
|
|
325
427
|
* warns honour `ARC_SUPPRESS_WARNINGS=1` via `arcLog()`.
|
|
326
428
|
*/
|
|
429
|
+
/** CRUD routes refused over a read-only repository. */
|
|
430
|
+
const READ_ONLY_REFUSED_ROUTES = [
|
|
431
|
+
"create",
|
|
432
|
+
"update",
|
|
433
|
+
"delete"
|
|
434
|
+
];
|
|
327
435
|
/**
|
|
328
436
|
* Resolve the controller for the resource. See module docstring for
|
|
329
437
|
* branch semantics.
|
|
330
438
|
*/
|
|
331
439
|
function resolveOrAutoCreateController(resolvedConfig, adapter, repository, hasCrudRoutes) {
|
|
332
440
|
const userController = resolvedConfig.controller;
|
|
441
|
+
if (resolvedConfig.transactional === true) {
|
|
442
|
+
if (typeof repository?.withTransaction !== "function") throw new Error(`Resource "${resolvedConfig.name}" declares \`transactional: true\` but its repository has no \`withTransaction\` (capability \`transactions: false\` — D1, standalone Mongo). Wire a transactional kit, or remove the flag; arc will not silently drop a requested transaction envelope.`);
|
|
443
|
+
}
|
|
444
|
+
if (repository && isReadOnlyRepo(repository)) {
|
|
445
|
+
const disabled = new Set(resolvedConfig.disabledRoutes ?? []);
|
|
446
|
+
const enabledWrites = READ_ONLY_REFUSED_ROUTES.filter((op) => !resolvedConfig.disableDefaultRoutes && !disabled.has(op));
|
|
447
|
+
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.`);
|
|
448
|
+
}
|
|
333
449
|
if (userController) {
|
|
334
450
|
threadQueryParser(userController, resolvedConfig);
|
|
451
|
+
enforceWriteVerbReachability(userController, resolvedConfig);
|
|
335
452
|
threadConfigureLifecycle(userController, resolvedConfig, adapter);
|
|
336
453
|
warnOnDroppedAuthorOptions(resolvedConfig);
|
|
337
454
|
warnOnDroppedPresetOptions(resolvedConfig);
|
|
455
|
+
warnOnWriteMethodOverride(userController, resolvedConfig);
|
|
338
456
|
return userController;
|
|
339
457
|
}
|
|
340
458
|
if (!hasCrudRoutes || !repository) return void 0;
|
|
@@ -370,7 +488,16 @@ function threadConfigureLifecycle(controller, resolvedConfig, adapter) {
|
|
|
370
488
|
if (resolvedConfig.defaultSort !== void 0 && wasDeclared("defaultSort")) opts.defaultSort = resolvedConfig.defaultSort;
|
|
371
489
|
if (resolvedConfig.cache !== void 0 && wasDeclared("cache")) opts.cache = resolvedConfig.cache;
|
|
372
490
|
if (resolvedConfig.onFieldWriteDenied !== void 0 && wasDeclared("onFieldWriteDenied")) opts.onFieldWriteDenied = resolvedConfig.onFieldWriteDenied;
|
|
491
|
+
if (resolvedConfig.onImmutableWrite !== void 0 && wasDeclared("onImmutableWrite")) opts.onImmutableWrite = resolvedConfig.onImmutableWrite;
|
|
373
492
|
if (resolvedConfig.queryParser !== void 0 && wasDeclared("queryParser")) opts.queryParser = resolvedConfig.queryParser;
|
|
493
|
+
/**
|
|
494
|
+
* NOT gated on `_declaredKeys`: `writes` is never inferred or injected by a
|
|
495
|
+
* preset, so its presence IS the declaration. Gating it would mean a
|
|
496
|
+
* resource that declares write verbs alongside its own controller silently
|
|
497
|
+
* kept generic CRUD — the exact bypass the seam exists to close.
|
|
498
|
+
*/
|
|
499
|
+
if (resolvedConfig.writes !== void 0) opts.writes = resolvedConfig.writes;
|
|
500
|
+
if (resolvedConfig.transactional !== void 0) opts.transactional = resolvedConfig.transactional;
|
|
374
501
|
if (adapter?.matchesFilter !== void 0) opts.matchesFilter = adapter.matchesFilter;
|
|
375
502
|
if (resolvedConfig._controllerOptions) opts.presetFields = {
|
|
376
503
|
slugField: resolvedConfig._controllerOptions.slugField,
|
|
@@ -420,6 +547,7 @@ function warnOnDroppedAuthorOptions(resolvedConfig) {
|
|
|
420
547
|
if (resolvedConfig.defaultSort !== void 0 && isDeclared("defaultSort")) dropped.push("defaultSort");
|
|
421
548
|
if (resolvedConfig.cache !== void 0 && isDeclared("cache")) dropped.push("cache");
|
|
422
549
|
if (resolvedConfig.onFieldWriteDenied !== void 0 && isDeclared("onFieldWriteDenied")) dropped.push("onFieldWriteDenied");
|
|
550
|
+
if (resolvedConfig.onImmutableWrite !== void 0 && isDeclared("onImmutableWrite")) dropped.push("onImmutableWrite");
|
|
423
551
|
if (dropped.length === 0) return;
|
|
424
552
|
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.`);
|
|
425
553
|
}
|
|
@@ -464,12 +592,34 @@ function buildBaseController(resolvedConfig, adapter, repository) {
|
|
|
464
592
|
matchesFilter: adapter?.matchesFilter,
|
|
465
593
|
cache: resolvedConfig.cache,
|
|
466
594
|
onFieldWriteDenied: resolvedConfig.onFieldWriteDenied,
|
|
595
|
+
onImmutableWrite: resolvedConfig.onImmutableWrite,
|
|
596
|
+
/**
|
|
597
|
+
* Domain commands for the write slots. Threaded here rather than
|
|
598
|
+
* subclassing: the pipeline stays arc's and the command stays the
|
|
599
|
+
* kernel's, so neither has to be re-implemented to reach the other.
|
|
600
|
+
*/
|
|
601
|
+
writes: resolvedConfig.writes,
|
|
602
|
+
transactional: resolvedConfig.transactional === true,
|
|
467
603
|
presetFields: resolvedConfig._controllerOptions ? {
|
|
468
604
|
slugField: resolvedConfig._controllerOptions.slugField,
|
|
469
605
|
parentField: resolvedConfig._controllerOptions.parentField
|
|
470
606
|
} : void 0
|
|
471
607
|
});
|
|
472
608
|
}
|
|
609
|
+
/**
|
|
610
|
+
* The DEFERRED half of the `transactional: true` gate — run at resource
|
|
611
|
+
* registration, after `beforeBoot()` has had its chance to connect.
|
|
612
|
+
*
|
|
613
|
+
* Fail-closed: anything other than a confirmed `true` refuses the boot. A
|
|
614
|
+
* repository with NO capability descriptor (hand-written, custom kit) passes
|
|
615
|
+
* — it never promised one, and the define-time presence check already covered
|
|
616
|
+
* the only signal it offers.
|
|
617
|
+
*/
|
|
618
|
+
function assertTransactionCapability(repository, resourceName) {
|
|
619
|
+
const capabilities = repository?.capabilities;
|
|
620
|
+
if (!capabilities || capabilities.transactions === true) return;
|
|
621
|
+
throw new Error(`Resource "${resourceName}" declares \`transactional: true\` but its repository reports \`capabilities.transactions: ${String(capabilities.transactions)}\` — this deployment cannot run transactions (standalone MongoDB, D1, or a topology arc could not confirm). \`withTransaction\` exists but every call would fail at BEGIN. Point at a replica set / transactional backend, or remove the flag. Checked at REGISTRATION, so a connection opened in \`beforeBoot()\` is already live by now.`);
|
|
622
|
+
}
|
|
473
623
|
//#endregion
|
|
474
624
|
//#region src/core/defineResource/hooks.ts
|
|
475
625
|
/**
|
|
@@ -970,7 +1120,7 @@ function collectUngatedCrudDiagnostics(config, strict = false) {
|
|
|
970
1120
|
code: "crud-public-by-omission",
|
|
971
1121
|
message: `[Arc] Resource '${config.name}' mounts ${ungated.length} CRUD route(s) with no permission gate: ${ungated.join(", ")}.\n` + (severity === "error" ? "strictPermissions is on and an ungated WRITE is fatal. State intent explicitly:\n" : "An omitted permission mounts the route WITHOUT auth. State intent explicitly:\n") + ` permissions: permissions.fullPublic() // intentionally public
|
|
972
1122
|
permissions: permissions.authenticated() // require auth on every op
|
|
973
|
-
permissions: { ${ungated[0]}: requireAuth(), ... } // per-operation`
|
|
1123
|
+
permissions: { ${ungated[0]}: requireAuth(), ... } // per-operation` + (severity === "warn" ? "\nRecommended in production: ARC_STRICT_PERMISSIONS=true (or `strictPermissions: true` on the resource) makes an ungated WRITE fail the boot instead of shipping. This is the arc v3 default." : "")
|
|
974
1124
|
}];
|
|
975
1125
|
}
|
|
976
1126
|
//#endregion
|
|
@@ -1214,6 +1364,7 @@ function buildResourcePlugin(resource) {
|
|
|
1214
1364
|
const sharedRoot = fastify.server;
|
|
1215
1365
|
const isFirstMount = !resource._sharedStateRegisteredOn.has(sharedRoot);
|
|
1216
1366
|
if (isFirstMount) resource._sharedStateRegisteredOn.add(sharedRoot);
|
|
1367
|
+
if (isFirstMount && resource._deferredChecks?.length) for (const check of resource._deferredChecks) check();
|
|
1217
1368
|
if (isFirstMount && resource._diagnostics?.length) for (const diagnostic of resource._diagnostics) {
|
|
1218
1369
|
const level = diagnostic.severity === "info" ? "info" : diagnostic.severity === "error" ? "error" : "warn";
|
|
1219
1370
|
fastify.log?.[level]?.(diagnostic.message);
|
|
@@ -1278,7 +1429,7 @@ function buildResourcePlugin(resource) {
|
|
|
1278
1429
|
extensions: resource.extensions
|
|
1279
1430
|
});
|
|
1280
1431
|
if (resource.actions && Object.keys(resource.actions).length > 0) {
|
|
1281
|
-
const { createActionRouter } = await import("./createActionRouter-
|
|
1432
|
+
const { createActionRouter } = await import("./createActionRouter-DVpoA0do.mjs").then((n) => n.n);
|
|
1282
1433
|
createActionRouter(typedInstance, {
|
|
1283
1434
|
...normalizeActionsToRouterConfig(resource.actions, resource.actionPermissions, resource.tag, resource.permissions, resource.name, typedInstance.log),
|
|
1284
1435
|
resourceName: resource.name,
|
|
@@ -1293,7 +1444,7 @@ function buildResourcePlugin(resource) {
|
|
|
1293
1444
|
});
|
|
1294
1445
|
}
|
|
1295
1446
|
if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
|
|
1296
|
-
const [{ createAggregationRouter }, { adapterSupportsAggregate, ArcAggregationConfigError }] = await Promise.all([import("./createAggregationRouter-
|
|
1447
|
+
const [{ createAggregationRouter }, { adapterSupportsAggregate, ArcAggregationConfigError }] = await Promise.all([import("./createAggregationRouter-B_B48JZ-.mjs"), import("./validate-CdQlz52F.mjs").then((n) => n.r)]);
|
|
1297
1448
|
const repoForAgg = resource.controller?.repository ?? resource.repository;
|
|
1298
1449
|
if (!adapterSupportsAggregate(repoForAgg)) {
|
|
1299
1450
|
const undispatchable = Object.entries(resource.aggregations).filter(([, cfg]) => !cfg.materialized).map(([name]) => name);
|
|
@@ -1406,6 +1557,22 @@ var ResourceDefinition = class {
|
|
|
1406
1557
|
*/
|
|
1407
1558
|
_diagnostics;
|
|
1408
1559
|
/**
|
|
1560
|
+
* Boot-time assertions that CANNOT be settled at define time because
|
|
1561
|
+
* they read state a connection establishes.
|
|
1562
|
+
*
|
|
1563
|
+
* `defineResource()` frequently runs at module-import time — before
|
|
1564
|
+
* `beforeBoot()` opens the database. A repository's capability
|
|
1565
|
+
* descriptor is therefore not yet authoritative then: mongokit
|
|
1566
|
+
* deliberately reports `transactions: false` for an unresolved topology
|
|
1567
|
+
* (fail-closed), so asserting at define time would reject a correctly
|
|
1568
|
+
* configured app for the crime of importing its resources early.
|
|
1569
|
+
*
|
|
1570
|
+
* `buildResourcePlugin` runs these on first mount — lifecycle slot 5,
|
|
1571
|
+
* after `beforeBoot` (0) and module resolution (0.5) — where the same
|
|
1572
|
+
* fail-closed rule finally means what it says.
|
|
1573
|
+
*/
|
|
1574
|
+
_deferredChecks;
|
|
1575
|
+
/**
|
|
1409
1576
|
* Public view of {@link _diagnostics} for programmatic consumption.
|
|
1410
1577
|
*
|
|
1411
1578
|
* Returns the collected define-time diagnostics for this resource.
|
|
@@ -1551,6 +1718,7 @@ var ResourceDefinition = class {
|
|
|
1551
1718
|
summary: r.summary,
|
|
1552
1719
|
description: r.description,
|
|
1553
1720
|
permissions: r.permissions,
|
|
1721
|
+
capability: r.capability,
|
|
1554
1722
|
raw: r.rawHandler !== void 0,
|
|
1555
1723
|
schema: r.schema
|
|
1556
1724
|
})),
|
|
@@ -1793,6 +1961,41 @@ function validateResourceConfig(config, options = {}) {
|
|
|
1793
1961
|
message: "Resource has no adapter and no routes",
|
|
1794
1962
|
suggestion: "Provide either adapter for CRUD or routes for custom logic"
|
|
1795
1963
|
});
|
|
1964
|
+
if (config.writes) {
|
|
1965
|
+
const writeVerbSlots = MUTATION_OPERATIONS;
|
|
1966
|
+
const entries = Object.entries(config.writes);
|
|
1967
|
+
const unknownKeys = entries.map(([key]) => key).filter((key) => !writeVerbSlots.includes(key));
|
|
1968
|
+
if (unknownKeys.length > 0) errors.push({
|
|
1969
|
+
field: "writes",
|
|
1970
|
+
message: `Unknown write verb slot(s) [${unknownKeys.join(", ")}] — only [${writeVerbSlots.join(", ")}] exist. An unknown slot is silently never called, so the command it names guards nothing`,
|
|
1971
|
+
suggestion: "Rename to a real slot (create/update/delete), or move the behaviour into a custom route / action"
|
|
1972
|
+
});
|
|
1973
|
+
const nonFunction = entries.filter(([key, value]) => writeVerbSlots.includes(key) && typeof value !== "function");
|
|
1974
|
+
for (const [key, value] of nonFunction) errors.push({
|
|
1975
|
+
field: `writes.${key}`,
|
|
1976
|
+
message: `Write verb "${key}" is declared but is ${value === void 0 ? "undefined" : `a ${typeof value}`}, not a function — generic repository CRUD would silently serve that slot instead of the command the config claims guards it`,
|
|
1977
|
+
suggestion: "Pass the command function itself (e.g. `update: (id, data, ctx) => engine.updateDraft(id, data)`), or delete the entry"
|
|
1978
|
+
});
|
|
1979
|
+
const declared = writeVerbSlots.filter((op) => typeof config.writes?.[op] === "function");
|
|
1980
|
+
if (entries.length === 0) warnings.push({
|
|
1981
|
+
field: "writes",
|
|
1982
|
+
message: "`writes` is declared but contains no verb functions",
|
|
1983
|
+
suggestion: "Remove the empty `writes` block, or declare create/update/delete"
|
|
1984
|
+
});
|
|
1985
|
+
if (declared.length > 0 && config.disableDefaultRoutes) errors.push({
|
|
1986
|
+
field: "writes",
|
|
1987
|
+
message: `Resource declares write verb(s) [${declared.join(", ")}] but \`disableDefaultRoutes: true\` means no CRUD slot will call them`,
|
|
1988
|
+
suggestion: "Drop `disableDefaultRoutes` so the slots mount, or move the command into a custom route / action and remove `writes`"
|
|
1989
|
+
});
|
|
1990
|
+
else if (declared.length > 0) {
|
|
1991
|
+
const notMounted = declared.filter((op) => disabledRoutes.has(op));
|
|
1992
|
+
if (notMounted.length > 0) errors.push({
|
|
1993
|
+
field: "writes",
|
|
1994
|
+
message: `Write verb(s) [${notMounted.join(", ")}] are declared but those CRUD slots are disabled, so the verbs can never run`,
|
|
1995
|
+
suggestion: `Remove [${notMounted.join(", ")}] from \`disabledRoutes\`, or delete the matching \`writes\` entr(ies) — a command nothing calls is decoration`
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1796
1999
|
if (config.controller && !options.skipControllerCheck && !config.disableDefaultRoutes) {
|
|
1797
2000
|
const ctrl = config.controller;
|
|
1798
2001
|
const requiredMethods = CRUD_OPERATIONS;
|
|
@@ -2074,7 +2277,12 @@ function validateDefineResourceConfig(config) {
|
|
|
2074
2277
|
validatePermissionsShape(config);
|
|
2075
2278
|
validateCustomRoutePermissions(config);
|
|
2076
2279
|
validateActionsShape(config);
|
|
2077
|
-
|
|
2280
|
+
validateRouteCapabilities(config);
|
|
2281
|
+
return [
|
|
2282
|
+
...collectRedundantFieldRuleDiagnostics(config),
|
|
2283
|
+
...collectReservedFilterNameDiagnostics(config),
|
|
2284
|
+
...collectPaginationCapDiagnostics(config)
|
|
2285
|
+
];
|
|
2078
2286
|
}
|
|
2079
2287
|
/**
|
|
2080
2288
|
* Query-string param names arc's `QueryParser` reserves for
|
|
@@ -2111,6 +2319,37 @@ function collectReservedFilterNameDiagnostics(config) {
|
|
|
2111
2319
|
message: `[Arc] Resource '${config.name}': filterable field(s) [${collisions.join(", ")}] collide with reserved query params (page, limit, cursor, after, sort, search, select, populate). The query parser consumes these names for pagination/projection, so filtering by them via the query string — and via the MCP list tool — will NOT work. Rename the field(s), or drop them from \`filterableFields\` if the collision is intended.`
|
|
2112
2320
|
}];
|
|
2113
2321
|
}
|
|
2322
|
+
/**
|
|
2323
|
+
* Warn when the parser's page cap exceeds the repository's.
|
|
2324
|
+
*
|
|
2325
|
+
* Page size is capped in THREE independent places — the query parser, the
|
|
2326
|
+
* repository's pagination engine, and arc — and none of them can see the others.
|
|
2327
|
+
* The lowest silently wins, so a resource that declares 1000 and a repository
|
|
2328
|
+
* left at its default 100 serves 100 rows with a `200` and no signal anywhere.
|
|
2329
|
+
* That is not hypothetical: an account picker read 100 of 696 rows, filtered
|
|
2330
|
+
* that arbitrary slice client-side, and rendered "No accounts found".
|
|
2331
|
+
*
|
|
2332
|
+
* arc is the only layer that holds BOTH, so it is the only one that can notice.
|
|
2333
|
+
* A diagnostic rather than a throw: the mismatch is usually an unconfigured
|
|
2334
|
+
* default rather than a mistake, and a published framework must not refuse to
|
|
2335
|
+
* boot an app that works today — it just works less than the author intended.
|
|
2336
|
+
*
|
|
2337
|
+
* Read structurally, never by importing a kit: `repository._pagination.config`
|
|
2338
|
+
* is mongokit's shape, and arc stays database-agnostic (`check:boundaries`).
|
|
2339
|
+
* Any kit exposing the same shape gets the check for free; one that does not is
|
|
2340
|
+
* simply skipped.
|
|
2341
|
+
*/
|
|
2342
|
+
function collectPaginationCapDiagnostics(config) {
|
|
2343
|
+
const parserCap = config.queryParser?.maxLimit;
|
|
2344
|
+
if (typeof parserCap !== "number") return [];
|
|
2345
|
+
const repoCap = (config.adapter?.repository)?._pagination?.config?.maxLimit;
|
|
2346
|
+
if (typeof repoCap !== "number" || parserCap <= repoCap) return [];
|
|
2347
|
+
return [{
|
|
2348
|
+
severity: "warn",
|
|
2349
|
+
code: "pagination-cap-mismatch",
|
|
2350
|
+
message: `[Arc] Resource '${config.name}': the query parser allows ${parserCap} rows per page but the repository caps at ${repoCap}, so ${repoCap} wins and larger pages are truncated with a 200 and no error. Configure the repository's pagination \`maxLimit\` to ${parserCap} as well, or lower the parser's to match.`
|
|
2351
|
+
}];
|
|
2352
|
+
}
|
|
2114
2353
|
/** Permissions must be `PermissionCheck` functions, not arbitrary values. */
|
|
2115
2354
|
function validatePermissionsShape(config) {
|
|
2116
2355
|
if (!config.permissions) return;
|
|
@@ -2166,6 +2405,37 @@ function collectRedundantFieldRuleDiagnostics(config) {
|
|
|
2166
2405
|
return diagnostics;
|
|
2167
2406
|
}
|
|
2168
2407
|
/**
|
|
2408
|
+
* Route `capability` keys — must not collide with a CRUD slot, an action, an
|
|
2409
|
+
* aggregation, or another route on the same resource.
|
|
2410
|
+
*
|
|
2411
|
+
* At BOOT, not at introspection. `introspectRegistry` also refuses a collision,
|
|
2412
|
+
* but nothing inside arc calls it: it is a host API, and a host calls it from a
|
|
2413
|
+
* permission-matrix endpoint. So the only thing catching a duplicate key was a
|
|
2414
|
+
* 500 on that endpoint at REQUEST time — and because a UI reads its whole
|
|
2415
|
+
* permission map from there, one static config typo took every client's gates
|
|
2416
|
+
* out at once, in production, rather than failing the deploy.
|
|
2417
|
+
*
|
|
2418
|
+
* A duplicate `capability` is decidable from the config alone, so it belongs
|
|
2419
|
+
* with the other boot checks. The introspection-side throw stays as a backstop
|
|
2420
|
+
* for callers that build a registry entry by hand instead of via
|
|
2421
|
+
* `defineResource`.
|
|
2422
|
+
*/
|
|
2423
|
+
function validateRouteCapabilities(config) {
|
|
2424
|
+
const routes = config.routes;
|
|
2425
|
+
if (!routes?.length) return;
|
|
2426
|
+
const taken = /* @__PURE__ */ new Map();
|
|
2427
|
+
for (const op of Object.keys(config.permissions ?? {})) taken.set(op, "CRUD slot");
|
|
2428
|
+
for (const name of Object.keys(config.actions ?? {})) taken.set(`action:${name}`, "action");
|
|
2429
|
+
for (const name of Object.keys(config.aggregations ?? {})) taken.set(`agg:${name}`, "aggregation");
|
|
2430
|
+
for (const route of routes) {
|
|
2431
|
+
const key = route.capability;
|
|
2432
|
+
if (key === void 0) continue;
|
|
2433
|
+
const owner = taken.get(key);
|
|
2434
|
+
if (owner !== void 0) throw new Error(`[Arc] Resource '${config.name}': route ${route.method} ${route.path} declares capability '${key}', which is already published by a ${owner}. Two gates under one key means one answers for the other verb — rename the route's \`capability\`.`);
|
|
2435
|
+
taken.set(key, `route ${route.method} ${route.path}`);
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2169
2439
|
* Actions (v2.8) — name must not collide with CRUD ops; handler +
|
|
2170
2440
|
* permissions must have the right shapes. Fail at boot so production
|
|
2171
2441
|
* never ships a misconfigured action endpoint.
|
|
@@ -2211,6 +2481,10 @@ function defineResource(config) {
|
|
|
2211
2481
|
const registryMeta = resolveOpenApiSchemas(narrowedConfig);
|
|
2212
2482
|
if (registryMeta) resource._registryMeta = registryMeta;
|
|
2213
2483
|
}
|
|
2484
|
+
if (resolvedConfig.transactional === true) {
|
|
2485
|
+
const name = resolvedConfig.name;
|
|
2486
|
+
resource._deferredChecks = [() => assertTransactionCapability(repository, name)];
|
|
2487
|
+
}
|
|
2214
2488
|
if (diagnostics.length > 0) resource._diagnostics = diagnostics;
|
|
2215
2489
|
return resource;
|
|
2216
2490
|
}
|
package/dist/discovery/index.mjs
CHANGED
package/dist/docs/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { p as RegistryEntry } from "../index-
|
|
2
|
+
import { p as RegistryEntry } from "../index-BcR2bFZQ.mjs";
|
|
3
3
|
import { t as ExternalOpenApiPaths } from "../externalPaths-CmGOxOtD.mjs";
|
|
4
4
|
import { FastifyPluginAsync } from "fastify";
|
|
5
5
|
//#region src/docs/openapi/types.d.ts
|
package/dist/docs/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import { n as getUserRoles } from "../userHelpers-
|
|
3
|
-
import { n as openApiPlugin, r as openapi_default, t as buildOpenApiSpec } from "../openapi-
|
|
1
|
+
/*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
|
|
2
|
+
import { n as getUserRoles } from "../userHelpers-CWF5pbZD.mjs";
|
|
3
|
+
import { n as openApiPlugin, r as openapi_default, t as buildOpenApiSpec } from "../openapi-BN5cQaO2.mjs";
|
|
4
4
|
import fp from "fastify-plugin";
|
|
5
5
|
//#region src/docs/scalar.ts
|
|
6
6
|
const scalarPlugin = async (fastify, opts = {}) => {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import { t as __exportAll } from "./rolldown-runtime-
|
|
1
|
+
/*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
|
|
2
|
+
import { t as __exportAll } from "./rolldown-runtime-DNg-KNCk.mjs";
|
|
3
3
|
import { arcLog } from "./logger/index.mjs";
|
|
4
|
-
import { n as requireSingleHeaderValue } from "./headers-
|
|
5
|
-
import { n as getUserRoles } from "./userHelpers-
|
|
4
|
+
import { n as requireSingleHeaderValue } from "./headers-CveNO9gR.mjs";
|
|
5
|
+
import { n as getUserRoles } from "./userHelpers-CWF5pbZD.mjs";
|
|
6
6
|
import fp from "fastify-plugin";
|
|
7
7
|
//#region src/scope/elevation.ts
|
|
8
8
|
var elevation_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import { t as ArcError } from "../errors-
|
|
3
|
-
import { t as parseJsonBody } from "../jsonBody-
|
|
1
|
+
/*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
|
|
2
|
+
import { t as ArcError } from "../errors-Cd_yy7aW.mjs";
|
|
3
|
+
import { t as parseJsonBody } from "../jsonBody-CfGH-GxO.mjs";
|
|
4
4
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
5
5
|
import fp from "fastify-plugin";
|
|
6
6
|
//#region src/encryption/fieldCipher.ts
|
|
@@ -1,9 +1,35 @@
|
|
|
1
|
-
/*! @classytic/arc v2.
|
|
2
|
-
import { t as __exportAll } from "./rolldown-runtime-
|
|
3
|
-
import { m as statusToArcCode, p as isArcError } from "./errors-
|
|
1
|
+
/*! @classytic/arc v2.34.0 | MIT | https://classytic.com */
|
|
2
|
+
import { t as __exportAll } from "./rolldown-runtime-DNg-KNCk.mjs";
|
|
3
|
+
import { m as statusToArcCode, p as isArcError } from "./errors-Cd_yy7aW.mjs";
|
|
4
4
|
import { isHttpError, toErrorContract } from "@classytic/repo-core/errors";
|
|
5
|
+
import { isProductionEnv } from "@classytic/primitives/environment";
|
|
5
6
|
import fp from "fastify-plugin";
|
|
6
7
|
//#region src/plugins/errorHandler.ts
|
|
8
|
+
/**
|
|
9
|
+
* Global error handler — converts every thrown error into the canonical
|
|
10
|
+
* `ErrorContract` wire shape from `@classytic/repo-core/errors`.
|
|
11
|
+
*
|
|
12
|
+
* Wire shape (no envelope; HTTP status discriminates success vs error):
|
|
13
|
+
* ```
|
|
14
|
+
* { code, message, status, details?, meta?, correlationId? }
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Throw anywhere — controller, hook, middleware — and the response is
|
|
18
|
+
* automatically converted. `ArcError` instances and any `HttpError`-shaped
|
|
19
|
+
* throw (mongokit, sqlitekit, streamline, prismakit) flow through the
|
|
20
|
+
* canonical converter unchanged.
|
|
21
|
+
*
|
|
22
|
+
* Custom domain error classes register via `errorMappers` (instanceof) or
|
|
23
|
+
* `errorMap` (by name). Driver-specific duplicate-key violations
|
|
24
|
+
* (Mongo 11000, Prisma P2002, Postgres 23505, MySQL 1062, SQLite
|
|
25
|
+
* UNIQUE) collapse to a 409 with `code: 'arc.conflict'`.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* await fastify.register(errorHandlerPlugin, {
|
|
29
|
+
* includeStack: process.env.NODE_ENV !== 'production',
|
|
30
|
+
* onError: (err) => Sentry.captureException(err),
|
|
31
|
+
* });
|
|
32
|
+
*/
|
|
7
33
|
var errorHandler_exports = /* @__PURE__ */ __exportAll({
|
|
8
34
|
defaultIsDuplicateKeyError: () => defaultIsDuplicateKeyError,
|
|
9
35
|
errorHandlerPlugin: () => errorHandlerPlugin
|
|
@@ -94,8 +120,19 @@ function mongooseValidationDetails(errors) {
|
|
|
94
120
|
}));
|
|
95
121
|
}
|
|
96
122
|
async function errorHandlerPluginFn(fastify, options = {}) {
|
|
97
|
-
|
|
98
|
-
|
|
123
|
+
/**
|
|
124
|
+
* The FALLBACK for a plugin registered directly (`fastify.register(errorHandlerPlugin)`), which
|
|
125
|
+
* has no access to `createApp`'s resolved `preset`. Composed through `createApp`, both switches
|
|
126
|
+
* arrive already derived from the preset and this default never applies — see
|
|
127
|
+
* `factory/registerAuth.ts#registerErrorHandler`.
|
|
128
|
+
*
|
|
129
|
+
* Via the shared classifier, NOT `process.env.NODE_ENV === "production"`. That comparison
|
|
130
|
+
* recognises only the long spelling, so a deployment using `NODE_ENV=prod` got BOTH switches
|
|
131
|
+
* true: stack traces AND the raw thrown message of a 500 returned to clients, in production,
|
|
132
|
+
* with nothing to indicate it.
|
|
133
|
+
*/
|
|
134
|
+
const productionEnv = isProductionEnv(process.env.NODE_ENV);
|
|
135
|
+
const { includeStack = !productionEnv, exposeInternalMessages = !productionEnv, onError, errorMap = {}, errorMappers = [], isDuplicateKeyError = defaultIsDuplicateKeyError } = options;
|
|
99
136
|
fastify.setErrorHandler(async (error, request, reply) => {
|
|
100
137
|
if (onError) try {
|
|
101
138
|
await onError(error, request);
|
|
@@ -17,11 +17,22 @@ interface ErrorMapper<T extends Error = Error> {
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
interface ErrorHandlerOptions {
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Include `meta.stack` on the wire.
|
|
22
|
+
*
|
|
23
|
+
* Under `createApp`, defaults from the resolved `preset` (falling back to the
|
|
24
|
+
* environment via `isProductionEnv`, which knows both `production` and `prod`).
|
|
25
|
+
* Registering this plugin DIRECTLY skips that, and the default is the
|
|
26
|
+
* environment alone.
|
|
27
|
+
*/
|
|
21
28
|
includeStack?: boolean;
|
|
22
29
|
/**
|
|
23
|
-
* Ship the raw `error.message` of UNCLASSIFIED 500s on the wire
|
|
24
|
-
*
|
|
30
|
+
* Ship the raw `error.message` of UNCLASSIFIED 500s on the wire.
|
|
31
|
+
*
|
|
32
|
+
* Same derivation as {@link includeStack}: the `preset` under `createApp`,
|
|
33
|
+
* otherwise the environment. Both are host keys, so stating one no longer
|
|
34
|
+
* silently resets the other — `registerErrorHandler` MERGES them over the
|
|
35
|
+
* derived pair instead of replacing it.
|
|
25
36
|
*
|
|
26
37
|
* Only the `arc.internal_error` fallback is affected — an unclassified
|
|
27
38
|
* throw carries whatever some driver/library put in the message (connection
|