@classytic/arc 2.16.1 → 2.17.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.
Files changed (72) hide show
  1. package/README.md +22 -2
  2. package/dist/{BaseController-Vk8I7C0x.mjs → BaseController-Dmeh4Y_y.mjs} +5 -47
  3. package/dist/audit/index.mjs +2 -1
  4. package/dist/auth/index.d.mts +1 -1
  5. package/dist/auth/index.mjs +2 -2
  6. package/dist/auth/redis-session.mjs +28 -2
  7. package/dist/cli/commands/describe.d.mts +1 -1
  8. package/dist/cli/commands/docs.mjs +1 -1
  9. package/dist/core/index.d.mts +2 -2
  10. package/dist/core/index.mjs +3 -3
  11. package/dist/{core-wKeGXn38.mjs → core-DYJvu7aI.mjs} +174 -31
  12. package/dist/{createActionRouter-eiOFtnC3.mjs → createActionRouter-BHUMb1-y.mjs} +5 -2
  13. package/dist/{createAggregationRouter-C4unhBnV.mjs → createAggregationRouter-BYVvhfc5.mjs} +1 -1
  14. package/dist/{createApp-DdPBtmzp.mjs → createApp-CzCEEsEm.mjs} +2 -2
  15. package/dist/docs/index.d.mts +1 -1
  16. package/dist/docs/index.mjs +2 -2
  17. package/dist/{elevation-Dci0AYLT.mjs → elevation-6mfTLBC-.mjs} +1 -1
  18. package/dist/{errorHandler-DNPzqaTT.mjs → errorHandler-wQejMBT3.mjs} +45 -4
  19. package/dist/events/transports/redis-stream-entry.mjs +34 -1
  20. package/dist/events/transports/redis.mjs +17 -1
  21. package/dist/factory/index.d.mts +1 -1
  22. package/dist/factory/index.mjs +1 -1
  23. package/dist/{fastify-augmentation-CJm0vh-l.d.mts → fastify-augmentation-7LIR0nDv.d.mts} +1 -1
  24. package/dist/hooks/index.d.mts +1 -1
  25. package/dist/idempotency/index.mjs +20 -4
  26. package/dist/{index-DBDgy6Hw.d.mts → index-C1JSBan-.d.mts} +177 -1
  27. package/dist/{index-CC_pgZjY.d.mts → index-CruZir1o.d.mts} +1 -1
  28. package/dist/index.d.mts +2 -2
  29. package/dist/index.mjs +5 -5
  30. package/dist/integrations/index.d.mts +1 -1
  31. package/dist/integrations/jobs.d.mts +36 -1
  32. package/dist/integrations/jobs.mjs +82 -9
  33. package/dist/integrations/mcp/index.d.mts +65 -3
  34. package/dist/integrations/mcp/index.mjs +93 -2
  35. package/dist/integrations/mcp/testing.d.mts +1 -1
  36. package/dist/integrations/mcp/testing.mjs +1 -1
  37. package/dist/integrations/websocket.mjs +2 -1
  38. package/dist/{mcpPlugin-Dbgz9_Rj.mjs → mcpPlugin-hZE00H74.mjs} +111 -8
  39. package/dist/middleware/index.d.mts +1 -1
  40. package/dist/middleware/index.mjs +1 -1
  41. package/dist/{multipartBody-BOvVSVCD.mjs → multipartBody-VjVWmZl8.mjs} +2 -1
  42. package/dist/{openapi-BRnd3pT7.mjs → openapi-DKDs9NfD.mjs} +2 -2
  43. package/dist/permissions/index.mjs +2 -2
  44. package/dist/{permissions-CTxMrreC.mjs → permissions-B1Qy55fg.mjs} +169 -169
  45. package/dist/pipeline/index.d.mts +1 -1
  46. package/dist/plugins/index.d.mts +2 -2
  47. package/dist/plugins/index.mjs +1 -1
  48. package/dist/plugins/tracing-entry.mjs +1 -1
  49. package/dist/presets/filesUpload.d.mts +1 -1
  50. package/dist/presets/filesUpload.mjs +2 -2
  51. package/dist/presets/index.d.mts +1 -1
  52. package/dist/presets/index.mjs +1 -1
  53. package/dist/presets/multiTenant.d.mts +1 -1
  54. package/dist/presets/search.d.mts +1 -1
  55. package/dist/presets/search.mjs +1 -1
  56. package/dist/{presets-C9BE6WaZ.mjs → presets-BYbPobbF.mjs} +5 -3
  57. package/dist/registry/index.d.mts +1 -1
  58. package/dist/{routerShared-ClV4cCaa.mjs → routerShared-nCyJGOKX.mjs} +127 -4
  59. package/dist/scope/index.mjs +2 -2
  60. package/dist/testing/index.d.mts +2 -2
  61. package/dist/testing/index.mjs +1 -1
  62. package/dist/types/index.d.mts +1 -1
  63. package/dist/{types-Dw--SyTq.d.mts → types-BRmS8TNa.d.mts} +1 -1
  64. package/dist/{types-BD0BCdf6.d.mts → types-CoMjNmkF.d.mts} +12 -1
  65. package/dist/utils/index.d.mts +79 -2
  66. package/dist/utils/index.mjs +2 -2
  67. package/dist/{utils-DPXZnZUx.mjs → utils-CvD3glpM.mjs} +99 -2
  68. package/package.json +1 -1
  69. package/skills/arc/SKILL.md +43 -1
  70. package/skills/arc/references/api-reference.md +2 -0
  71. package/skills/arc/references/mcp.md +30 -1
  72. /package/dist/{types-D57iXYb8.mjs → types-DV9WDfeg.mjs} +0 -0
package/README.md CHANGED
@@ -122,12 +122,31 @@ export default defineResource({
122
122
  actions: {
123
123
  approve: { handler: approveOrder, permissions: requireRoles(['admin']) },
124
124
  },
125
- // mcp: false, // opt out of MCP tool generation for this resource (2.16)
125
+ // mcp: false, // opt out of MCP tool generation for this resource
126
126
  });
