@classytic/arc 2.11.3 → 2.13.1
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 +27 -18
- package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
- package/dist/EventTransport-CT_52aWU.d.mts +34 -0
- package/dist/EventTransport-DLWoUMHy.mjs +103 -0
- package/dist/{QueryCache-DOBNHBE0.d.mts → QueryCache-D41bfdBB.d.mts} +1 -1
- package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +1 -1
- package/dist/auth/audit.d.mts +199 -0
- package/dist/auth/audit.mjs +288 -0
- package/dist/auth/index.d.mts +5 -5
- package/dist/auth/index.mjs +117 -191
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
- package/dist/buildHandler-olo-gt94.mjs +610 -0
- package/dist/cache/index.d.mts +3 -3
- package/dist/cache/index.mjs +3 -3
- package/dist/cli/commands/describe.d.mts +89 -13
- package/dist/cli/commands/describe.mjs +56 -2
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +147 -48
- package/dist/cli/commands/init.d.mts +13 -0
- package/dist/cli/commands/init.mjs +237 -112
- package/dist/cli/commands/introspect.mjs +8 -1
- package/dist/context/index.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +5 -5
- package/dist/core-D72ia0EH.mjs +1399 -0
- package/dist/{createActionRouter-u3ql2EDo.mjs → createActionRouter-CEvzKcy8.mjs} +7 -20
- package/dist/createAggregationRouter-CyecOxnO.mjs +114 -0
- package/dist/{createApp-BFxtdKy6.mjs → createApp-XX2-N0Yd.mjs} +31 -27
- package/dist/defineEvent-D5h7EvAx.mjs +188 -0
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
- package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
- package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
- package/dist/errors-j4aJm1Wg.mjs +184 -0
- package/dist/{eventPlugin-KrFIQ097.mjs → eventPlugin-CaKTYkYM.mjs} +35 -137
- package/dist/{eventPlugin-CUNjYYRY.d.mts → eventPlugin-qXpqTebY.d.mts} +57 -7
- package/dist/events/index.d.mts +164 -5
- package/dist/events/index.mjs +133 -209
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +204 -31
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +2 -2
- package/dist/factory/index.mjs +2 -2
- package/dist/{fields-C8Y0XLAu.d.mts → fields-COhcH3fk.d.mts} +23 -2
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +3 -3
- package/dist/idempotency/index.mjs +1 -20
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +1 -1
- package/dist/{index-BYCqHCVu.d.mts → index-BTqLEvhu.d.mts} +164 -4
- package/dist/{index-6u4_Gg6G.d.mts → index-BtW7qYwa.d.mts} +661 -281
- package/dist/{index-BdXnTPRj.d.mts → index-Ds61mrJE.d.mts} +50 -4
- package/dist/{index-DdQ3O9Pg.d.mts → index-Dz5IKsrE.d.mts} +360 -219
- package/dist/index.d.mts +6 -7
- package/dist/index.mjs +9 -10
- package/dist/integrations/event-gateway.d.mts +2 -2
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/streamline.d.mts +60 -11
- package/dist/integrations/streamline.mjs +75 -85
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +1 -1
- package/dist/integrations/websocket.mjs +2 -8
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +2 -2
- package/dist/migrations/index.d.mts +23 -3
- package/dist/migrations/index.mjs +0 -7
- package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
- package/dist/{openapi-BGUn7Ki1.mjs → openapi-CiOMVW1p.mjs} +143 -13
- package/dist/org/index.d.mts +2 -2
- package/dist/org/index.mjs +1 -1
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +3 -3
- package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
- package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +1 -1
- package/dist/plugins/index.d.mts +18 -33
- package/dist/plugins/index.mjs +33 -13
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +5 -5
- package/dist/presets/filesUpload.mjs +6 -9
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +2 -2
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +6 -8
- package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
- package/dist/{queryCachePlugin-BUXBSm4F.d.mts → queryCachePlugin-CqMdLI2-.d.mts} +2 -2
- package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
- package/dist/{redis-Cm1gnRDf.d.mts → redis-DiMkdHEl.d.mts} +1 -1
- package/dist/redis-stream-D6HzR1Z_.d.mts +232 -0
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
- package/dist/{resourceToTools-ByZpgjeH.mjs → resourceToTools-C5coh64w.mjs} +224 -71
- package/dist/{routerShared-BqLRb5l7.mjs → routerShared-D6_fEGHh.mjs} +40 -36
- package/dist/{schemaIR-BlG9bY7v.mjs → schemaIR-7Vl611Qs.mjs} +1 -1
- package/dist/schemas/index.d.mts +100 -30
- package/dist/schemas/index.mjs +86 -29
- package/dist/scim/index.d.mts +264 -0
- package/dist/scim/index.mjs +963 -0
- package/dist/scope/index.d.mts +3 -3
- package/dist/scope/index.mjs +4 -4
- package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
- package/dist/{store-helpers-BhrzxvyQ.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
- package/dist/testing/index.d.mts +2 -8
- package/dist/testing/index.mjs +16 -24
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-BH7dEGvU.d.mts → types-BvqwCCSx.d.mts} +77 -29
- package/dist/{types-tgR4Pt8F.d.mts → types-CTYvcwHe.d.mts} +195 -1
- package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
- package/dist/{types-9beEMe25.d.mts → types-DQHFc8PM.d.mts} +1 -1
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.mjs +5 -5
- package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
- package/dist/{versioning-M9lNLhO8.d.mts → versioning-DTTvc80y.d.mts} +1 -1
- package/package.json +24 -34
- package/skills/arc/SKILL.md +521 -785
- package/skills/arc/references/agent-auth.md +238 -0
- package/skills/arc/references/api-reference.md +187 -0
- package/skills/arc/references/auth.md +354 -7
- package/skills/arc/references/enterprise-auth.md +94 -0
- package/skills/arc/references/events.md +8 -6
- package/skills/arc/references/mcp.md +2 -2
- package/skills/arc/references/multi-tenancy.md +11 -2
- package/skills/arc/references/production.md +10 -9
- package/skills/arc/references/scim.md +247 -0
- package/skills/arc/references/testing.md +1 -1
- package/skills/arc-code-review/SKILL.md +141 -0
- package/skills/arc-code-review/references/anti-patterns.md +911 -0
- package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
- package/skills/arc-code-review/references/migration-recipes.md +700 -0
- package/skills/arc-code-review/references/mongokit-migration.md +386 -0
- package/skills/arc-code-review/references/scaffolding.md +230 -0
- package/skills/arc-code-review/references/severity.md +127 -0
- package/dist/EventTransport-CfVEGaEl.d.mts +0 -293
- package/dist/adapters/index.d.mts +0 -3
- package/dist/adapters/index.mjs +0 -2
- package/dist/adapters-D0tT2Tyo.mjs +0 -949
- package/dist/auth/mongoose.d.mts +0 -191
- package/dist/auth/mongoose.mjs +0 -73
- package/dist/core-DnUsRpuX.mjs +0 -1049
- package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
- package/dist/errorHandler-Co3lnVmJ.d.mts +0 -114
- package/dist/errors-D5c-5BJL.mjs +0 -232
- package/dist/index-BbMrcvGp.d.mts +0 -362
- package/dist/redis-stream-CM8TXTix.d.mts +0 -110
- /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
- /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
- /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
- /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
- /package/dist/{elevation-s5ykdNHr.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
- /package/dist/{externalPaths-Bapitwvd.d.mts → externalPaths-BD5nw6St.d.mts} +0 -0
- /package/dist/{interface-CkkWm5uR.d.mts → interface-DfLGcus7.d.mts} +0 -0
- /package/dist/{interface-Da0r7Lna.d.mts → interface-beEtJyWM.d.mts} +0 -0
- /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
- /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
- /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
- /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
- /package/dist/{pluralize-BneOJkpi.mjs → pluralize-DQgqgifU.mjs} +0 -0
- /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
- /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
- /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
- /package/dist/{sessionManager-D-oNWHz3.d.mts → sessionManager-C4Le_UB3.d.mts} +0 -0
- /package/dist/{storage-BwGQXUpd.d.mts → storage-Dfzt4VTl.d.mts} +0 -0
- /package/dist/{tracing-DokiEsuz.d.mts → tracing-QJVprktp.d.mts} +0 -0
- /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
- /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
- /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
- /package/dist/{websocket-CyJ1VIFI.d.mts → websocket-ChC2rqe1.d.mts} +0 -0
|
@@ -0,0 +1,1399 @@
|
|
|
1
|
+
import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-Cxde4rpC.mjs";
|
|
2
|
+
import { arcLog } from "./logger/index.mjs";
|
|
3
|
+
import { A as assertValidConfig, l as getDefaultCrudSchemas } from "./utils-_h9B3c57.mjs";
|
|
4
|
+
import { t as BaseController } from "./BaseController-DX_T-bDB.mjs";
|
|
5
|
+
import { t as applyPresets } from "./presets-BbkjdPeH.mjs";
|
|
6
|
+
import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-De34B1ZG.mjs";
|
|
7
|
+
import { t as hasEvents } from "./typeGuards-BzkXkvVv.mjs";
|
|
8
|
+
import { b as buildRequestScopeProjection, c as buildPreHandlerChain, d as resolveRoutePreHandlers, f as resolveRouterPluginMw, h as createFastifyHandler, i as buildAuthMiddleware, l as buildRateLimitConfig, m as createCrudHandlers, o as buildCrudPermissionMw, p as selectPluginMw, r as buildArcDecorator, s as buildPipelineHandler, u as resolvePipelineSteps } from "./routerShared-D6_fEGHh.mjs";
|
|
9
|
+
import { t as resolveActionPermission } from "./actionPermissions-CyUkQu6O.mjs";
|
|
10
|
+
//#region src/core/aggregation/defineAggregation.ts
|
|
11
|
+
/**
|
|
12
|
+
* Declare a single resource aggregation. Exported configs flow into
|
|
13
|
+
* `defineResource({ aggregations: { ... } })` either as named keys or
|
|
14
|
+
* via auto-discovery patterns (loadAggregations — future).
|
|
15
|
+
*
|
|
16
|
+
* @example Inline
|
|
17
|
+
* ```ts
|
|
18
|
+
* defineResource({
|
|
19
|
+
* name: 'order',
|
|
20
|
+
* aggregations: {
|
|
21
|
+
* revenueByStatus: defineAggregation({
|
|
22
|
+
* groupBy: 'status',
|
|
23
|
+
* measures: { count: 'count', revenue: 'sum:totalPrice' },
|
|
24
|
+
* permissions: requireRoles(['admin']),
|
|
25
|
+
* }),
|
|
26
|
+
* },
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* @example Multi-file
|
|
31
|
+
* ```ts
|
|
32
|
+
* // orders/aggregations/revenue-by-status.ts
|
|
33
|
+
* import { defineAggregation } from '@classytic/arc';
|
|
34
|
+
*
|
|
35
|
+
* export const revenueByStatus = defineAggregation({
|
|
36
|
+
* groupBy: 'status',
|
|
37
|
+
* measures: { count: 'count', revenue: 'sum:totalPrice' },
|
|
38
|
+
* permissions: requireRoles(['admin']),
|
|
39
|
+
* timeout: 5000,
|
|
40
|
+
* cache: { staleTime: 60 },
|
|
41
|
+
* });
|
|
42
|
+
*
|
|
43
|
+
* // orders/order.resource.ts
|
|
44
|
+
* import * as aggregations from './aggregations/index.js';
|
|
45
|
+
*
|
|
46
|
+
* defineResource({
|
|
47
|
+
* name: 'order',
|
|
48
|
+
* aggregations, // 30+ entries flow in
|
|
49
|
+
* });
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
function defineAggregation(config) {
|
|
53
|
+
return config;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/core/createCrudRouter.ts
|
|
57
|
+
/**
|
|
58
|
+
* Mount custom routes (from presets or user-defined `routes`) on Fastify.
|
|
59
|
+
* `wrapHandler` is derived inline from `!route.raw`.
|
|
60
|
+
*/
|
|
61
|
+
function createCustomRoutes(fastify, routes, controller, options) {
|
|
62
|
+
const { tag, resourceName, arcDecorator, rateLimitConfig, pluginMw, pipeline, routeGuards } = options;
|
|
63
|
+
for (const route of routes) {
|
|
64
|
+
const opName = route.operation ?? (typeof route.handler === "string" ? route.handler : `${route.method.toLowerCase()}${route.path.replace(/[/:]/g, "_")}`);
|
|
65
|
+
const wrapHandler = !route.raw;
|
|
66
|
+
let handler;
|
|
67
|
+
if (typeof route.handler === "string") {
|
|
68
|
+
if (!controller) throw new Error(`Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller. Either provide a controller or use a function handler instead.`);
|
|
69
|
+
const method = controller[route.handler];
|
|
70
|
+
if (typeof method !== "function") throw new Error(`Handler '${route.handler}' not found on controller`);
|
|
71
|
+
const boundMethod = method.bind(controller);
|
|
72
|
+
if (wrapHandler) {
|
|
73
|
+
const steps = resolvePipelineSteps(pipeline, opName);
|
|
74
|
+
handler = steps.length > 0 ? buildPipelineHandler(boundMethod, steps, opName, resourceName) : createFastifyHandler(boundMethod);
|
|
75
|
+
} else handler = boundMethod;
|
|
76
|
+
} else if (wrapHandler) {
|
|
77
|
+
const steps = resolvePipelineSteps(pipeline, opName);
|
|
78
|
+
handler = steps.length > 0 ? buildPipelineHandler(route.handler, steps, opName, resourceName) : createFastifyHandler(route.handler);
|
|
79
|
+
} else handler = route.handler;
|
|
80
|
+
const routeTags = route.tags ?? (tag ? [tag] : void 0);
|
|
81
|
+
const convertedSchema = route.schema ? convertRouteSchema(route.schema) : void 0;
|
|
82
|
+
const schema = {
|
|
83
|
+
...routeTags ? { tags: routeTags } : {},
|
|
84
|
+
...route.summary ? { summary: route.summary } : {},
|
|
85
|
+
...route.description ? { description: route.description } : {},
|
|
86
|
+
...convertedSchema ?? {}
|
|
87
|
+
};
|
|
88
|
+
const customPreHandlers = resolveRoutePreHandlers(route.preHandler, fastify, `${route.method} ${route.path}`);
|
|
89
|
+
const preHandler = buildPreHandlerChain({
|
|
90
|
+
preAuth: route.preAuth ?? [],
|
|
91
|
+
arcDecorator,
|
|
92
|
+
authMw: buildAuthMiddleware(fastify, route.permissions),
|
|
93
|
+
permissionMw: buildCrudPermissionMw(route.permissions, resourceName, opName),
|
|
94
|
+
pluginMw: selectPluginMw(route.method, pluginMw),
|
|
95
|
+
routeGuards,
|
|
96
|
+
customMws: customPreHandlers
|
|
97
|
+
});
|
|
98
|
+
const isStream = route.streamResponse === true;
|
|
99
|
+
fastify.route({
|
|
100
|
+
method: route.method,
|
|
101
|
+
url: route.path,
|
|
102
|
+
schema,
|
|
103
|
+
preHandler: preHandler.length > 0 ? preHandler : void 0,
|
|
104
|
+
handler: isStream ? async (request, reply) => {
|
|
105
|
+
reply.raw.setHeader("Content-Type", "text/event-stream");
|
|
106
|
+
reply.raw.setHeader("Cache-Control", "no-cache");
|
|
107
|
+
reply.raw.setHeader("Connection", "keep-alive");
|
|
108
|
+
return handler(request, reply);
|
|
109
|
+
} : handler,
|
|
110
|
+
...rateLimitConfig ? { config: rateLimitConfig } : {}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Create CRUD routes for a controller.
|
|
116
|
+
*
|
|
117
|
+
* @param fastify - Fastify instance with Arc decorators
|
|
118
|
+
* @param controller - CRUD controller with handler methods (optional when
|
|
119
|
+
* `disableDefaultRoutes: true` and only custom `routes`
|
|
120
|
+
* are being registered)
|
|
121
|
+
* @param options - Router configuration
|
|
122
|
+
*/
|
|
123
|
+
function createCrudRouter(fastify, controller, options = {}) {
|
|
124
|
+
const { tag = "Resource", schemas = {}, permissions = {}, middlewares = {}, routeGuards = [], routes: customRoutes = [], disableDefaultRoutes = false, disabledRoutes = [], resourceName = "unknown", schemaOptions, rateLimit, pipe: pipeline, fields: fieldPermissions, updateMethod = DEFAULT_UPDATE_METHOD } = options;
|
|
125
|
+
const rateLimitConfig = buildRateLimitConfig(rateLimit);
|
|
126
|
+
const resourceHasQueryCache = fastify.hasDecorator("queryCache") && controller && typeof controller._cacheConfig !== "undefined" && controller._cacheConfig !== void 0;
|
|
127
|
+
const pluginMw = resolveRouterPluginMw(fastify, Boolean(resourceHasQueryCache));
|
|
128
|
+
const arcDecorator = buildArcDecorator({
|
|
129
|
+
resourceName,
|
|
130
|
+
schemaOptions,
|
|
131
|
+
permissions,
|
|
132
|
+
hooks: fastify.arc?.hooks,
|
|
133
|
+
events: fastify.events,
|
|
134
|
+
fields: fieldPermissions
|
|
135
|
+
});
|
|
136
|
+
const mw = {
|
|
137
|
+
list: middlewares.list ?? [],
|
|
138
|
+
get: middlewares.get ?? [],
|
|
139
|
+
create: middlewares.create ?? [],
|
|
140
|
+
update: middlewares.update ?? [],
|
|
141
|
+
delete: middlewares.delete ?? []
|
|
142
|
+
};
|
|
143
|
+
const idParamsSchema = {
|
|
144
|
+
type: "object",
|
|
145
|
+
properties: { id: { type: "string" } },
|
|
146
|
+
required: ["id"]
|
|
147
|
+
};
|
|
148
|
+
const defaultSchemas = getDefaultCrudSchemas();
|
|
149
|
+
/**
|
|
150
|
+
* Merge: base (tags/summary) → defaults (response/querystring) → user overrides.
|
|
151
|
+
* User-provided schemas always win; defaults enable fast-json-stringify when
|
|
152
|
+
* no user schema is set.
|
|
153
|
+
*/
|
|
154
|
+
const buildSchema = (base, defaults, userSchema) => ({
|
|
155
|
+
...defaults,
|
|
156
|
+
...base,
|
|
157
|
+
...userSchema ?? {}
|
|
158
|
+
});
|
|
159
|
+
if (!disableDefaultRoutes) {
|
|
160
|
+
if (!controller) throw new Error("Controller is required when disableDefaultRoutes is not true. Provide a controller or use defineResource which auto-creates BaseController.");
|
|
161
|
+
const handlers = buildCrudHandlers(controller, pipeline, resourceName);
|
|
162
|
+
const crudTable = [
|
|
163
|
+
{
|
|
164
|
+
op: "list",
|
|
165
|
+
method: "GET",
|
|
166
|
+
url: "/",
|
|
167
|
+
summary: `List ${tag}`,
|
|
168
|
+
hasIdParams: false
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
op: "get",
|
|
172
|
+
method: "GET",
|
|
173
|
+
url: "/:id",
|
|
174
|
+
summary: `Get ${tag} by ID`,
|
|
175
|
+
hasIdParams: true
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
op: "create",
|
|
179
|
+
method: "POST",
|
|
180
|
+
url: "/",
|
|
181
|
+
summary: `Create ${tag}`,
|
|
182
|
+
hasIdParams: false
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
op: "update",
|
|
186
|
+
method: "PATCH",
|
|
187
|
+
url: "/:id",
|
|
188
|
+
summary: `Update ${tag}`,
|
|
189
|
+
hasIdParams: true
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
op: "delete",
|
|
193
|
+
method: "DELETE",
|
|
194
|
+
url: "/:id",
|
|
195
|
+
summary: `Delete ${tag}`,
|
|
196
|
+
hasIdParams: true
|
|
197
|
+
}
|
|
198
|
+
];
|
|
199
|
+
for (const spec of crudTable) {
|
|
200
|
+
if (disabledRoutes.includes(spec.op)) continue;
|
|
201
|
+
const permission = permissions[spec.op];
|
|
202
|
+
const preHandler = buildPreHandlerChain({
|
|
203
|
+
arcDecorator,
|
|
204
|
+
authMw: buildAuthMiddleware(fastify, permission),
|
|
205
|
+
permissionMw: buildCrudPermissionMw(permission, resourceName, spec.op),
|
|
206
|
+
pluginMw: selectPluginMw(spec.method, pluginMw),
|
|
207
|
+
routeGuards,
|
|
208
|
+
customMws: mw[spec.op]
|
|
209
|
+
});
|
|
210
|
+
const methodsToRegister = spec.op === "update" ? updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod] : [spec.method];
|
|
211
|
+
for (const method of methodsToRegister) {
|
|
212
|
+
const summary = spec.op === "update" ? `${method === "PUT" ? "Replace" : "Update"} ${tag}` : spec.summary;
|
|
213
|
+
fastify.route({
|
|
214
|
+
method,
|
|
215
|
+
url: spec.url,
|
|
216
|
+
schema: buildSchema({
|
|
217
|
+
tags: [tag],
|
|
218
|
+
summary,
|
|
219
|
+
...spec.hasIdParams ? { params: idParamsSchema } : {}
|
|
220
|
+
}, defaultSchemas[spec.op], schemas[spec.op]),
|
|
221
|
+
preHandler: preHandler.length > 0 ? preHandler : void 0,
|
|
222
|
+
handler: handlers[spec.op],
|
|
223
|
+
...rateLimitConfig ? { config: rateLimitConfig } : {}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (customRoutes.length > 0) createCustomRoutes(fastify, customRoutes, controller, {
|
|
229
|
+
tag,
|
|
230
|
+
resourceName,
|
|
231
|
+
arcDecorator,
|
|
232
|
+
rateLimitConfig,
|
|
233
|
+
pluginMw,
|
|
234
|
+
pipeline,
|
|
235
|
+
routeGuards
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function buildCrudHandlers(ctrl, pipeline, resourceName) {
|
|
239
|
+
const standardHandlers = createCrudHandlers(ctrl);
|
|
240
|
+
if (!pipeline) return standardHandlers;
|
|
241
|
+
const wrapped = { ...standardHandlers };
|
|
242
|
+
for (const op of CRUD_OPERATIONS) {
|
|
243
|
+
const steps = resolvePipelineSteps(pipeline, op);
|
|
244
|
+
if (steps.length === 0) continue;
|
|
245
|
+
wrapped[op] = buildPipelineHandler(ctrl[op].bind(ctrl), steps, op, resourceName);
|
|
246
|
+
}
|
|
247
|
+
return wrapped;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Build a permission middleware from a PermissionCheck — useful when hosts
|
|
251
|
+
* register their own routes outside the resource system but still want to
|
|
252
|
+
* evaluate permissions through the shared applicator.
|
|
253
|
+
*/
|
|
254
|
+
function createPermissionMiddleware(permission, resourceName, action) {
|
|
255
|
+
return buildCrudPermissionMw(permission, resourceName, action);
|
|
256
|
+
}
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/core/defineResource/controller.ts
|
|
259
|
+
/**
|
|
260
|
+
* Phase 4 — pick (or auto-create) the resource's controller.
|
|
261
|
+
*
|
|
262
|
+
* Three branches:
|
|
263
|
+
* 1. User-supplied controller → forward `queryParser` (duck-typed)
|
|
264
|
+
* and warn on dropped resource-level options.
|
|
265
|
+
* 2. No controller, has CRUD routes, has a repository → auto-build
|
|
266
|
+
* a `BaseController` with every resource-level knob threaded
|
|
267
|
+
* through (tenantField, schemaOptions, idField, defaultSort,
|
|
268
|
+
* cache, onFieldWriteDenied, presetFields).
|
|
269
|
+
* 3. Otherwise → `undefined` (custom-routes-only resource).
|
|
270
|
+
*
|
|
271
|
+
* The warns are load-bearing DX: silently dropping `queryParser`,
|
|
272
|
+
* `schemaOptions`, etc. on a custom controller produces 90-minute
|
|
273
|
+
* "why don't my filters work" debugs. Each warn names the resource,
|
|
274
|
+
* lists the dropped options, and points at the canonical fix. All
|
|
275
|
+
* warns honour `ARC_SUPPRESS_WARNINGS=1` via `arcLog()`.
|
|
276
|
+
*/
|
|
277
|
+
/**
|
|
278
|
+
* Resolve the controller for the resource. See module docstring for
|
|
279
|
+
* branch semantics.
|
|
280
|
+
*/
|
|
281
|
+
function resolveOrAutoCreateController(resolvedConfig, adapter, repository, hasCrudRoutes) {
|
|
282
|
+
const userController = resolvedConfig.controller;
|
|
283
|
+
if (userController) {
|
|
284
|
+
threadQueryParser(userController, resolvedConfig);
|
|
285
|
+
warnOnDroppedAuthorOptions(resolvedConfig);
|
|
286
|
+
warnOnDroppedPresetOptions(resolvedConfig);
|
|
287
|
+
return userController;
|
|
288
|
+
}
|
|
289
|
+
if (!hasCrudRoutes || !repository) return void 0;
|
|
290
|
+
return buildBaseController(resolvedConfig, adapter, repository);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Forward a resource-level `queryParser` into a user-supplied
|
|
294
|
+
* controller via duck-typed `setQueryParser`. Without this the
|
|
295
|
+
* controller's internal default would silently override the
|
|
296
|
+
* resource's parser, drifting `[contains]` / `[like]` semantics
|
|
297
|
+
* away from what the OpenAPI schema advertises.
|
|
298
|
+
*/
|
|
299
|
+
function threadQueryParser(controller, resolvedConfig) {
|
|
300
|
+
if (!resolvedConfig.queryParser) return;
|
|
301
|
+
const ctrl = controller;
|
|
302
|
+
if (typeof ctrl.setQueryParser === "function") {
|
|
303
|
+
ctrl.setQueryParser(resolvedConfig.queryParser);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" declares a custom \`queryParser\` but its controller does not expose \`setQueryParser(qp)\`. The parser will NOT be threaded into the controller's query resolution — operator filters (\`[contains]\`, \`[like]\`, etc.) may fall back to the controller's internal default. Extend \`BaseController\` / \`BaseCrudController\` (both implement \`setQueryParser\`) OR add the method to your custom controller to honor the resource-level parser.`);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Warn when the user supplies their own controller AND declares
|
|
310
|
+
* resource-level options arc only auto-threads on the auto-build
|
|
311
|
+
* path. The user *can* fix this by forwarding through `super(repo,
|
|
312
|
+
* { ... })`, so the warn names the dropped options + the canonical
|
|
313
|
+
* fix.
|
|
314
|
+
*/
|
|
315
|
+
function warnOnDroppedAuthorOptions(resolvedConfig) {
|
|
316
|
+
const dropped = [];
|
|
317
|
+
if (resolvedConfig.tenantField !== void 0) dropped.push("tenantField");
|
|
318
|
+
if (resolvedConfig.schemaOptions !== void 0 && Object.keys(resolvedConfig.schemaOptions).length > 0) dropped.push("schemaOptions");
|
|
319
|
+
if (resolvedConfig.idField !== void 0) dropped.push("idField");
|
|
320
|
+
if (resolvedConfig.defaultSort !== void 0) dropped.push("defaultSort");
|
|
321
|
+
if (resolvedConfig.cache !== void 0) dropped.push("cache");
|
|
322
|
+
if (resolvedConfig.onFieldWriteDenied !== void 0) dropped.push("onFieldWriteDenied");
|
|
323
|
+
if (dropped.length === 0) return;
|
|
324
|
+
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'). Forward them to your controller's \`super(repo, { ... })\` call. Same root cause as the \`queryParser\` warn above.`);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Warn when a preset injected `_controllerOptions` (slugLookup,
|
|
328
|
+
* softDelete, parent presets) but the user supplied their own
|
|
329
|
+
* controller. The user did NOT declare these — "forward them" is
|
|
330
|
+
* bad advice. The fix is either drop the preset or extend
|
|
331
|
+
* `BaseController` so the auto-build path runs.
|
|
332
|
+
*/
|
|
333
|
+
function warnOnDroppedPresetOptions(resolvedConfig) {
|
|
334
|
+
if (resolvedConfig._controllerOptions === void 0) return;
|
|
335
|
+
const presetFields = [];
|
|
336
|
+
if (resolvedConfig._controllerOptions.slugField) presetFields.push("slugField");
|
|
337
|
+
if (resolvedConfig._controllerOptions.parentField) presetFields.push("parentField");
|
|
338
|
+
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.`);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Auto-build a `BaseController` with every resource-level knob
|
|
342
|
+
* threaded in. `maxLimit` is extracted from the parser's schema so
|
|
343
|
+
* `BaseController.QueryResolver` and Fastify validation stay in sync
|
|
344
|
+
* with the parser's configured limit.
|
|
345
|
+
*/
|
|
346
|
+
function buildBaseController(resolvedConfig, adapter, repository) {
|
|
347
|
+
const qp = resolvedConfig.queryParser;
|
|
348
|
+
let maxLimitFromParser;
|
|
349
|
+
if (qp?.getQuerySchema) {
|
|
350
|
+
const limitProp = qp.getQuerySchema()?.properties?.limit;
|
|
351
|
+
if (limitProp?.maximum) maxLimitFromParser = limitProp.maximum;
|
|
352
|
+
}
|
|
353
|
+
return new BaseController(repository, {
|
|
354
|
+
resourceName: resolvedConfig.name,
|
|
355
|
+
schemaOptions: resolvedConfig.schemaOptions,
|
|
356
|
+
queryParser: qp,
|
|
357
|
+
maxLimit: maxLimitFromParser,
|
|
358
|
+
tenantField: resolvedConfig.tenantField,
|
|
359
|
+
idField: resolvedConfig.idField,
|
|
360
|
+
...resolvedConfig.defaultSort !== void 0 ? { defaultSort: resolvedConfig.defaultSort } : {},
|
|
361
|
+
matchesFilter: adapter?.matchesFilter,
|
|
362
|
+
cache: resolvedConfig.cache,
|
|
363
|
+
onFieldWriteDenied: resolvedConfig.onFieldWriteDenied,
|
|
364
|
+
presetFields: resolvedConfig._controllerOptions ? {
|
|
365
|
+
slugField: resolvedConfig._controllerOptions.slugField,
|
|
366
|
+
parentField: resolvedConfig._controllerOptions.parentField
|
|
367
|
+
} : void 0
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/core/defineResource/hooks.ts
|
|
372
|
+
/**
|
|
373
|
+
* Phase 6 — wire preset hooks + inline `config.hooks` onto the
|
|
374
|
+
* resource's `_pendingHooks`.
|
|
375
|
+
*
|
|
376
|
+
* Two sources feed the same array:
|
|
377
|
+
* 1. Preset hooks collected during `applyPresets()` (raw `_hooks`
|
|
378
|
+
* on the internal config). Already in the canonical
|
|
379
|
+
* `{ operation, phase, handler, priority }` shape — copied
|
|
380
|
+
* verbatim (priority defaults to 10).
|
|
381
|
+
* 2. Inline `config.hooks.beforeCreate` / `afterCreate` / etc.
|
|
382
|
+
* Authored by the user on the original `ResourceConfig`.
|
|
383
|
+
* Wrapped in a `ResourceHookContext` projection (v2.10.8) so
|
|
384
|
+
* authors can read `scope` / `context` without reaching into
|
|
385
|
+
* internal request fields.
|
|
386
|
+
*
|
|
387
|
+
* The 6 inline hook keys (before/after × create/update/delete) used
|
|
388
|
+
* to be 6 nearly-identical blocks; collapsed into a table + loop so
|
|
389
|
+
* a future hook (e.g. `beforeRead`) is one row, not seven scattered
|
|
390
|
+
* edits.
|
|
391
|
+
*/
|
|
392
|
+
/**
|
|
393
|
+
* Combined entry-point for Phase 6. Pushes preset-collected hooks
|
|
394
|
+
* first, then inline `config.hooks` (so hosts can rely on
|
|
395
|
+
* registration order if priorities tie).
|
|
396
|
+
*/
|
|
397
|
+
function wireHooks(resource, resolvedConfig, inlineHooksConfig) {
|
|
398
|
+
if (resolvedConfig._hooks?.length) resource._pendingHooks.push(...resolvedConfig._hooks.map((hook) => ({
|
|
399
|
+
operation: hook.operation,
|
|
400
|
+
phase: hook.phase,
|
|
401
|
+
handler: hook.handler,
|
|
402
|
+
priority: hook.priority ?? 10
|
|
403
|
+
})));
|
|
404
|
+
if (!inlineHooksConfig) return;
|
|
405
|
+
const h = inlineHooksConfig;
|
|
406
|
+
for (const spec of INLINE_HOOK_SPECS) {
|
|
407
|
+
const fn = h[spec.key];
|
|
408
|
+
if (typeof fn !== "function") continue;
|
|
409
|
+
resource._pendingHooks.push({
|
|
410
|
+
operation: spec.operation,
|
|
411
|
+
phase: spec.phase,
|
|
412
|
+
priority: 10,
|
|
413
|
+
handler: (ctx) => fn(buildHookContext(ctx))
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Inline hook spec table — one row per `config.hooks.{key}`. Adding
|
|
419
|
+
* a new lifecycle hook (e.g. `beforeRead`) means appending one row
|
|
420
|
+
* here; the loop handles the rest.
|
|
421
|
+
*/
|
|
422
|
+
const INLINE_HOOK_SPECS = [
|
|
423
|
+
{
|
|
424
|
+
key: "beforeCreate",
|
|
425
|
+
operation: "create",
|
|
426
|
+
phase: "before"
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
key: "afterCreate",
|
|
430
|
+
operation: "create",
|
|
431
|
+
phase: "after"
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
key: "beforeUpdate",
|
|
435
|
+
operation: "update",
|
|
436
|
+
phase: "before"
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
key: "afterUpdate",
|
|
440
|
+
operation: "update",
|
|
441
|
+
phase: "after"
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
key: "beforeDelete",
|
|
445
|
+
operation: "delete",
|
|
446
|
+
phase: "before"
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
key: "afterDelete",
|
|
450
|
+
operation: "delete",
|
|
451
|
+
phase: "after"
|
|
452
|
+
}
|
|
453
|
+
];
|
|
454
|
+
/**
|
|
455
|
+
* Project a raw HookSystem context into a `ResourceHookContext` for
|
|
456
|
+
* inline `config.hooks.*` handlers. The projection lifts `scope`
|
|
457
|
+
* out of `context._scope` so authors don't reach into internal
|
|
458
|
+
* fields.
|
|
459
|
+
*/
|
|
460
|
+
function buildHookContext(ctx) {
|
|
461
|
+
const context = ctx.context;
|
|
462
|
+
const rawScope = context?._scope;
|
|
463
|
+
return {
|
|
464
|
+
data: ctx.data ?? ctx.result ?? {},
|
|
465
|
+
user: ctx.user,
|
|
466
|
+
context,
|
|
467
|
+
scope: buildRequestScopeProjection(rawScope),
|
|
468
|
+
meta: ctx.meta
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
//#endregion
|
|
472
|
+
//#region src/core/defineResource/idField.ts
|
|
473
|
+
/**
|
|
474
|
+
* Returns a fresh config with `idField` filled in (when applicable),
|
|
475
|
+
* or the original reference when nothing changes. Never mutates the
|
|
476
|
+
* caller's input.
|
|
477
|
+
*/
|
|
478
|
+
function resolveIdField(config, repository) {
|
|
479
|
+
if (config.idField !== void 0 || !repository) return config;
|
|
480
|
+
const repoIdField = repository.idField;
|
|
481
|
+
if (typeof repoIdField === "string" && repoIdField !== "_id") return {
|
|
482
|
+
...config,
|
|
483
|
+
idField: repoIdField
|
|
484
|
+
};
|
|
485
|
+
return config;
|
|
486
|
+
}
|
|
487
|
+
//#endregion
|
|
488
|
+
//#region src/core/schemaOptions.ts
|
|
489
|
+
/**
|
|
490
|
+
* Inject the tenant-scoping field rule into `schemaOptions.fieldRules`:
|
|
491
|
+
*
|
|
492
|
+
* { [tenantField]: { systemManaged: true, preserveForElevated: true } }
|
|
493
|
+
*
|
|
494
|
+
* Why both flags: `systemManaged` tells `BodySanitizer` to strip the
|
|
495
|
+
* field from inbound bodies (so member clients can't forge a target
|
|
496
|
+
* tenant). `preserveForElevated` exempts elevated-admin scopes from the
|
|
497
|
+
* strip, so platform admins without a pinned org can still pick a target
|
|
498
|
+
* org via the request body (the only channel they have —
|
|
499
|
+
* `BaseController.create` can't re-stamp from scope when scope has no
|
|
500
|
+
* orgId).
|
|
501
|
+
*
|
|
502
|
+
* **Returns a new `RouteSchemaOptions`** — the input is never mutated.
|
|
503
|
+
* Callers should assign the return value to whatever config slot they
|
|
504
|
+
* read from downstream (always the `resolvedConfig`, never raw `config`).
|
|
505
|
+
*
|
|
506
|
+
* **No-op when:**
|
|
507
|
+
* - `tenantField` is `false` (platform-universal resource)
|
|
508
|
+
* - `tenantField` is undefined
|
|
509
|
+
* - The caller already declared `fieldRules[tenantField].systemManaged`
|
|
510
|
+
* (even as `false`) — explicit opt-outs are respected
|
|
511
|
+
*
|
|
512
|
+
* `preserveForElevated` defaults to `true` but is preserved verbatim
|
|
513
|
+
* when the caller set it explicitly.
|
|
514
|
+
*/
|
|
515
|
+
function autoInjectTenantFieldRules(schemaOptions, tenantField) {
|
|
516
|
+
if (tenantField === false || tenantField === void 0) return schemaOptions;
|
|
517
|
+
const fieldName = tenantField || "organizationId";
|
|
518
|
+
const existing = schemaOptions?.fieldRules ?? {};
|
|
519
|
+
const existingRule = existing[fieldName];
|
|
520
|
+
if (existingRule && existingRule.systemManaged !== void 0) return schemaOptions;
|
|
521
|
+
return {
|
|
522
|
+
...schemaOptions ?? {},
|
|
523
|
+
fieldRules: {
|
|
524
|
+
...existing,
|
|
525
|
+
[fieldName]: {
|
|
526
|
+
...existingRule ?? {},
|
|
527
|
+
systemManaged: true,
|
|
528
|
+
preserveForElevated: existingRule?.preserveForElevated ?? true
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Remove a field from a JSON Schema's `required[]` array. Leaves `properties`
|
|
535
|
+
* intact so advanced callers can still send the value — the field just isn't
|
|
536
|
+
* mandatory at validation time.
|
|
537
|
+
*
|
|
538
|
+
* Returns a fresh schema (no mutation). No-op when the schema is undefined,
|
|
539
|
+
* lacks a `required[]`, or the field is already absent from it.
|
|
540
|
+
*/
|
|
541
|
+
function stripFromRequired(schema, fieldName) {
|
|
542
|
+
if (!schema || typeof schema !== "object") return schema;
|
|
543
|
+
const required = schema.required;
|
|
544
|
+
if (!Array.isArray(required) || !required.includes(fieldName)) return schema;
|
|
545
|
+
const filtered = required.filter((f) => f !== fieldName);
|
|
546
|
+
const next = { ...schema };
|
|
547
|
+
if (filtered.length > 0) next.required = filtered;
|
|
548
|
+
else delete next.required;
|
|
549
|
+
return next;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Strip framework-injected fields from the `required[]` list of every
|
|
553
|
+
* body-shaped slot in an adapter's generated schemas (v2.11.0).
|
|
554
|
+
*
|
|
555
|
+
* A "framework-injected field" is any field marked `systemManaged: true`
|
|
556
|
+
* in `schemaOptions.fieldRules`. Arc populates those fields from the
|
|
557
|
+
* request scope / preset middleware / controller — the client is never
|
|
558
|
+
* expected to supply them, so they must not be in the wire contract's
|
|
559
|
+
* `required[]` even if the underlying engine's Mongoose/Zod schema
|
|
560
|
+
* declares them as required at the DB layer.
|
|
561
|
+
*
|
|
562
|
+
* **The primary gotcha this closes:** engines built on
|
|
563
|
+
* `@classytic/primitives` (mongokit, pricelist, and every downstream
|
|
564
|
+
* `@classytic/*` engine) default to `tenant: { required: true }` in
|
|
565
|
+
* `resolveTenantConfig()`. That stamps `organizationId: { required: true }`
|
|
566
|
+
* on the Mongoose schema, which the adapter faithfully reflects into the
|
|
567
|
+
* generated `createBody` / `updateBody` schema's `required[]`. Fastify's
|
|
568
|
+
* preValidation runs BEFORE arc's preHandler chain, so
|
|
569
|
+
* `multiTenantPreset`'s tenant-injection hook never gets a chance to run —
|
|
570
|
+
* the request is rejected with `must have required property 'organizationId'`
|
|
571
|
+
* even though the client correctly supplied `x-organization-id` and the
|
|
572
|
+
* framework had already promised to inject the value.
|
|
573
|
+
*
|
|
574
|
+
* The only workaround before 2.11 was
|
|
575
|
+
* `createEngine({ tenant: { required: false } })` at every consumer site —
|
|
576
|
+
* a leaky abstraction every new engine-backed resource had to remember.
|
|
577
|
+
*
|
|
578
|
+
* **Secondary coverage (defense-in-depth):** the same transform also fires
|
|
579
|
+
* for `auditedPreset`'s `createdBy` / `updatedBy`, any future preset that
|
|
580
|
+
* marks fields `systemManaged`, and any host-declared `fieldRules` with
|
|
581
|
+
* `systemManaged: true`. Every framework-injected field gets the wire
|
|
582
|
+
* contract / runtime pairing for free.
|
|
583
|
+
*
|
|
584
|
+
* **Leaves `properties` intact** — elevated admins or advanced callers can
|
|
585
|
+
* still send systemManaged fields in the body. `BodySanitizer` enforces
|
|
586
|
+
* the runtime policy (`preserveForElevated`, `strip` vs `reject`, etc.).
|
|
587
|
+
*
|
|
588
|
+
* **No-op when:**
|
|
589
|
+
* - `schemaOptions.fieldRules` is undefined / empty
|
|
590
|
+
* - No rule has `systemManaged: true`
|
|
591
|
+
* - The generated schemas object is undefined (adapter didn't generate any)
|
|
592
|
+
*
|
|
593
|
+
* Applies to both `createBody` and `updateBody` — update middleware also
|
|
594
|
+
* injects tenant/audit fields, so the update wire contract has the same
|
|
595
|
+
* problem as create.
|
|
596
|
+
*/
|
|
597
|
+
function stripSystemManagedFromBodyRequired(schemas, schemaOptions) {
|
|
598
|
+
if (!schemas) return schemas;
|
|
599
|
+
const rules = schemaOptions?.fieldRules;
|
|
600
|
+
if (!rules) return schemas;
|
|
601
|
+
const systemManagedFields = Object.entries(rules).filter(([, rule]) => rule?.systemManaged === true).map(([field]) => field);
|
|
602
|
+
if (systemManagedFields.length === 0) return schemas;
|
|
603
|
+
const next = { ...schemas };
|
|
604
|
+
let createBody = schemas.createBody;
|
|
605
|
+
for (const field of systemManagedFields) createBody = stripFromRequired(createBody, field);
|
|
606
|
+
if (createBody !== schemas.createBody) next.createBody = createBody;
|
|
607
|
+
let updateBody = schemas.updateBody;
|
|
608
|
+
for (const field of systemManagedFields) updateBody = stripFromRequired(updateBody, field);
|
|
609
|
+
if (updateBody !== schemas.updateBody) next.updateBody = updateBody;
|
|
610
|
+
return next;
|
|
611
|
+
}
|
|
612
|
+
//#endregion
|
|
613
|
+
//#region src/core/defineResource/presets.ts
|
|
614
|
+
/**
|
|
615
|
+
* Phase 3 — apply presets + auto-inject tenant-field schema rules.
|
|
616
|
+
*
|
|
617
|
+
* Produces the canonical `resolvedConfig` — a fresh clone of the
|
|
618
|
+
* caller's config with presets applied and tenant-field schema rules
|
|
619
|
+
* inferred. Always returns a fresh object so downstream mutations
|
|
620
|
+
* (`_appliedPresets`, `schemaOptions` auto-inject, `_controllerOptions`,
|
|
621
|
+
* `_hooks`) never leak onto the caller's config. Pre-2.11 the
|
|
622
|
+
* no-preset branch returned the raw caller reference, which mutated
|
|
623
|
+
* resource-config fragments hosts were reusing.
|
|
624
|
+
*
|
|
625
|
+
* Centralising the auto-inject + tenant inference here means every
|
|
626
|
+
* downstream reader (`BodySanitizer`, adapter `generateSchemas()`,
|
|
627
|
+
* MCP tool generator, OpenAPI builder) sees the same post-inject
|
|
628
|
+
* shape — `defineResource()` only ever consults `resolvedConfig`,
|
|
629
|
+
* never the raw user input, after this phase runs.
|
|
630
|
+
*/
|
|
631
|
+
/**
|
|
632
|
+
* Run the Phase 3 pipeline: clone → apply presets → infer tenant
|
|
633
|
+
* field → auto-inject system-managed rules. Returns the resolved
|
|
634
|
+
* `InternalResourceConfig` that every later phase consumes.
|
|
635
|
+
*/
|
|
636
|
+
function applyPresetsAndAutoInject(config) {
|
|
637
|
+
const originalPresets = (config.presets ?? []).map((p) => typeof p === "string" ? p : p.name);
|
|
638
|
+
const resolvedConfig = config.presets?.length ? applyPresets(config, config.presets) : { ...config };
|
|
639
|
+
resolvedConfig._appliedPresets = originalPresets;
|
|
640
|
+
inferTenantFieldFromAdapter(resolvedConfig);
|
|
641
|
+
resolvedConfig.schemaOptions = autoInjectTenantFieldRules(resolvedConfig.schemaOptions, resolvedConfig.tenantField);
|
|
642
|
+
return resolvedConfig;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Infer `tenantField: false` for resources whose model schema doesn't
|
|
646
|
+
* declare the configured tenant path. Closes the silent-zero-results
|
|
647
|
+
* footgun where hosts forget `tenantField: false` on company-wide
|
|
648
|
+
* tables (lookup tables, platform settings, single-tenant apps) — the
|
|
649
|
+
* default `'organizationId'` filter would scope every read to the
|
|
650
|
+
* caller's org and return nothing for documents that don't carry the
|
|
651
|
+
* field. Adapters opt into inference by implementing `hasFieldPath`;
|
|
652
|
+
* when the hook is absent, behaviour is unchanged (legacy default).
|
|
653
|
+
*
|
|
654
|
+
* Mutates the resolved config in place because (a) the next call
|
|
655
|
+
* (`autoInjectTenantFieldRules`) reads the inferred value, and (b)
|
|
656
|
+
* `_appliedPresets` is already stamped — keeping the mutation here
|
|
657
|
+
* avoids a second clone per resource.
|
|
658
|
+
*
|
|
659
|
+
* Three branches:
|
|
660
|
+
* - `tenantField === false` → host explicitly opted out, no inference.
|
|
661
|
+
* - `tenantField === undefined` AND adapter says the default doesn't
|
|
662
|
+
* exist → set to `false`, log info (the inferred decision).
|
|
663
|
+
* - `tenantField === '<custom>'` AND adapter says it doesn't exist →
|
|
664
|
+
* warn (likely typo or stale field); leave the value as-is so
|
|
665
|
+
* failures surface at runtime with the configured name in error
|
|
666
|
+
* messages.
|
|
667
|
+
*/
|
|
668
|
+
function inferTenantFieldFromAdapter(config) {
|
|
669
|
+
if (config.tenantField === false) return;
|
|
670
|
+
const adapter = config.adapter;
|
|
671
|
+
if (!adapter?.hasFieldPath) return;
|
|
672
|
+
const configured = config.tenantField ?? "organizationId";
|
|
673
|
+
const exists = adapter.hasFieldPath(configured);
|
|
674
|
+
if (exists === void 0) return;
|
|
675
|
+
if (exists) return;
|
|
676
|
+
if (config.tenantField === void 0) {
|
|
677
|
+
config.tenantField = false;
|
|
678
|
+
arcLog("defineResource").info(`Resource "${config.name}": auto-inferred \`tenantField: false\` — model has no \`${configured}\` path. Set \`tenantField\` explicitly to silence this log, or to a real field name on this resource's model.`);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
arcLog("defineResource").warn(`Resource "${config.name}": configured \`tenantField: '${configured}'\` but the model has no such path. Queries scoped by this field will silently return nothing. Either set \`tenantField: false\` (company-wide resource), or fix the field name.`);
|
|
682
|
+
}
|
|
683
|
+
/** Does this resource register any default CRUD routes? */
|
|
684
|
+
function computeHasCrudRoutes(config) {
|
|
685
|
+
const disabled = new Set(config.disabledRoutes ?? []);
|
|
686
|
+
return !config.disableDefaultRoutes && CRUD_OPERATIONS.some((op) => !disabled.has(op));
|
|
687
|
+
}
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/core/defineResource/plugin.ts
|
|
690
|
+
/**
|
|
691
|
+
* Build the CRUD schema map from the adapter's `OpenApiSchemas` plus
|
|
692
|
+
* any `customSchemas` overrides on the resource.
|
|
693
|
+
*
|
|
694
|
+
* Returns `null` when neither input has anything to contribute, so
|
|
695
|
+
* the caller can pass `undefined` straight to `createCrudRouter`.
|
|
696
|
+
*
|
|
697
|
+
* **Per-slot layering (post-2.12 DX fix).** Adapter auto-gen runs
|
|
698
|
+
* unconditionally — declaring one custom slot (e.g. a richer
|
|
699
|
+
* `create.body`) no longer wholesale-disables generated `get`,
|
|
700
|
+
* `update`, `delete`, and `params` schemas. The pre-fix branch
|
|
701
|
+
* skipped auto-gen entirely whenever `customSchemas` had any entry,
|
|
702
|
+
* which silently flipped four slots from "auto-derived from the
|
|
703
|
+
* adapter's schema generator" to "Fastify default" the moment a
|
|
704
|
+
* host customised one. Now: auto-gen first, then deep-merge
|
|
705
|
+
* customSchemas on top per slot.
|
|
706
|
+
*
|
|
707
|
+
* **`params` cloning is load-bearing.** Three CRUD slots (`get`,
|
|
708
|
+
* `delete`, `update`) need a `params` schema. The previous inline
|
|
709
|
+
* code shared the same reference across all three, so a downstream
|
|
710
|
+
* mutation (e.g. attaching a vendor `description` for OpenAPI
|
|
711
|
+
* tooling) leaked across operations. Each slot now owns its own
|
|
712
|
+
* shallow clone.
|
|
713
|
+
*/
|
|
714
|
+
function buildGeneratedCrudSchemas(openApi, customSchemas) {
|
|
715
|
+
const generated = {};
|
|
716
|
+
if (openApi) {
|
|
717
|
+
const { createBody, updateBody, params } = openApi;
|
|
718
|
+
if (createBody) generated.create = { body: safeBody(createBody) };
|
|
719
|
+
if (updateBody) {
|
|
720
|
+
const patchBody = { ...updateBody };
|
|
721
|
+
delete patchBody.required;
|
|
722
|
+
generated.update = { body: safeBody(patchBody) };
|
|
723
|
+
if (params) generated.update.params = cloneShallow(params);
|
|
724
|
+
}
|
|
725
|
+
if (params) {
|
|
726
|
+
generated.get = { params: cloneShallow(params) };
|
|
727
|
+
generated.delete = { params: cloneShallow(params) };
|
|
728
|
+
if (!generated.update) generated.update = { params: cloneShallow(params) };
|
|
729
|
+
else if (!generated.update.params) generated.update.params = cloneShallow(params);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
let schemas = Object.keys(generated).length > 0 ? generated : null;
|
|
733
|
+
if (customSchemas && Object.keys(customSchemas).length > 0) {
|
|
734
|
+
schemas = schemas ?? {};
|
|
735
|
+
for (const [op, customSchema] of Object.entries(customSchemas)) {
|
|
736
|
+
const key = op;
|
|
737
|
+
const converted = convertRouteSchema(customSchema);
|
|
738
|
+
schemas[key] = schemas[key] ? deepMergeSchemas(schemas[key], converted) : converted;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return schemas;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Normalize the listQuery JSON Schema for Fastify/AJV strict-mode use.
|
|
745
|
+
*
|
|
746
|
+
* The `qs` parser turns bracket notation into nested objects/arrays:
|
|
747
|
+
* `?name[contains]=foo` → `{ name: { contains: "foo" } }`
|
|
748
|
+
* `?tags[]=a&tags[]=b` → `{ tags: ["a", "b"] }`
|
|
749
|
+
* `?populate[author][select]=name` → deeply nested object
|
|
750
|
+
*
|
|
751
|
+
* AJV rejects these against the OpenAPI-friendly `type: "string"`
|
|
752
|
+
* declarations adapters generate. The QueryParser is the source of truth
|
|
753
|
+
* for filter validation/coercion, so we replace each property with a
|
|
754
|
+
* minimal AJV-strict-mode-clean shape: numeric pagination keys keep
|
|
755
|
+
* `type: "integer"` (so `minimum`/`maximum` don't trigger AJV warnings),
|
|
756
|
+
* everything else collapses to `{}`.
|
|
757
|
+
*
|
|
758
|
+
* The original `limit.maximum` is preserved so the parser's configured
|
|
759
|
+
* max stays effective at the AJV layer.
|
|
760
|
+
*/
|
|
761
|
+
function normalizeListQuerySchema(listQuerySchema) {
|
|
762
|
+
const NORMALIZED_PROPS = {
|
|
763
|
+
page: {
|
|
764
|
+
type: "integer",
|
|
765
|
+
minimum: 1
|
|
766
|
+
},
|
|
767
|
+
limit: {
|
|
768
|
+
type: "integer",
|
|
769
|
+
minimum: 1
|
|
770
|
+
},
|
|
771
|
+
sort: {},
|
|
772
|
+
search: {},
|
|
773
|
+
select: {},
|
|
774
|
+
after: {},
|
|
775
|
+
populate: {},
|
|
776
|
+
lookup: {},
|
|
777
|
+
aggregate: {}
|
|
778
|
+
};
|
|
779
|
+
const props = listQuerySchema.properties;
|
|
780
|
+
const normalizedProps = props ? { ...props } : void 0;
|
|
781
|
+
if (normalizedProps) {
|
|
782
|
+
const originalLimit = normalizedProps.limit;
|
|
783
|
+
if (originalLimit?.maximum) NORMALIZED_PROPS.limit = {
|
|
784
|
+
...NORMALIZED_PROPS.limit,
|
|
785
|
+
maximum: originalLimit.maximum
|
|
786
|
+
};
|
|
787
|
+
for (const key of Object.keys(normalizedProps)) normalizedProps[key] = NORMALIZED_PROPS[key] ?? {};
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
...listQuerySchema,
|
|
791
|
+
...normalizedProps ? { properties: normalizedProps } : {},
|
|
792
|
+
additionalProperties: listQuerySchema.additionalProperties ?? true
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Merge two JSON schema branches deeply. Arrays are unioned with
|
|
797
|
+
* deduplication (so combined `required` lists don't duplicate field
|
|
798
|
+
* names); plain-object keys recurse; primitives are overwritten.
|
|
799
|
+
*
|
|
800
|
+
* Exported for the action/CRUD router-config code paths that need to
|
|
801
|
+
* compose user overrides on top of generated schemas.
|
|
802
|
+
*/
|
|
803
|
+
function deepMergeSchemas(base, override) {
|
|
804
|
+
if (!override) return base;
|
|
805
|
+
if (!base) return override;
|
|
806
|
+
const result = { ...base };
|
|
807
|
+
for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [...new Set([...result[key], ...value])];
|
|
808
|
+
else if (value && typeof value === "object" && !Array.isArray(value)) result[key] = deepMergeSchemas(result[key], value);
|
|
809
|
+
else result[key] = value;
|
|
810
|
+
return result;
|
|
811
|
+
}
|
|
812
|
+
function cloneShallow(value) {
|
|
813
|
+
return { ...value };
|
|
814
|
+
}
|
|
815
|
+
function safeBody(schema) {
|
|
816
|
+
if (schema && typeof schema === "object" && schema.type === "object") return {
|
|
817
|
+
additionalProperties: true,
|
|
818
|
+
...schema
|
|
819
|
+
};
|
|
820
|
+
return schema;
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Normalize `ActionsMap` into the `ActionRouterConfig` shape that
|
|
824
|
+
* `createActionRouter` expects.
|
|
825
|
+
*
|
|
826
|
+
* **Permission fallback chain (fail-closed, v2.10.5):** delegated to the
|
|
827
|
+
* shared resolver in `actionPermissions.ts`. The resource-level gate
|
|
828
|
+
* goes into the resolver's slot 2 (`resourceActionPermissions`) — its
|
|
829
|
+
* semantic home — leaving slot 3 (`globalAuth`) reserved for direct
|
|
830
|
+
* `createActionRouter` callers that genuinely have a router-wide gate.
|
|
831
|
+
*
|
|
832
|
+
* The returned `actionPermissions` map is FULLY RESOLVED per action.
|
|
833
|
+
* Earlier versions returned a sparse map plus a `globalAuth` field that
|
|
834
|
+
* `createActionRouter` flattened at request time via `?? globalAuth`.
|
|
835
|
+
* That conflated two different layers in the resolver chain (slot 2 vs.
|
|
836
|
+
* slot 3). Boot-time resolution closes the drift: every action that
|
|
837
|
+
* survives this pass has its effective gate baked into the map, and
|
|
838
|
+
* `createActionRouter`'s request-time `?? globalAuth` becomes a no-op
|
|
839
|
+
* for the defineResource path.
|
|
840
|
+
*/
|
|
841
|
+
function normalizeActionsToRouterConfig(actions, resourceActionPermissions, tag, resourcePermissions, resourceName, log) {
|
|
842
|
+
const handlers = {};
|
|
843
|
+
const permissions = {};
|
|
844
|
+
const schemas = {};
|
|
845
|
+
for (const [name, entry] of Object.entries(actions)) {
|
|
846
|
+
const explicit = typeof entry !== "function" && entry.permissions ? entry.permissions : void 0;
|
|
847
|
+
if (typeof entry === "function") handlers[name] = entry;
|
|
848
|
+
else {
|
|
849
|
+
const def = entry;
|
|
850
|
+
handlers[name] = def.handler;
|
|
851
|
+
if (def.schema) schemas[name] = def.schema;
|
|
852
|
+
}
|
|
853
|
+
const effective = resolveActionPermission({
|
|
854
|
+
action: entry,
|
|
855
|
+
resourcePermissions,
|
|
856
|
+
resourceActionPermissions,
|
|
857
|
+
globalAuth: void 0
|
|
858
|
+
});
|
|
859
|
+
if (!explicit && !resourceActionPermissions && effective && effective === resourcePermissions?.update) log?.warn?.({
|
|
860
|
+
resource: resourceName,
|
|
861
|
+
action: name,
|
|
862
|
+
fallback: "permissions.update"
|
|
863
|
+
}, `[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.`);
|
|
864
|
+
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.`);
|
|
865
|
+
permissions[name] = effective;
|
|
866
|
+
}
|
|
867
|
+
return {
|
|
868
|
+
tag,
|
|
869
|
+
actions: handlers,
|
|
870
|
+
actionPermissions: permissions,
|
|
871
|
+
actionSchemas: schemas
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Build the FastifyPluginAsync that materialises a `ResourceDefinition`
|
|
876
|
+
* into routes, hooks, registry entries, and cache invalidation rules.
|
|
877
|
+
*
|
|
878
|
+
* Called once per `ResourceDefinition.toPlugin()`. The returned plugin
|
|
879
|
+
* function captures `resource` in its closure and can be `app.register`-ed
|
|
880
|
+
* any number of times — shared-state writes are idempotent per host
|
|
881
|
+
* Fastify instance via `resource._sharedStateRegisteredOn`.
|
|
882
|
+
*/
|
|
883
|
+
function buildResourcePlugin(resource) {
|
|
884
|
+
return async function resourcePlugin(fastify, _opts) {
|
|
885
|
+
const sharedRoot = fastify.server ?? fastify;
|
|
886
|
+
const isFirstMount = !resource._sharedStateRegisteredOn.has(sharedRoot);
|
|
887
|
+
if (isFirstMount) resource._sharedStateRegisteredOn.add(sharedRoot);
|
|
888
|
+
const arc = fastify.arc;
|
|
889
|
+
if (isFirstMount && arc?.registry && resource._registryMeta) try {
|
|
890
|
+
arc.registry.register(resource, resource._registryMeta);
|
|
891
|
+
} catch (err) {
|
|
892
|
+
fastify.log?.warn?.(`Failed to register resource '${resource.name}' in registry: ${err instanceof Error ? err.message : err}`);
|
|
893
|
+
}
|
|
894
|
+
if (isFirstMount && resource._pendingHooks.length > 0 && arc?.hooks) for (const hook of resource._pendingHooks) arc.hooks.register({
|
|
895
|
+
resource: resource.name,
|
|
896
|
+
operation: hook.operation,
|
|
897
|
+
phase: hook.phase,
|
|
898
|
+
handler: hook.handler,
|
|
899
|
+
priority: hook.priority
|
|
900
|
+
});
|
|
901
|
+
const registerRule = fastify.registerCacheInvalidationRule;
|
|
902
|
+
if (isFirstMount && resource.cache?.invalidateOn && typeof registerRule === "function") for (const [pattern, tags] of Object.entries(resource.cache.invalidateOn)) registerRule({
|
|
903
|
+
pattern,
|
|
904
|
+
tags
|
|
905
|
+
});
|
|
906
|
+
await fastify.register(async (instance) => {
|
|
907
|
+
const typedInstance = instance;
|
|
908
|
+
let schemas = buildGeneratedCrudSchemas(resource._registryMeta?.openApiSchemas, resource.customSchemas);
|
|
909
|
+
const listQuerySchema = resource._registryMeta?.openApiSchemas?.listQuery;
|
|
910
|
+
if (listQuerySchema) {
|
|
911
|
+
const normalizedSchema = normalizeListQuerySchema(listQuerySchema);
|
|
912
|
+
schemas = schemas ?? {};
|
|
913
|
+
schemas.list = schemas.list ? deepMergeSchemas({ querystring: normalizedSchema }, schemas.list) : { querystring: normalizedSchema };
|
|
914
|
+
}
|
|
915
|
+
createCrudRouter(typedInstance, resource.controller, {
|
|
916
|
+
tag: resource.tag,
|
|
917
|
+
schemas: schemas ?? void 0,
|
|
918
|
+
permissions: resource.permissions,
|
|
919
|
+
middlewares: resource.middlewares,
|
|
920
|
+
routeGuards: resource.routeGuards,
|
|
921
|
+
routes: resource.routes,
|
|
922
|
+
disableDefaultRoutes: resource.disableDefaultRoutes,
|
|
923
|
+
disabledRoutes: [...resource.disabledRoutes],
|
|
924
|
+
resourceName: resource.name,
|
|
925
|
+
schemaOptions: resource.schemaOptions,
|
|
926
|
+
rateLimit: resource.rateLimit,
|
|
927
|
+
updateMethod: resource.updateMethod,
|
|
928
|
+
pipe: resource.pipe,
|
|
929
|
+
fields: resource.fields
|
|
930
|
+
});
|
|
931
|
+
if (resource.actions && Object.keys(resource.actions).length > 0) {
|
|
932
|
+
const { createActionRouter } = await import("./createActionRouter-CEvzKcy8.mjs").then((n) => n.n);
|
|
933
|
+
createActionRouter(typedInstance, {
|
|
934
|
+
...normalizeActionsToRouterConfig(resource.actions, resource.actionPermissions, resource.tag, resource.permissions, resource.name, typedInstance.log),
|
|
935
|
+
resourceName: resource.name,
|
|
936
|
+
fields: resource.fields,
|
|
937
|
+
schemaOptions: resource.schemaOptions,
|
|
938
|
+
permissions: resource.permissions,
|
|
939
|
+
routeGuards: resource.routeGuards,
|
|
940
|
+
pipeline: resource.pipe,
|
|
941
|
+
rateLimit: resource.rateLimit
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
|
|
945
|
+
const { createAggregationRouter } = await import("./createAggregationRouter-CyecOxnO.mjs");
|
|
946
|
+
const repoForAgg = resource.controller?.repository;
|
|
947
|
+
const buildOptions = (req) => {
|
|
948
|
+
return resource.controller?.tenantRepoOptions?.(req) ?? {};
|
|
949
|
+
};
|
|
950
|
+
createAggregationRouter(typedInstance, {
|
|
951
|
+
tag: resource.tag,
|
|
952
|
+
resourceName: resource.name,
|
|
953
|
+
aggregations: resource.aggregations,
|
|
954
|
+
fields: resource.fields,
|
|
955
|
+
schemaOptions: resource.schemaOptions,
|
|
956
|
+
permissions: resource.permissions,
|
|
957
|
+
routeGuards: resource.routeGuards,
|
|
958
|
+
repository: repoForAgg,
|
|
959
|
+
buildOptions
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
if (resource.events && Object.keys(resource.events).length > 0) typedInstance.log?.debug?.(`Resource '${resource.name}' defined ${Object.keys(resource.events).length} events`);
|
|
963
|
+
}, { prefix: resource.prefix });
|
|
964
|
+
if (hasEvents(fastify)) try {
|
|
965
|
+
await fastify.events.publish("arc.resource.registered", {
|
|
966
|
+
resource: resource.name,
|
|
967
|
+
prefix: resource.prefix,
|
|
968
|
+
presets: resource._appliedPresets,
|
|
969
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
970
|
+
});
|
|
971
|
+
} catch {}
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/core/defineResource/ResourceDefinition.ts
|
|
976
|
+
var ResourceDefinition = class {
|
|
977
|
+
name;
|
|
978
|
+
displayName;
|
|
979
|
+
tag;
|
|
980
|
+
prefix;
|
|
981
|
+
skipGlobalPrefix;
|
|
982
|
+
adapter;
|
|
983
|
+
controller;
|
|
984
|
+
schemaOptions;
|
|
985
|
+
customSchemas;
|
|
986
|
+
permissions;
|
|
987
|
+
routes;
|
|
988
|
+
middlewares;
|
|
989
|
+
routeGuards;
|
|
990
|
+
disableDefaultRoutes;
|
|
991
|
+
disabledRoutes;
|
|
992
|
+
actions;
|
|
993
|
+
actionPermissions;
|
|
994
|
+
aggregations;
|
|
995
|
+
events;
|
|
996
|
+
rateLimit;
|
|
997
|
+
audit;
|
|
998
|
+
updateMethod;
|
|
999
|
+
pipe;
|
|
1000
|
+
fields;
|
|
1001
|
+
cache;
|
|
1002
|
+
tenantField;
|
|
1003
|
+
idField;
|
|
1004
|
+
queryParser;
|
|
1005
|
+
_appliedPresets;
|
|
1006
|
+
_pendingHooks;
|
|
1007
|
+
_registryMeta;
|
|
1008
|
+
/**
|
|
1009
|
+
* Per-host idempotency guard used by `buildResourcePlugin` to
|
|
1010
|
+
* skip duplicate shared-state writes when the same resource is
|
|
1011
|
+
* mounted at multiple prefixes (`/v1`, `/v2`). See the plugin
|
|
1012
|
+
* file for the full rationale; surfaced here as `readonly` so
|
|
1013
|
+
* the helper can consult it without a class-method indirection.
|
|
1014
|
+
*/
|
|
1015
|
+
_sharedStateRegisteredOn = /* @__PURE__ */ new WeakSet();
|
|
1016
|
+
constructor(config) {
|
|
1017
|
+
this.name = config.name;
|
|
1018
|
+
this.displayName = config.displayName ?? `${capitalize(config.name)}s`;
|
|
1019
|
+
this.tag = config.tag ?? this.displayName;
|
|
1020
|
+
this.prefix = config.prefix ?? `/${config.name}s`;
|
|
1021
|
+
this.skipGlobalPrefix = config.skipGlobalPrefix ?? false;
|
|
1022
|
+
this.adapter = config.adapter;
|
|
1023
|
+
this.controller = config.controller;
|
|
1024
|
+
this.schemaOptions = Object.freeze({ ...config.schemaOptions ?? {} });
|
|
1025
|
+
this.customSchemas = Object.freeze({ ...config.customSchemas ?? {} });
|
|
1026
|
+
this.permissions = Object.freeze({ ...config.permissions ?? {} });
|
|
1027
|
+
this.routes = freezeRoutes(config.routes);
|
|
1028
|
+
this.disabledRoutes = Object.freeze([...config.disabledRoutes ?? []]);
|
|
1029
|
+
this.events = Object.freeze({ ...config.events ?? {} });
|
|
1030
|
+
this.middlewares = config.middlewares ?? {};
|
|
1031
|
+
this.routeGuards = config.routeGuards;
|
|
1032
|
+
this.disableDefaultRoutes = config.disableDefaultRoutes ?? false;
|
|
1033
|
+
this.actions = freezeActions(config.actions);
|
|
1034
|
+
this.actionPermissions = config.actionPermissions;
|
|
1035
|
+
this.aggregations = config.aggregations;
|
|
1036
|
+
this.rateLimit = config.rateLimit;
|
|
1037
|
+
this.audit = config.audit;
|
|
1038
|
+
this.updateMethod = config.updateMethod;
|
|
1039
|
+
this.pipe = config.pipe;
|
|
1040
|
+
this.fields = config.fields;
|
|
1041
|
+
this.cache = config.cache;
|
|
1042
|
+
this.tenantField = config.tenantField;
|
|
1043
|
+
this.idField = config.idField;
|
|
1044
|
+
this.queryParser = config.queryParser;
|
|
1045
|
+
this._appliedPresets = config._appliedPresets ?? [];
|
|
1046
|
+
this._pendingHooks = config._pendingHooks ?? [];
|
|
1047
|
+
}
|
|
1048
|
+
/** Repository accessor — pulled off the adapter when one is wired. */
|
|
1049
|
+
get repository() {
|
|
1050
|
+
return this.adapter?.repository;
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Validate that the wired controller implements every method
|
|
1054
|
+
* needed by enabled CRUD routes + every string-handler custom
|
|
1055
|
+
* route. Runs at the end of `defineResource()` (skippable via
|
|
1056
|
+
* `skipValidation: true`) so misconfigured resources fail at
|
|
1057
|
+
* boot, not on first request.
|
|
1058
|
+
*/
|
|
1059
|
+
_validateControllerMethods() {
|
|
1060
|
+
const errors = [];
|
|
1061
|
+
const enabledCrudRoutes = CRUD_OPERATIONS.filter((route) => !this.disabledRoutes.includes(route));
|
|
1062
|
+
if (!this.disableDefaultRoutes && enabledCrudRoutes.length > 0) if (!this.controller) errors.push("Controller is required when CRUD routes are enabled");
|
|
1063
|
+
else {
|
|
1064
|
+
const ctrl = this.controller;
|
|
1065
|
+
for (const method of enabledCrudRoutes) if (typeof ctrl[method] !== "function") errors.push(`CRUD method '${method}' not found on controller`);
|
|
1066
|
+
}
|
|
1067
|
+
for (const route of this.routes) {
|
|
1068
|
+
if (typeof route.handler !== "string") continue;
|
|
1069
|
+
if (!this.controller) {
|
|
1070
|
+
errors.push(`Route ${route.method} ${route.path}: string handler '${route.handler}' requires a controller`);
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
if (typeof this.controller[route.handler] !== "function") errors.push(`Route ${route.method} ${route.path}: handler '${route.handler}' not found`);
|
|
1074
|
+
}
|
|
1075
|
+
if (errors.length === 0) return;
|
|
1076
|
+
throw new Error([
|
|
1077
|
+
`Resource '${this.name}' validation failed:`,
|
|
1078
|
+
...errors.map((e) => ` - ${e}`),
|
|
1079
|
+
"",
|
|
1080
|
+
"Ensure controller implements IController<TDoc> interface.",
|
|
1081
|
+
"For preset routes (softDelete, tree), add corresponding methods to controller."
|
|
1082
|
+
].join("\n"));
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Build the Fastify plugin that materialises this resource into
|
|
1086
|
+
* routes, hooks, registry entries, and cache invalidation rules.
|
|
1087
|
+
* One-line delegate — the implementation lives in `./plugin.ts`.
|
|
1088
|
+
*/
|
|
1089
|
+
toPlugin() {
|
|
1090
|
+
return buildResourcePlugin(this);
|
|
1091
|
+
}
|
|
1092
|
+
/** Event definitions for registry consumption. */
|
|
1093
|
+
getEvents() {
|
|
1094
|
+
return Object.entries(this.events).map(([action, meta]) => ({
|
|
1095
|
+
name: `${this.name}:${action}`,
|
|
1096
|
+
module: this.name,
|
|
1097
|
+
schema: meta.schema,
|
|
1098
|
+
description: meta.description
|
|
1099
|
+
}));
|
|
1100
|
+
}
|
|
1101
|
+
/** Resource metadata — shape consumed by registry / introspection. */
|
|
1102
|
+
getMetadata() {
|
|
1103
|
+
return {
|
|
1104
|
+
name: this.name,
|
|
1105
|
+
displayName: this.displayName,
|
|
1106
|
+
tag: this.tag,
|
|
1107
|
+
prefix: this.prefix,
|
|
1108
|
+
presets: this._appliedPresets,
|
|
1109
|
+
permissions: this.permissions,
|
|
1110
|
+
customRoutes: this.routes.map((r) => ({
|
|
1111
|
+
method: r.method,
|
|
1112
|
+
path: r.path,
|
|
1113
|
+
handler: typeof r.handler === "string" ? r.handler : r.handler.name || "anonymous",
|
|
1114
|
+
operation: r.operation,
|
|
1115
|
+
summary: r.summary,
|
|
1116
|
+
description: r.description,
|
|
1117
|
+
permissions: r.permissions,
|
|
1118
|
+
raw: r.raw,
|
|
1119
|
+
schema: r.schema
|
|
1120
|
+
})),
|
|
1121
|
+
routes: [],
|
|
1122
|
+
events: Object.keys(this.events)
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
function capitalize(str) {
|
|
1127
|
+
if (!str) return "";
|
|
1128
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Freeze the routes array AND each route object inside it. Catches
|
|
1132
|
+
* `resource.routes[0].permissions = bypass` and equivalent post-
|
|
1133
|
+
* define mutations that would silently rewire the registered surface.
|
|
1134
|
+
*
|
|
1135
|
+
* Each route is shallow-copied before freezing so the host's
|
|
1136
|
+
* original route object stays mutable (consistent with how the
|
|
1137
|
+
* top-level config slots are treated).
|
|
1138
|
+
*/
|
|
1139
|
+
function freezeRoutes(routes) {
|
|
1140
|
+
const list = (routes ?? []).map((route) => Object.freeze({ ...route }));
|
|
1141
|
+
return Object.freeze(list);
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Freeze the actions map AND each action entry. Function-shorthand
|
|
1145
|
+
* actions (`async (id, data, req) => ...`) need no per-entry freeze
|
|
1146
|
+
* — function references are immutable in practice; you can't mutate
|
|
1147
|
+
* a closure post-hoc. Object-form `ActionDefinition` entries DO need
|
|
1148
|
+
* a freeze so `actions.send.permissions = bypass` throws.
|
|
1149
|
+
*/
|
|
1150
|
+
function freezeActions(actions) {
|
|
1151
|
+
if (!actions) return void 0;
|
|
1152
|
+
const frozen = {};
|
|
1153
|
+
for (const [name, entry] of Object.entries(actions)) frozen[name] = typeof entry === "function" ? entry : Object.freeze({ ...entry });
|
|
1154
|
+
return Object.freeze(frozen);
|
|
1155
|
+
}
|
|
1156
|
+
//#endregion
|
|
1157
|
+
//#region src/core/defineResource/schemas.ts
|
|
1158
|
+
/**
|
|
1159
|
+
* OpenAPI schema resolution — Phase 7 of `defineResource()`.
|
|
1160
|
+
*
|
|
1161
|
+
* Pipeline (each step is a pure function over `OpenApiSchemas | undefined`):
|
|
1162
|
+
*
|
|
1163
|
+
* adapter.generateSchemas()
|
|
1164
|
+
* → stripSystemManagedFromBodyRequired (from `../schemaOptions.js`)
|
|
1165
|
+
* → cleanLegacyObjectIdParams (idField safety net)
|
|
1166
|
+
* → layerQueryParserListQuery (kit's listQuery JSON Schema)
|
|
1167
|
+
* → mergeUserOpenApiOverrides (per-resource overrides)
|
|
1168
|
+
* → convertOpenApiSchemas (Zod → JSON Schema if needed)
|
|
1169
|
+
*
|
|
1170
|
+
* Non-fatal: if any step throws, the orchestrator returns `undefined` so
|
|
1171
|
+
* the resource still boots — docs / introspection / MCP tool schemas
|
|
1172
|
+
* degrade visibly instead of silently drifting.
|
|
1173
|
+
*
|
|
1174
|
+
* Pulled out of `defineResource.ts` so the central function reads as
|
|
1175
|
+
* orchestration only; the schema mechanics live next to each other and
|
|
1176
|
+
* are easier to evolve in isolation.
|
|
1177
|
+
*/
|
|
1178
|
+
/**
|
|
1179
|
+
* Phase 7 orchestrator — runs the schema pipeline and returns the
|
|
1180
|
+
* registry metadata for the resource. Returns `undefined` (with a
|
|
1181
|
+
* structured warn log) if any step throws.
|
|
1182
|
+
*/
|
|
1183
|
+
function resolveOpenApiSchemas(resolvedConfig) {
|
|
1184
|
+
try {
|
|
1185
|
+
let openApiSchemas = generateAdapterSchemas(resolvedConfig);
|
|
1186
|
+
openApiSchemas = stripSystemManagedFromBodyRequired(openApiSchemas, resolvedConfig.schemaOptions);
|
|
1187
|
+
openApiSchemas = cleanLegacyObjectIdParams(openApiSchemas, resolvedConfig.idField);
|
|
1188
|
+
openApiSchemas = layerQueryParserListQuery(openApiSchemas, resolvedConfig.queryParser);
|
|
1189
|
+
openApiSchemas = mergeUserOpenApiOverrides(openApiSchemas, resolvedConfig.openApiSchemas);
|
|
1190
|
+
if (openApiSchemas) openApiSchemas = convertOpenApiSchemas(openApiSchemas);
|
|
1191
|
+
return {
|
|
1192
|
+
module: resolvedConfig.module,
|
|
1193
|
+
openApiSchemas
|
|
1194
|
+
};
|
|
1195
|
+
} catch (err) {
|
|
1196
|
+
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.`);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Step 1 — delegate to the adapter's `generateSchemas`. Returns
|
|
1202
|
+
* `undefined` when the adapter doesn't implement the optional method.
|
|
1203
|
+
*/
|
|
1204
|
+
function generateAdapterSchemas(resolvedConfig) {
|
|
1205
|
+
if (!resolvedConfig.adapter?.generateSchemas) return void 0;
|
|
1206
|
+
const adapterContext = {
|
|
1207
|
+
idField: resolvedConfig.idField,
|
|
1208
|
+
resourceName: resolvedConfig.name
|
|
1209
|
+
};
|
|
1210
|
+
return resolvedConfig.adapter.generateSchemas(resolvedConfig.schemaOptions, adapterContext);
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Safety net: when `idField` is overridden to a non-default value (UUIDs,
|
|
1214
|
+
* slugs, ORD-2026-0001), strip any ObjectId pattern left on `params.id` by
|
|
1215
|
+
* legacy adapters or plugins that didn't honor `AdapterSchemaContext.idField`.
|
|
1216
|
+
* Custom IDs must not be rejected by AJV before BaseController runs the
|
|
1217
|
+
* actual lookup.
|
|
1218
|
+
*/
|
|
1219
|
+
function cleanLegacyObjectIdParams(openApiSchemas, idField) {
|
|
1220
|
+
if (!openApiSchemas || !idField || idField === "_id") return openApiSchemas;
|
|
1221
|
+
const params = openApiSchemas.params;
|
|
1222
|
+
if (!params || typeof params !== "object") return openApiSchemas;
|
|
1223
|
+
const properties = params.properties;
|
|
1224
|
+
const idProp = properties?.id;
|
|
1225
|
+
if (!idProp || typeof idProp !== "object") return openApiSchemas;
|
|
1226
|
+
const pattern = idProp.pattern;
|
|
1227
|
+
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;
|
|
1228
|
+
const cleanedId = { ...idProp };
|
|
1229
|
+
delete cleanedId.pattern;
|
|
1230
|
+
delete cleanedId.minLength;
|
|
1231
|
+
delete cleanedId.maxLength;
|
|
1232
|
+
if (!cleanedId.description) cleanedId.description = `${idField} (custom ID field)`;
|
|
1233
|
+
return {
|
|
1234
|
+
...openApiSchemas,
|
|
1235
|
+
params: {
|
|
1236
|
+
...params,
|
|
1237
|
+
properties: {
|
|
1238
|
+
...properties,
|
|
1239
|
+
id: cleanedId
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Layer the query parser's `getQuerySchema()` output as `listQuery` so
|
|
1246
|
+
* the kit's filterable-fields surface flows into OpenAPI / MCP without
|
|
1247
|
+
* the user re-declaring it.
|
|
1248
|
+
*/
|
|
1249
|
+
function layerQueryParserListQuery(openApiSchemas, queryParser) {
|
|
1250
|
+
const qp = queryParser;
|
|
1251
|
+
if (!qp?.getQuerySchema) return openApiSchemas;
|
|
1252
|
+
const querySchema = qp.getQuerySchema();
|
|
1253
|
+
if (!querySchema) return openApiSchemas;
|
|
1254
|
+
return {
|
|
1255
|
+
...openApiSchemas,
|
|
1256
|
+
listQuery: querySchema
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Apply per-resource `openApiSchemas` overrides on top of the kit's
|
|
1261
|
+
* generated schemas. Shallow merge by slot — users who want field-level
|
|
1262
|
+
* surgery should compose at the schema-options layer before this point.
|
|
1263
|
+
*/
|
|
1264
|
+
function mergeUserOpenApiOverrides(openApiSchemas, userOverrides) {
|
|
1265
|
+
if (!userOverrides) return openApiSchemas;
|
|
1266
|
+
return {
|
|
1267
|
+
...openApiSchemas,
|
|
1268
|
+
...userOverrides
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
//#endregion
|
|
1272
|
+
//#region src/core/defineResource/validate.ts
|
|
1273
|
+
/**
|
|
1274
|
+
* CRUD op names — kept module-scope (vs allocated per `defineResource()`
|
|
1275
|
+
* call) since the set is fixed and the cost of re-allocating is a
|
|
1276
|
+
* pointless boot tax for hosts with hundreds of resources.
|
|
1277
|
+
*/
|
|
1278
|
+
const CRUD_OP_NAMES = new Set([
|
|
1279
|
+
"create",
|
|
1280
|
+
"update",
|
|
1281
|
+
"delete",
|
|
1282
|
+
"list",
|
|
1283
|
+
"get"
|
|
1284
|
+
]);
|
|
1285
|
+
/**
|
|
1286
|
+
* Run the structural validation pipeline. Throws an `Error` with a
|
|
1287
|
+
* resource-named message on the first failure — `defineResource()`
|
|
1288
|
+
* surfaces it verbatim so hosts get a clear "fix this resource"
|
|
1289
|
+
* pointer.
|
|
1290
|
+
*/
|
|
1291
|
+
function validateDefineResourceConfig(config) {
|
|
1292
|
+
assertValidConfig(config, { skipControllerCheck: true });
|
|
1293
|
+
validatePermissionsShape(config);
|
|
1294
|
+
validateCustomRoutePermissions(config);
|
|
1295
|
+
validateActionsShape(config);
|
|
1296
|
+
}
|
|
1297
|
+
/** Permissions must be `PermissionCheck` functions, not arbitrary values. */
|
|
1298
|
+
function validatePermissionsShape(config) {
|
|
1299
|
+
if (!config.permissions) return;
|
|
1300
|
+
for (const [key, value] of Object.entries(config.permissions)) if (value !== void 0 && typeof value !== "function") throw new Error(`[Arc] Resource '${config.name}': permissions.${key} must be a PermissionCheck function.\nUse allowPublic(), requireAuth(), or requireRoles(['role']) from @classytic/arc/permissions.`);
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Custom routes must declare `permissions` as a function — fail-closed
|
|
1304
|
+
* default. A missing `permissions` could otherwise quietly mount an
|
|
1305
|
+
* unauthenticated route.
|
|
1306
|
+
*/
|
|
1307
|
+
function validateCustomRoutePermissions(config) {
|
|
1308
|
+
for (const route of config.routes ?? []) if (typeof route.permissions !== "function") throw new Error(`[Arc] Resource '${config.name}' route ${route.method} ${route.path}: permissions is required and must be a PermissionCheck function.`);
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Actions (v2.8) — name must not collide with CRUD ops; handler +
|
|
1312
|
+
* permissions must have the right shapes. Fail at boot so production
|
|
1313
|
+
* never ships a misconfigured action endpoint.
|
|
1314
|
+
*/
|
|
1315
|
+
function validateActionsShape(config) {
|
|
1316
|
+
if (!config.actions) return;
|
|
1317
|
+
for (const [name, entry] of Object.entries(config.actions)) {
|
|
1318
|
+
if (CRUD_OP_NAMES.has(name)) throw new Error(`[Arc] Resource '${config.name}': action '${name}' conflicts with CRUD operation.\nUse a different name (e.g., '${name}_item', 'do_${name}').`);
|
|
1319
|
+
if (typeof entry !== "function") {
|
|
1320
|
+
const def = entry;
|
|
1321
|
+
if (typeof def.handler !== "function") throw new Error(`[Arc] Resource '${config.name}': actions.${name}.handler must be a function.`);
|
|
1322
|
+
if (def.permissions !== void 0 && typeof def.permissions !== "function") throw new Error(`[Arc] Resource '${config.name}': actions.${name}.permissions must be a PermissionCheck function.`);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
//#endregion
|
|
1327
|
+
//#region src/core/defineResource.ts
|
|
1328
|
+
/**
|
|
1329
|
+
* `TDoc` is **unconstrained** at this layer. The previous `TDoc
|
|
1330
|
+
* extends AnyRecord` bound leaked out of `BaseController`'s
|
|
1331
|
+
* mixin-composition requirement into every host's adapter boundary:
|
|
1332
|
+
* Mongoose's `HydratedDocument<T>`, Prisma's generated row types,
|
|
1333
|
+
* and any domain interface without an explicit index signature all
|
|
1334
|
+
* failed to satisfy `Record<string, unknown>` even though at runtime
|
|
1335
|
+
* they ARE string-keyed objects. Hosts were forced to cast at every
|
|
1336
|
+
* adapter (`as RepositoryLike<Record<string, unknown>>`) — a type
|
|
1337
|
+
* escape with no runtime purpose, since arc's pipeline only reads
|
|
1338
|
+
* known envelope fields.
|
|
1339
|
+
*
|
|
1340
|
+
* The cast moved inside `resolveOrAutoCreateController` where
|
|
1341
|
+
* `BaseController<TDoc extends AnyRecord>` actually requires it.
|
|
1342
|
+
* One internal boundary cast replaces N host-side casts.
|
|
1343
|
+
*/
|
|
1344
|
+
function defineResource(config) {
|
|
1345
|
+
if (!config.skipValidation) validateDefineResourceConfig(config);
|
|
1346
|
+
const repository = config.adapter?.repository;
|
|
1347
|
+
const configWithId = resolveIdField(config, repository);
|
|
1348
|
+
const resolvedConfig = applyPresetsAndAutoInject(configWithId);
|
|
1349
|
+
const hasCrudRoutes = computeHasCrudRoutes(resolvedConfig);
|
|
1350
|
+
const narrowedConfig = resolvedConfig;
|
|
1351
|
+
const narrowedAdapter = configWithId.adapter;
|
|
1352
|
+
const controller = resolveOrAutoCreateController(narrowedConfig, narrowedAdapter, repository, hasCrudRoutes);
|
|
1353
|
+
const resource = new ResourceDefinition({
|
|
1354
|
+
...resolvedConfig,
|
|
1355
|
+
adapter: configWithId.adapter,
|
|
1356
|
+
controller
|
|
1357
|
+
});
|
|
1358
|
+
if (!config.skipValidation && controller) resource._validateControllerMethods();
|
|
1359
|
+
wireHooks(resource, narrowedConfig, configWithId.hooks);
|
|
1360
|
+
if (!config.skipRegistry) {
|
|
1361
|
+
const registryMeta = resolveOpenApiSchemas(narrowedConfig);
|
|
1362
|
+
if (registryMeta) resource._registryMeta = registryMeta;
|
|
1363
|
+
}
|
|
1364
|
+
return resource;
|
|
1365
|
+
}
|
|
1366
|
+
//#endregion
|
|
1367
|
+
//#region src/core/defineResourceVariants.ts
|
|
1368
|
+
/**
|
|
1369
|
+
* Define multiple resources from a shared base config and per-variant overrides.
|
|
1370
|
+
*
|
|
1371
|
+
* Each variant is independently passed through `defineResource()` — the
|
|
1372
|
+
* returned `ResourceDefinition`s are real, fully-registered resources.
|
|
1373
|
+
* Register each one's plugin in your app:
|
|
1374
|
+
*
|
|
1375
|
+
* ```typescript
|
|
1376
|
+
* await app.register(articlePublic.toPlugin());
|
|
1377
|
+
* await app.register(articleAdmin.toPlugin());
|
|
1378
|
+
* ```
|
|
1379
|
+
*
|
|
1380
|
+
* @param base Shared config — adapter, queryParser, schemaOptions, hooks, etc.
|
|
1381
|
+
* Must NOT include `name` or `prefix` (those are per-variant).
|
|
1382
|
+
* @param variants Map of variant key → override. Each variant must declare
|
|
1383
|
+
* its own `name` and `prefix`. Other fields override the base.
|
|
1384
|
+
* @returns A record where each key from `variants` maps to a real
|
|
1385
|
+
* `ResourceDefinition` ready for `.toPlugin()` registration.
|
|
1386
|
+
*/
|
|
1387
|
+
function defineResourceVariants(base, variants) {
|
|
1388
|
+
const out = {};
|
|
1389
|
+
for (const key of Object.keys(variants)) {
|
|
1390
|
+
const override = variants[key];
|
|
1391
|
+
out[key] = defineResource({
|
|
1392
|
+
...base,
|
|
1393
|
+
...override
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
return out;
|
|
1397
|
+
}
|
|
1398
|
+
//#endregion
|
|
1399
|
+
export { createPermissionMiddleware as a, createCrudRouter as i, defineResource as n, defineAggregation as o, ResourceDefinition as r, defineResourceVariants as t };
|