127
127
  ```
128
128
 
129
129
  Auto-generates: `GET /products`, `GET /products/:id`, `POST /products`, `PATCH /products/:id`, `DELETE /products/:id` + softDelete adds `GET /products/deleted`, `POST /products/:id/restore` + slugLookup adds `GET /products/by-slug/:slug` + custom routes + `POST /products/:id/action`.
130
130
 
131
+ ### Resource shorthands
132
+
133
+ ```typescript
134
+ // Reference data — read-only list+get, fetch-all limits (1000), 5min/10min cache.
135
+ defineResource({ name: 'currency', adapter, referenceData: true });
136
+
137
+ // Service resource — custom routes only, no adapter, no auto-CRUD, no registry entry.
138
+ defineResource({
139
+ name: 'health',
140
+ customRoutesOnly: true,
141
+ routes: [{ method: 'GET', path: '/ping', permissions: allowPublic(), handler: () => ({ ok: true }) }],
142
+ });
143
+
144
+ // Tune pagination caps inline (no custom queryParser needed).
145
+ defineResource({ name: 'pipeline', adapter, defaultLimit: 200, maxLimit: 500 });
146
+ ```
147
+
148
+ `?limit=` violations respond 400 with a cap-aware envelope: `message: "Query parameter 'limit' must be <= 500 (got 800) (cap is 500)"` plus `meta: { field: 'limit', cap: 500 }` so callers can self-correct without scraping the message.
149
+
131
150
  ---
132
151
 
133
152
  ## Permissions
@@ -243,7 +262,8 @@ Tree-shake by importing only the subpath you need:
243
262
  | `@classytic/arc/plugins` | Health, request-id, versioning, tracing, response-cache |
244
263
  | `@classytic/arc/integrations/jobs` | BullMQ job dispatcher |
245
264
  | `@classytic/arc/integrations/websocket` | WebSocket integration |
246
- | `@classytic/arc/mcp` | Model Context Protocol tools |
265
+ | `@classytic/arc/mcp` | Model Context Protocol tools + `createMcpAuthFromBetterAuthApiKey` (Better Auth API-key resolver), `resolveToolCollisions` |
266
+ | `@classytic/arc/utils` | `pipeUIMessageStreamToReply`, `UI_MESSAGE_STREAM_HEADERS` (Vercel AI SDK → Fastify reply); errors, query parser, circuit breaker |
247
267
  | `@classytic/arc/testing` | `createTestApp`, `expectArc`, `TestAuthProvider`, `createTestFixtures` |
248
268
  | `@classytic/arc/types` | Type-only barrel (zero runtime cost) |
249
269
 
@@ -1,10 +1,9 @@
1
- import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-TrJVIJl0.mjs";
1
+ import { o as DEFAULT_TENANT_FIELD } from "./constants-TrJVIJl0.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
3
  import { d as createDomainError, f as createError, i as NotFoundError, r as ForbiddenError } from "./errors-j4aJm1Wg.mjs";
4
- import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-Bi0r0vjG.mjs";
5
- import { N as simpleEqualityMatcher, _ as ArcQueryParser, r as scheduleBackground, t as getUserId } from "./utils-DPXZnZUx.mjs";
6
- import { d as applyFieldWritePermissions, p as resolveEffectiveRoles } from "./permissions-CTxMrreC.mjs";
7
- import { t as getUserRoles } from "./types-D57iXYb8.mjs";
4
+ import { b as isElevated, c as getOrgId } from "./types-Bi0r0vjG.mjs";
5
+ import { I as simpleEqualityMatcher, b as ArcQueryParser, o as scheduleBackground, t as getUserId } from "./utils-CvD3glpM.mjs";
6
+ import { w as BodySanitizer } from "./routerShared-nCyJGOKX.mjs";
8
7
  import { t as buildQueryKey } from "./keys-DopsCuyQ.mjs";
9
8
  //#region src/core/AccessControl.ts
10
9
  const log = arcLog("access-control");
@@ -241,47 +240,6 @@ var AccessControl = class {
241
240
  return req.metadata;
242
241
  }
243
242
  };
244
- var BodySanitizer = class {
245
- schemaOptions;
246
- onFieldWriteDenied;
247
- constructor(config) {
248
- this.schemaOptions = config.schemaOptions;
249
- this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
250
- }
251
- /**
252
- * Strip readonly and system-managed fields from request body.
253
- * Prevents clients from overwriting _id, timestamps, __v, etc.
254
- *
255
- * Also applies field-level write permissions when the request has
256
- * field permission metadata.
257
- */
258
- sanitize(body, _operation, req, meta) {
259
- let sanitized = { ...body };
260
- for (const field of SYSTEM_FIELDS) delete sanitized[field];
261
- const scopeForRules = req ? (meta ?? req.metadata)?._scope ?? PUBLIC_SCOPE : void 0;
262
- const scopeIsElevated = scopeForRules ? isElevated(scopeForRules) : false;
263
- const fieldRules = this.schemaOptions.fieldRules ?? {};
264
- for (const [field, rules] of Object.entries(fieldRules)) {
265
- const bypass = Boolean(rules.preserveForElevated) && scopeIsElevated;
266
- if ((rules.systemManaged || rules.readonly) && !bypass) delete sanitized[field];
267
- if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate) && !bypass) delete sanitized[field];
268
- }
269
- if (req) {
270
- const arcContext = meta ?? req.metadata;
271
- const scope = arcContext?._scope ?? PUBLIC_SCOPE;
272
- if (!isElevated(scope)) {
273
- const fieldPerms = arcContext?.arc?.fields;
274
- if (fieldPerms) {
275
- const effectiveRoles = resolveEffectiveRoles(getUserRoles(req.user), isMember(scope) ? scope.orgRoles : []);
276
- const { body: filtered, deniedFields } = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
277
- if (deniedFields.length > 0 && this.onFieldWriteDenied === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
278
- sanitized = filtered;
279
- }
280
- }
281
- }
282
- return sanitized;
283
- }
284
- };
285
243
  //#endregion
286
244
  //#region src/core/fieldRulePredicates.ts
287
245
  /**
@@ -1531,4 +1489,4 @@ function TreeMixin(Base) {
1531
1489
  */
1532
1490
  var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
1533
1491
  //#endregion
1534
- export { BulkMixin as a, collectReadBlockedFields as c, AccessControl as d, SlugMixin as i, isFieldReadable as l, TreeMixin as n, BaseCrudController as o, SoftDeleteMixin as r, QueryResolver as s, BaseController as t, BodySanitizer as u };
1492
+ export { BulkMixin as a, collectReadBlockedFields as c, SlugMixin as i, isFieldReadable as l, TreeMixin as n, BaseCrudController as o, SoftDeleteMixin as r, QueryResolver as s, BaseController as t, AccessControl as u };
@@ -351,7 +351,8 @@ function createNoopLogger() {
351
351
  restore: noop,
352
352
  custom: noop,
353
353
  query: async () => [],
354
- purge: async () => 0
354
+ purge: async () => 0,
355
+ _noop: true
355
356
  };
356
357
  }
357
358
  var auditPlugin_default = fp(auditPlugin, {
@@ -1,4 +1,4 @@
1
- import { Bt as AuthHelpers, Vt as AuthPluginOptions } from "../index-DBDgy6Hw.mjs";
1
+ import { Bt as AuthHelpers, Vt as AuthPluginOptions } from "../index-C1JSBan-.mjs";
2
2
  import { c as PermissionCheck } from "../fields-DqZLp5d6.mjs";
3
3
  import { t as ExternalOpenApiPaths } from "../externalPaths-ClykOQqa.mjs";
4
4
  import { a as SessionManagerOptions, c as createSessionManager, i as SessionData, n as MemorySessionStoreOptions, o as SessionManagerResult, r as SessionCookieOptions, s as SessionStore, t as MemorySessionStore } from "../sessionManager-DlmaHUAl.mjs";
@@ -1,6 +1,6 @@
1
1
  import { d as createDomainError, l as UnauthorizedError, p as isArcError, t as ArcError } from "../errors-j4aJm1Wg.mjs";
2
- import { _ as requireOrgMembership, v as requireOrgRole, x as requireTeamMembership } from "../permissions-CTxMrreC.mjs";
3
- import { n as normalizeRoles, t as getUserRoles } from "../types-D57iXYb8.mjs";
2
+ import { _ as requireTeamMembership, m as requireOrgRole, p as requireOrgMembership } from "../permissions-B1Qy55fg.mjs";
3
+ import { n as normalizeRoles, t as getUserRoles } from "../types-DV9WDfeg.mjs";
4
4
  import { n as extractBetterAuthOpenApi } from "../betterAuthOpenApi-ClWxaceA.mjs";
5
5
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
6
6
  import fp from "fastify-plugin";
@@ -1,5 +1,31 @@
1
+ import sjson from "secure-json-parse";
1
2
  //#region src/auth/redis-session.ts
2
3
  /**
4
+ * Redis Session Store for Arc
5
+ *
6
+ * Implements the SessionStore interface using Redis for distributed session storage.
7
+ * Use this in multi-instance/clustered deployments where MemorySessionStore won't work.
8
+ *
9
+ * This is a SEPARATE subpath import — only loaded when explicitly used:
10
+ * import { RedisSessionStore } from '@classytic/arc/auth/redis';
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * import { createSessionManager } from '@classytic/arc/auth';
15
+ * import { RedisSessionStore } from '@classytic/arc/auth/redis';
16
+ * import Redis from 'ioredis';
17
+ *
18
+ * const redis = new Redis(process.env.REDIS_URL);
19
+ *
20
+ * const sessions = createSessionManager({
21
+ * store: new RedisSessionStore({ redis }),
22
+ * secret: process.env.SESSION_SECRET!,
23
+ * });
24
+ *
25
+ * await fastify.register(sessions.plugin);
26
+ * ```
27
+ */
28
+ /**
3
29
  * Redis-backed session store for distributed deployments.
4
30
  *
5
31
  * Uses two key patterns:
@@ -22,7 +48,7 @@ var RedisSessionStore = class {
22
48
  if (!raw) return null;
23
49
  let session;
24
50
  try {
25
- session = JSON.parse(raw);
51
+ session = sjson.parse(raw);
26
52
  } catch {
27
53
  await this.delete(sessionId);
28
54
  return null;
@@ -46,7 +72,7 @@ var RedisSessionStore = class {
46
72
  async delete(sessionId) {
47
73
  const raw = await this.redis.get(this.prefix + sessionId);
48
74
  if (raw) try {
49
- const session = JSON.parse(raw);
75
+ const session = sjson.parse(raw);
50
76
  await this.redis.srem(this.userPrefix + session.userId, sessionId);
51
77
  } catch {}
52
78
  await this.redis.del(this.prefix + sessionId);
@@ -1,4 +1,4 @@
1
- import { V as ResourceDefinition, it as RateLimitConfig, mt as RouteSchemaOptions } from "../../index-DBDgy6Hw.mjs";
1
+ import { V as ResourceDefinition, it as RateLimitConfig, mt as RouteSchemaOptions } from "../../index-C1JSBan-.mjs";
2
2
 
3
3
  //#region src/cli/commands/describe.d.ts
4
4
  interface DescribedResource {
@@ -1,4 +1,4 @@
1
- import { t as buildOpenApiSpec } from "../../openapi-BRnd3pT7.mjs";
1
+ import { t as buildOpenApiSpec } from "../../openapi-DKDs9NfD.mjs";
2
2
  import { t as loadResourcesFromEntry } from "../../loadResourcesFromEntry-DCjYMgfC.mjs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { mkdirSync, writeFileSync } from "node:fs";
@@ -1,3 +1,3 @@
1
- import { $t as BaseController, At as AggregationRateLimit, B as defineResource, Cn as AccessControlConfig, Ct as AggMeasureShorthand, Dt as AggregationIndexHint, Et as AggregationDateRangeRequirement, Ot as AggregationMaterializedContext, Sn as AccessControl, St as AggMeasureInput, Tt as AggregationConfig, V as ResourceDefinition, an as SlugMixin, bn as BodySanitizer, cn as BaseControllerOptions, dn as ControllerConstructionOptions, en as SoftDeleteExt, fn as QueryResolver, in as SlugExt, jt as AggregationsMap, kt as AggregationMaterializedResult, ln as BaseCrudController, nn as TreeExt, on as BulkExt, pn as QueryResolverConfig, rn as TreeMixin, sn as BulkMixin, tn as SoftDeleteMixin, un as ControllerConfigurableOptions, wt as AggregationCacheConfig, xn as BodySanitizerConfig, yn as ListResult } from "../index-DBDgy6Hw.mjs";
2
- import { A as MAX_FILTER_DEPTH, C as DEFAULT_SORT, D as HOOK_PHASES, E as HOOK_OPERATIONS, F as RESERVED_QUERY_PARAMS, I as SYSTEM_FIELDS, M as MAX_SEARCH_LENGTH, N as MUTATION_OPERATIONS, O as HookOperation, P as MutationOperation, S as DEFAULT_MAX_LIMIT, T as DEFAULT_UPDATE_METHOD, _ as defineAggregation, a as createRequestContext, b as DEFAULT_ID_FIELD, c as sendControllerResponse, d as getEntityQuery, f as defineResourceVariants, g as createPermissionMiddleware, h as createCrudRouter, i as createFastifyHandler, j as MAX_REGEX_LENGTH, k as HookPhase, l as getEntityId, m as defineAction, n as isFieldReadable, o as getControllerContext, p as DefineActionConfig, r as createCrudHandlers, s as getControllerScope, t as collectReadBlockedFields, u as getEntityIdField, v as CRUD_OPERATIONS, w as DEFAULT_TENANT_FIELD, x as DEFAULT_LIMIT, y as CrudOperation } from "../index-CC_pgZjY.mjs";
1
+ import { $t as BaseController, At as AggregationRateLimit, B as defineResource, Cn as AccessControlConfig, Ct as AggMeasureShorthand, Dt as AggregationIndexHint, Et as AggregationDateRangeRequirement, Ot as AggregationMaterializedContext, Sn as AccessControl, St as AggMeasureInput, Tt as AggregationConfig, V as ResourceDefinition, an as SlugMixin, bn as BodySanitizer, cn as BaseControllerOptions, dn as ControllerConstructionOptions, en as SoftDeleteExt, fn as QueryResolver, in as SlugExt, jt as AggregationsMap, kt as AggregationMaterializedResult, ln as BaseCrudController, nn as TreeExt, on as BulkExt, pn as QueryResolverConfig, rn as TreeMixin, sn as BulkMixin, tn as SoftDeleteMixin, un as ControllerConfigurableOptions, wt as AggregationCacheConfig, xn as BodySanitizerConfig, yn as ListResult } from "../index-C1JSBan-.mjs";
2
+ import { A as MAX_FILTER_DEPTH, C as DEFAULT_SORT, D as HOOK_PHASES, E as HOOK_OPERATIONS, F as RESERVED_QUERY_PARAMS, I as SYSTEM_FIELDS, M as MAX_SEARCH_LENGTH, N as MUTATION_OPERATIONS, O as HookOperation, P as MutationOperation, S as DEFAULT_MAX_LIMIT, T as DEFAULT_UPDATE_METHOD, _ as defineAggregation, a as createRequestContext, b as DEFAULT_ID_FIELD, c as sendControllerResponse, d as getEntityQuery, f as defineResourceVariants, g as createPermissionMiddleware, h as createCrudRouter, i as createFastifyHandler, j as MAX_REGEX_LENGTH, k as HookPhase, l as getEntityId, m as defineAction, n as isFieldReadable, o as getControllerContext, p as DefineActionConfig, r as createCrudHandlers, s as getControllerScope, t as collectReadBlockedFields, u as getEntityIdField, v as CRUD_OPERATIONS, w as DEFAULT_TENANT_FIELD, x as DEFAULT_LIMIT, y as CrudOperation } from "../index-CruZir1o.mjs";
3
3
  export { AccessControl, AccessControlConfig, AggMeasureInput, AggMeasureShorthand, AggregationCacheConfig, AggregationConfig, AggregationDateRangeRequirement, AggregationIndexHint, AggregationMaterializedContext, AggregationMaterializedResult, AggregationRateLimit, AggregationsMap, BaseController, BaseControllerOptions, BaseCrudController, BodySanitizer, BodySanitizerConfig, BulkExt, BulkMixin, CRUD_OPERATIONS, ControllerConfigurableOptions, ControllerConstructionOptions, CrudOperation, DEFAULT_ID_FIELD, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_SORT, DEFAULT_TENANT_FIELD, DEFAULT_UPDATE_METHOD, DefineActionConfig, HOOK_OPERATIONS, HOOK_PHASES, HookOperation, HookPhase, ListResult, MAX_FILTER_DEPTH, MAX_REGEX_LENGTH, MAX_SEARCH_LENGTH, MUTATION_OPERATIONS, MutationOperation, QueryResolver, QueryResolverConfig, RESERVED_QUERY_PARAMS, ResourceDefinition, SYSTEM_FIELDS, SlugExt, SlugMixin, SoftDeleteExt, SoftDeleteMixin, TreeExt, TreeMixin, collectReadBlockedFields, createCrudHandlers, createCrudRouter, createFastifyHandler, createPermissionMiddleware, createRequestContext, defineAction, defineAggregation, defineResource, defineResourceVariants, getControllerContext, getControllerScope, getEntityId, getEntityIdField, getEntityQuery, isFieldReadable, sendControllerResponse };
@@ -1,5 +1,5 @@
1
1
  import { a as DEFAULT_SORT, c as HOOK_OPERATIONS, d as MAX_REGEX_LENGTH, f as MAX_SEARCH_LENGTH, h as SYSTEM_FIELDS, i as DEFAULT_MAX_LIMIT, l as HOOK_PHASES, m as RESERVED_QUERY_PARAMS, n as DEFAULT_ID_FIELD, o as DEFAULT_TENANT_FIELD, p as MUTATION_OPERATIONS, r as DEFAULT_LIMIT, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS, u as MAX_FILTER_DEPTH } from "../constants-TrJVIJl0.mjs";
2
- import { a as BulkMixin, c as collectReadBlockedFields, d as AccessControl, i as SlugMixin, l as isFieldReadable, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, s as QueryResolver, t as BaseController, u as BodySanitizer } from "../BaseController-Vk8I7C0x.mjs";
3
- import { _ as getControllerContext, g as createRequestContext, h as createFastifyHandler, m as createCrudHandlers, v as getControllerScope, y as sendControllerResponse } from "../routerShared-ClV4cCaa.mjs";
4
- import { a as defineResource, c as createCrudRouter, i as defineResourceVariants, l as createPermissionMiddleware, n as getEntityIdField, o as ResourceDefinition, r as getEntityQuery, s as defineAction, t as getEntityId, u as defineAggregation } from "../core-wKeGXn38.mjs";
2
+ import { a as BulkMixin, c as collectReadBlockedFields, i as SlugMixin, l as isFieldReadable, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, s as QueryResolver, t as BaseController, u as AccessControl } from "../BaseController-Dmeh4Y_y.mjs";
3
+ import { S as sendControllerResponse, _ as createCrudHandlers, b as getControllerContext, v as createFastifyHandler, w as BodySanitizer, x as getControllerScope, y as createRequestContext } from "../routerShared-nCyJGOKX.mjs";
4
+ import { a as defineResource, c as createCrudRouter, i as defineResourceVariants, l as createPermissionMiddleware, n as getEntityIdField, o as ResourceDefinition, r as getEntityQuery, s as defineAction, t as getEntityId, u as defineAggregation } from "../core-DYJvu7aI.mjs";
5
5
  export { AccessControl, BaseController, BaseCrudController, BodySanitizer, BulkMixin, CRUD_OPERATIONS, DEFAULT_ID_FIELD, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_SORT, DEFAULT_TENANT_FIELD, DEFAULT_UPDATE_METHOD, HOOK_OPERATIONS, HOOK_PHASES, MAX_FILTER_DEPTH, MAX_REGEX_LENGTH, MAX_SEARCH_LENGTH, MUTATION_OPERATIONS, QueryResolver, RESERVED_QUERY_PARAMS, ResourceDefinition, SYSTEM_FIELDS, SlugMixin, SoftDeleteMixin, TreeMixin, collectReadBlockedFields, createCrudHandlers, createCrudRouter, createFastifyHandler, createPermissionMiddleware, createRequestContext, defineAction, defineAggregation, defineResource, defineResourceVariants, getControllerContext, getControllerScope, getEntityId, getEntityIdField, getEntityQuery, isFieldReadable, sendControllerResponse };
@@ -1,11 +1,11 @@
1
1
  import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-TrJVIJl0.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
- import { A as assertValidConfig, l as getDefaultCrudSchemas } from "./utils-DPXZnZUx.mjs";
4
- import { t as BaseController } from "./BaseController-Vk8I7C0x.mjs";
5
- import { t as applyPresets } from "./presets-C9BE6WaZ.mjs";
3
+ import { N as assertValidConfig, f as getDefaultCrudSchemas, i as pipeUIMessageStreamToReply, r as isReadableStream } from "./utils-CvD3glpM.mjs";
4
+ import { t as BaseController } from "./BaseController-Dmeh4Y_y.mjs";
5
+ import { t as applyPresets } from "./presets-BYbPobbF.mjs";
6
6
  import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-De34B1ZG.mjs";
7
7
  import { t as hasEvents } from "./typeGuards-BzkXkvVv.mjs";
8
- import { b as buildRequestScopeProjection, c as buildPreHandlerChain, d as resolveRoutePreHandlers, f as resolveRouterPluginMw, g as createRequestContext, 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-ClV4cCaa.mjs";
8
+ import { C as buildRequestScopeProjection, _ as createCrudHandlers, c as buildPipelineHandler, d as methodCarriesBody, f as resolvePipelineSteps, g as tryRegisterRoute, h as selectPluginMw, i as buildAuthMiddleware, l as buildPreHandlerChain, m as resolveRouterPluginMw, o as buildCrudPermissionMw, p as resolveRoutePreHandlers, r as buildArcDecorator, s as buildFieldWritePreHandler, u as buildRateLimitConfig, v as createFastifyHandler, y as createRequestContext } from "./routerShared-nCyJGOKX.mjs";
9
9
  import { t as pluralize } from "./pluralize-B9M8xvy-.mjs";
10
10
  import { t as resolveActionPermission } from "./actionPermissions-Bjmvn7Eb.mjs";
11
11
  //#region src/core/aggregation/defineAggregation.ts
@@ -60,7 +60,7 @@ function defineAggregation(config) {
60
60
  * `wrapHandler` is derived inline from `!route.raw`.
61
61
  */
62
62
  function createCustomRoutes(fastify, routes, controller, options) {
63
- const { tag, resourceName, arcDecorator, rateLimitConfig, pluginMw, pipeline, routeGuards, tenantScopeMw } = options;
63
+ const { tag, resourceName, arcDecorator, rateLimitConfig, pluginMw, pipeline, routeGuards, tenantScopeMw, fieldPermissions, onFieldWriteDenied } = options;
64
64
  for (const route of routes) {
65
65
  const routeWithRefs = route;
66
66
  const hasHandler = route.handler !== void 0;
@@ -103,6 +103,10 @@ function createCustomRoutes(fastify, routes, controller, options) {
103
103
  if (!tenantScopeMw || tenantScopeMw.length === 0) throw new Error(`Route ${route.method} ${route.path}: \`tenantScope: true\` requires a multi-tenant preset. Add \`multiTenantPreset()\` (or \`flexibleMultiTenantPreset()\`) to the resource's \`presets\`, or remove the \`tenantScope\` flag from this route.`);
104
104
  customPreHandlers.unshift(...tenantScopeMw);
105
105
  }
106
+ if (route.raw !== true && route.fieldWrite !== false && methodCarriesBody(route.method)) {
107
+ const fieldWriteMw = buildFieldWritePreHandler(fieldPermissions, onFieldWriteDenied);
108
+ if (fieldWriteMw) customPreHandlers.push(fieldWriteMw);
109
+ }
106
110
  const preHandler = buildPreHandlerChain({
107
111
  preAuth: route.preAuth ?? [],
108
112
  arcDecorator,
@@ -113,7 +117,7 @@ function createCustomRoutes(fastify, routes, controller, options) {
113
117
  customMws: customPreHandlers
114
118
  });
115
119
  const isStream = route.streamResponse === true;
116
- fastify.route({
120
+ tryRegisterRoute(fastify, {
117
121
  method: route.method,
118
122
  url: route.path,
119
123
  schema,
@@ -122,9 +126,14 @@ function createCustomRoutes(fastify, routes, controller, options) {
122
126
  reply.raw.setHeader("Content-Type", "text/event-stream");
123
127
  reply.raw.setHeader("Cache-Control", "no-cache");
124
128
  reply.raw.setHeader("Connection", "keep-alive");
125
- return handler(request, reply);
129
+ const result = await handler(request, reply);
130
+ if (isReadableStream(result)) await pipeUIMessageStreamToReply(reply, result);
131
+ return result;
126
132
  } : handler,
127
133
  ...rateLimitConfig ? { config: rateLimitConfig } : {}
134
+ }, {
135
+ resourceName,
136
+ op: opName
128
137
  });
129
138
  }
130
139
  }
@@ -138,7 +147,7 @@ function createCustomRoutes(fastify, routes, controller, options) {
138
147
  * @param options - Router configuration
139
148
  */
140
149
  function createCrudRouter(fastify, controller, options = {}) {
141
- const { tag = "Resource", schemas = {}, permissions = {}, middlewares = {}, routeGuards = [], routes: customRoutes = [], disableDefaultRoutes = false, disabledRoutes = [], resourceName = "unknown", schemaOptions, rateLimit, pipe: pipeline, fields: fieldPermissions, updateMethod = DEFAULT_UPDATE_METHOD, idField } = options;
150
+ const { tag = "Resource", schemas = {}, permissions = {}, middlewares = {}, routeGuards = [], routes: customRoutes = [], disableDefaultRoutes = false, disabledRoutes = [], resourceName = "unknown", schemaOptions, rateLimit, pipe: pipeline, fields: fieldPermissions, onFieldWriteDenied, updateMethod = DEFAULT_UPDATE_METHOD, idField } = options;
142
151
  const rateLimitConfig = buildRateLimitConfig(rateLimit);
143
152
  const resourceHasQueryCache = fastify.hasDecorator("queryCache") && controller && typeof controller._cacheConfig !== "undefined" && controller._cacheConfig !== void 0;
144
153
  const pluginMw = resolveRouterPluginMw(fastify, Boolean(resourceHasQueryCache));
@@ -229,7 +238,7 @@ function createCrudRouter(fastify, controller, options = {}) {
229
238
  const methodsToRegister = spec.op === "update" ? updateMethod === "both" ? ["PUT", "PATCH"] : [updateMethod] : [spec.method];
230
239
  for (const method of methodsToRegister) {
231
240
  const summary = spec.op === "update" ? `${method === "PUT" ? "Replace" : "Update"} ${tag}` : spec.summary;
232
- fastify.route({
241
+ tryRegisterRoute(fastify, {
233
242
  method,
234
243
  url: spec.url,
235
244
  schema: buildSchema({
@@ -240,6 +249,9 @@ function createCrudRouter(fastify, controller, options = {}) {
240
249
  preHandler: preHandler.length > 0 ? preHandler : void 0,
241
250
  handler: handlers[spec.op],
242
251
  ...rateLimitConfig ? { config: rateLimitConfig } : {}
252
+ }, {
253
+ resourceName,
254
+ op: spec.op
243
255
  });
244
256
  }
245
257
  }
@@ -252,7 +264,9 @@ function createCrudRouter(fastify, controller, options = {}) {
252
264
  pluginMw,
253
265
  pipeline,
254
266
  routeGuards,
255
- tenantScopeMw
267
+ tenantScopeMw,
268
+ fieldPermissions,
269
+ onFieldWriteDenied
256
270
  });
257
271
  }
258
272
  function buildCrudHandlers(ctrl, pipeline, resourceName) {
@@ -442,11 +456,14 @@ function buildBaseController(resolvedConfig, adapter, repository) {
442
456
  const limitProp = qp.getQuerySchema()?.properties?.limit;
443
457
  if (limitProp?.maximum) maxLimitFromParser = limitProp.maximum;
444
458
  }
459
+ const resourceConfigMaxLimit = resolvedConfig.maxLimit;
460
+ const resourceConfigDefaultLimit = resolvedConfig.defaultLimit;
445
461
  return new BaseController(repository, {
446
462
  resourceName: resolvedConfig.name,
447
463
  schemaOptions: resolvedConfig.schemaOptions,
448
464
  queryParser: qp,
449
- maxLimit: maxLimitFromParser,
465
+ maxLimit: resourceConfigMaxLimit ?? maxLimitFromParser,
466
+ ...resourceConfigDefaultLimit !== void 0 ? { defaultLimit: resourceConfigDefaultLimit } : {},
450
467
  tenantField: resolvedConfig.tenantField,
451
468
  idField: resolvedConfig.idField,
452
469
  ...resolvedConfig.defaultSort !== void 0 ? { defaultSort: resolvedConfig.defaultSort } : {},
@@ -1033,10 +1050,21 @@ function buildResourcePlugin(resource) {
1033
1050
  handler: hook.handler,
1034
1051
  priority: hook.priority
1035
1052
  });
1036
- if (isFirstMount && resource.cache?.invalidateOn && fastify.registerCacheInvalidationRule) for (const [pattern, tags] of Object.entries(resource.cache.invalidateOn)) fastify.registerCacheInvalidationRule({
1053
+ if (isFirstMount && resource.cache?.invalidateOn) if (fastify.registerCacheInvalidationRule) for (const [pattern, tags] of Object.entries(resource.cache.invalidateOn)) fastify.registerCacheInvalidationRule({
1037
1054
  pattern,
1038
1055
  tags
1039
1056
  });
1057
+ else fastify.log?.warn?.(`[Arc] Resource '${resource.name}' declares \`cache.invalidateOn\` but \`queryCachePlugin\` is not registered. Cache-invalidation rules are silently ignored. Either register the plugin (\`await fastify.register(queryCachePlugin, ...)\`) or remove the \`cache.invalidateOn\` config to clean up the resource definition.`);
1058
+ if (isFirstMount && resource.audit) {
1059
+ if (!(fastify.hasDecorator("audit") && typeof fastify.audit?.create === "function" && fastify.audit?._noop !== true)) fastify.log?.warn?.(`[Arc] Resource '${resource.name}' declares \`audit: ${JSON.stringify(resource.audit)}\` but \`auditPlugin\` is not registered (or is registered with a noop logger). Audit events for this resource will NOT be recorded. Either register \`auditPlugin\` (\`await fastify.register(auditPlugin, { store: ... })\`) or remove the \`audit\` flag from the resource definition.`);
1060
+ }
1061
+ if (isFirstMount && resource.events && Object.keys(resource.events).length > 0) {
1062
+ const eventsDecor = fastify.events;
1063
+ if (!(eventsDecor != null && typeof eventsDecor.publish === "function")) {
1064
+ const eventNames = Object.keys(resource.events).join(", ");
1065
+ fastify.log?.warn?.(`[Arc] Resource '${resource.name}' declares events (${eventNames}) but \`eventPlugin\` is not registered. The events will silently NOT be emitted on CRUD mutations. Either register the plugin (\`await fastify.register(eventPlugin, { transport: ... })\`) or remove the \`events\` declaration from the resource.`);
1066
+ }
1067
+ }
1040
1068
  await fastify.register(async (instance) => {
1041
1069
  const typedInstance = instance;
1042
1070
  let schemas = buildGeneratedCrudSchemas(resource._registryMeta?.openApiSchemas, resource.customSchemas);
@@ -1061,10 +1089,11 @@ function buildResourcePlugin(resource) {
1061
1089
  updateMethod: resource.updateMethod,
1062
1090
  pipe: resource.pipe,
1063
1091
  fields: resource.fields,
1092
+ onFieldWriteDenied: resource.onFieldWriteDenied,
1064
1093
  idField: resource.idField
1065
1094
  });
1066
1095
  if (resource.actions && Object.keys(resource.actions).length > 0) {
1067
- const { createActionRouter } = await import("./createActionRouter-eiOFtnC3.mjs").then((n) => n.n);
1096
+ const { createActionRouter } = await import("./createActionRouter-BHUMb1-y.mjs").then((n) => n.n);
1068
1097
  createActionRouter(typedInstance, {
1069
1098
  ...normalizeActionsToRouterConfig(resource.actions, resource.actionPermissions, resource.tag, resource.permissions, resource.name, typedInstance.log),
1070
1099
  resourceName: resource.name,
@@ -1078,7 +1107,7 @@ function buildResourcePlugin(resource) {
1078
1107
  });
1079
1108
  }
1080
1109
  if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
1081
- const [{ createAggregationRouter }, { adapterSupportsAggregate, ArcAggregationConfigError }] = await Promise.all([import("./createAggregationRouter-C4unhBnV.mjs"), import("./validate-DFIP2VrM.mjs").then((n) => n.i)]);
1110
+ const [{ createAggregationRouter }, { adapterSupportsAggregate, ArcAggregationConfigError }] = await Promise.all([import("./createAggregationRouter-BYVvhfc5.mjs"), import("./validate-DFIP2VrM.mjs").then((n) => n.i)]);
1082
1111
  const repoForAgg = resource.controller?.repository ?? resource.repository;
1083
1112
  if (!adapterSupportsAggregate(repoForAgg)) {
1084
1113
  const undispatchable = Object.entries(resource.aggregations).filter(([, cfg]) => !cfg.materialized).map(([name]) => name);
@@ -1142,7 +1171,16 @@ var ResourceDefinition = class {
1142
1171
  updateMethod;
1143
1172
  pipe;
1144
1173
  fields;
1174
+ /**
1175
+ * Policy for field-write denials — `'reject'` (default, secure) raises 403
1176
+ * listing the denied fields; `'strip'` silently drops them. Honored by
1177
+ * both `BodySanitizer` (auto-CRUD) and `buildFieldWritePreHandler`
1178
+ * (custom routes) so the policy is consistent across all body-bearing
1179
+ * routes for the resource.
1180
+ */
1181
+ onFieldWriteDenied;
1145
1182
  cache;
1183
+ referenceData;
1146
1184
  /**
1147
1185
  * Per-resource MCP opt-out. `false` keeps the resource out of every
1148
1186
  * `mcpPlugin` registration regardless of the plugin's `expose` /
@@ -1174,9 +1212,37 @@ var ResourceDefinition = class {
1174
1212
  * `buildResourcePlugin` so the host's configured logger owns the
1175
1213
  * output — the framework never speaks to `console.*` from `src/`
1176
1214
  * outside of the CLI.
1215
+ *
1216
+ * Prefer the public {@link warnings} getter for programmatic access
1217
+ * (CI fail-on-warnings, lint integrations) — it returns a frozen
1218
+ * snapshot so callers can't accidentally mutate the diagnostic list.
1177
1219
  */
1178
1220
  _diagnostics;
1179
1221
  /**
1222
+ * Public view of {@link _diagnostics} for programmatic consumption.
1223
+ *
1224
+ * Returns the collected define-time diagnostics for this resource.
1225
+ * Empty when the resource validated cleanly. Useful for CI gates that
1226
+ * want to fail the build on any non-fatal warning:
1227
+ *
1228
+ * @example
1229
+ * ```ts
1230
+ * import { orderResource } from './resources/order.resource.js';
1231
+ * if (orderResource.warnings.length > 0) {
1232
+ * for (const w of orderResource.warnings) console.error(w.message);
1233
+ * process.exit(1);
1234
+ * }
1235
+ * ```
1236
+ *
1237
+ * **Scope.** Covers define-time only — diagnostics emitted at
1238
+ * first-mount (e.g. `cache.invalidateOn` without `queryCachePlugin`)
1239
+ * surface through `fastify.log.warn` instead and are not collected
1240
+ * here; they require a Fastify instance to detect.
1241
+ */
1242
+ get warnings() {
1243
+ return this._diagnostics ?? [];
1244
+ }
1245
+ /**
1180
1246
  * Per-host idempotency guard used by `buildResourcePlugin` to
1181
1247
  * skip duplicate shared-state writes when the same resource is
1182
1248
  * mounted at multiple prefixes (`/v1`, `/v2`). See the plugin
@@ -1209,8 +1275,10 @@ var ResourceDefinition = class {
1209
1275
  this.updateMethod = config.updateMethod;
1210
1276
  this.pipe = config.pipe;
1211
1277
  this.fields = config.fields;
1278
+ this.onFieldWriteDenied = config.onFieldWriteDenied;
1212
1279
  this.cache = config.cache;
1213
1280
  this.mcp = config.mcp !== false;
1281
+ this.referenceData = config.referenceData === true;
1214
1282
  this.tenantField = config.tenantField;
1215
1283
  this.onTenantDelete = config.onTenantDelete;
1216
1284
  this.resolvedTenantPurge = resolveTenantPurge({
@@ -1364,6 +1432,7 @@ function resolveOpenApiSchemas(resolvedConfig) {
1364
1432
  openApiSchemas = stripSystemManagedFromBodyRequired(openApiSchemas, resolvedConfig.schemaOptions);
1365
1433
  openApiSchemas = cleanLegacyObjectIdParams(openApiSchemas, resolvedConfig.idField);
1366
1434
  openApiSchemas = layerQueryParserListQuery(openApiSchemas, resolvedConfig.queryParser);
1435
+ openApiSchemas = applyResourcePaginationCaps(openApiSchemas, resolvedConfig);
1367
1436
  openApiSchemas = mergeUserOpenApiOverrides(openApiSchemas, resolvedConfig.openApiSchemas);
1368
1437
  if (openApiSchemas) openApiSchemas = convertOpenApiSchemas(openApiSchemas);
1369
1438
  return {
@@ -1435,6 +1504,45 @@ function layerQueryParserListQuery(openApiSchemas, queryParser) {
1435
1504
  };
1436
1505
  }
1437
1506
  /**
1507
+ * Apply resource-level `defaultLimit` / `maxLimit` (2.17.0) onto the
1508
+ * listQuery schema. Resource-level caps win over both the framework
1509
+ * default and the parser-derived value because they're the most-explicit
1510
+ * declaration — a host that wrote `defaultLimit: 1000` on the resource
1511
+ * means "this resource specifically returns up to 1000", regardless of
1512
+ * what the parser's default would have been. The `referenceData: true`
1513
+ * shorthand lifts both to 1000 so reference-data resources don't need
1514
+ * a custom queryParser just to get the caps right.
1515
+ *
1516
+ * Bootstraps a minimal listQuery slot when none exists (no queryParser
1517
+ * registered, no adapter-generated schema) so the AJV layer still gets
1518
+ * a `maximum:` and surfaces the cap in the 400 envelope via
1519
+ * `errorHandler.describePaginationCap()` instead of producing the
1520
+ * generic "Bad Request" we ate pre-2.17.0.
1521
+ */
1522
+ function applyResourcePaginationCaps(openApiSchemas, resolvedConfig) {
1523
+ const cfg = resolvedConfig;
1524
+ if (cfg.defaultLimit === void 0 && cfg.maxLimit === void 0) return openApiSchemas;
1525
+ const existing = openApiSchemas?.listQuery;
1526
+ const existingProps = existing?.properties ?? {};
1527
+ const patchedLimit = { ...existingProps.limit ?? {
1528
+ type: "integer",
1529
+ minimum: 1
1530
+ } };
1531
+ if (cfg.maxLimit !== void 0) patchedLimit.maximum = cfg.maxLimit;
1532
+ if (cfg.defaultLimit !== void 0) patchedLimit.default = cfg.defaultLimit;
1533
+ return {
1534
+ ...openApiSchemas,
1535
+ listQuery: {
1536
+ type: "object",
1537
+ ...existing ?? {},
1538
+ properties: {
1539
+ ...existingProps,
1540
+ limit: patchedLimit
1541
+ }
1542
+ }
1543
+ };
1544
+ }
1545
+ /**
1438
1546
  * Apply per-resource `openApiSchemas` overrides on top of the kit's
1439
1547
  * generated schemas. Shallow merge by slot — users who want field-level
1440
1548
  * surgery should compose at the schema-options layer before this point.
@@ -1547,24 +1655,8 @@ function validateActionsShape(config) {
1547
1655
  }
1548
1656
  //#endregion
1549
1657
  //#region src/core/defineResource.ts
1550
- /**
1551
- * `TDoc` is **unconstrained** at this layer. The previous `TDoc
1552
- * extends AnyRecord` bound leaked out of `BaseController`'s
1553
- * mixin-composition requirement into every host's adapter boundary:
1554
- * Mongoose's `HydratedDocument<T>`, Prisma's generated row types,
1555
- * and any domain interface without an explicit index signature all
1556
- * failed to satisfy `Record<string, unknown>` even though at runtime
1557
- * they ARE string-keyed objects. Hosts were forced to cast at every
1558
- * adapter (`as RepositoryLike<Record<string, unknown>>`) — a type
1559
- * escape with no runtime purpose, since arc's pipeline only reads
1560
- * known envelope fields.
1561
- *
1562
- * The cast moved inside `resolveOrAutoCreateController` where
1563
- * `BaseController<TDoc extends AnyRecord>` actually requires it.
1564
- * One internal boundary cast replaces N host-side casts.
1565
- */
1566
1658
  function defineResource(config) {
1567
- const normalisedConfig = resolveCrudAllowList(config);
1659
+ const normalisedConfig = resolveCrudAllowList(expandCustomRoutesOnly(expandReferenceData(config)));
1568
1660
  let diagnostics = [];
1569
1661
  if (!normalisedConfig.skipValidation) diagnostics = validateDefineResourceConfig(normalisedConfig);
1570
1662
  const repository = normalisedConfig.adapter?.repository;
@@ -1606,6 +1698,57 @@ function defineResource(config) {
1606
1698
  * doesn't false-positive "Data adapter required when CRUD routes are
1607
1699
  * enabled" on a host that explicitly opted CRUD out.
1608
1700
  */
1701
+ /**
1702
+ * Expand the `referenceData: true` shorthand. Reference data is the
1703
+ * recurring "small static catalogue" shape (currencies, plans,
1704
+ * pipeline stages, credential types) where callers want "fetch all"
1705
+ * semantics and aggressive caching — pre-2.17.0 hosts hand-wired
1706
+ * `crud` + `defaultLimit` + `maxLimit` + `cache` on every such
1707
+ * resource. The shorthand pins canonical defaults; explicit narrow
1708
+ * settings always win so a host can opt INTO mutations
1709
+ * (`referenceData: true, crud: { list: true, get: true, create: true }`)
1710
+ * or override the cache window without giving up the read-only +
1711
+ * fetch-all defaults.
1712
+ */
1713
+ function expandReferenceData(config) {
1714
+ if (config.referenceData !== true) return config;
1715
+ return {
1716
+ ...config,
1717
+ crud: config.crud ?? {
1718
+ list: true,
1719
+ get: true
1720
+ },
1721
+ defaultLimit: config.defaultLimit ?? 1e3,
1722
+ maxLimit: config.maxLimit ?? 1e3,
1723
+ cache: config.cache ?? {
1724
+ staleTime: 300,
1725
+ gcTime: 600
1726
+ }
1727
+ };
1728
+ }
1729
+ /**
1730
+ * Expand the `customRoutesOnly: true` shorthand into its three primitive
1731
+ * flags. The shorthand exists because hosts wiring a "service resource"
1732
+ * (custom routes only, no adapter, no auto-CRUD) had to remember to set
1733
+ * `disableDefaultRoutes` + `skipValidation` + `skipRegistry` in lockstep —
1734
+ * forgetting any one of them produced confusing errors ("controller
1735
+ * required when CRUD routes are enabled" for a resource that ships none).
1736
+ *
1737
+ * Explicit narrow settings always win — the shorthand only fills in
1738
+ * flags that were not set so power users can opt back into a specific
1739
+ * primitive (`customRoutesOnly: true, skipRegistry: false` keeps OpenAPI
1740
+ * docs while still skipping CRUD + validation).
1741
+ */
1742
+ function expandCustomRoutesOnly(config) {
1743
+ if (config.customRoutesOnly !== true) return config;
1744
+ return {
1745
+ ...config,
1746
+ customRoutesOnly: void 0,
1747
+ disableDefaultRoutes: config.disableDefaultRoutes ?? true,
1748
+ skipValidation: config.skipValidation ?? true,
1749
+ skipRegistry: config.skipRegistry ?? true
1750
+ };
1751
+ }
1609
1752
  function resolveCrudAllowList(config) {
1610
1753
  const { crud, disabledRoutes: legacyDisabled, disableDefaultRoutes: legacyDisableAll } = config;
1611
1754
  if (crud === void 0) return config;
@@ -1,7 +1,7 @@
1
1
  import { t as __exportAll } from "./chunk-BpYLSNr0.mjs";
2
2
  import "./constants-TrJVIJl0.mjs";
3
3
  import { f as createError } from "./errors-j4aJm1Wg.mjs";
4
- import { a as buildAuthMiddlewareForPermissions, c as buildPreHandlerChain, f as resolveRouterPluginMw, l as buildRateLimitConfig, n as buildActionPipelineHandler, p as selectPluginMw, r as buildArcDecorator, t as buildActionPermissionMw, u as resolvePipelineSteps, y as sendControllerResponse } from "./routerShared-ClV4cCaa.mjs";
4
+ import { S as sendControllerResponse, a as buildAuthMiddlewareForPermissions, f as resolvePipelineSteps, g as tryRegisterRoute, h as selectPluginMw, l as buildPreHandlerChain, m as resolveRouterPluginMw, n as buildActionPipelineHandler, r as buildArcDecorator, t as buildActionPermissionMw, u as buildRateLimitConfig } from "./routerShared-nCyJGOKX.mjs";
5
5
  import { n as schemaIRToJsonSchemaBranch, t as normalizeSchemaIR } from "./schemaIR-DAOvBWgT.mjs";
6
6
  //#region src/core/createActionRouter.ts
7
7
  var createActionRouter_exports = /* @__PURE__ */ __exportAll({
@@ -114,7 +114,7 @@ function createActionRouter(fastify, config) {
114
114
  pluginMw: selectPluginMw("POST", pluginMw),
115
115
  routeGuards
116
116
  })];
117
- fastify.route({
117
+ tryRegisterRoute(fastify, {
118
118
  method: "POST",
119
119
  url: urlPath,
120
120
  schema: routeSchema,
@@ -152,6 +152,9 @@ function createActionRouter(fastify, config) {
152
152
  throw createError(statusCode, err.message || `Failed to execute '${action}' action`, { code: errorCode });
153
153
  }
154
154
  }
155
+ }, {
156
+ resourceName,
157
+ op: `action${urlPath}`
155
158
  });
156
159
  fastify.log.debug({
157
160
  actions: actionSubset,
@@ -1,5 +1,5 @@
1
1
  import { f as createError, l as UnauthorizedError, r as ForbiddenError } from "./errors-j4aJm1Wg.mjs";
2
- import { c as buildPreHandlerChain, f as resolveRouterPluginMw, i as buildAuthMiddleware, l as buildRateLimitConfig, p as selectPluginMw, r as buildArcDecorator } from "./routerShared-ClV4cCaa.mjs";
2
+ import { h as selectPluginMw, i as buildAuthMiddleware, l as buildPreHandlerChain, m as resolveRouterPluginMw, r as buildArcDecorator, u as buildRateLimitConfig } from "./routerShared-nCyJGOKX.mjs";
3
3
  import { r as validateAggregations } from "./validate-DFIP2VrM.mjs";
4
4
  import { t as buildAggregationHandler } from "./buildHandler-C_SkBNF3.mjs";
5
5
  //#region src/core/aggregation/createAggregationRouter.ts