@classytic/arc 2.18.5 → 2.20.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 (92) hide show
  1. package/README.md +1 -1
  2. package/bin/arc.js +37 -0
  3. package/dist/{BaseController-Dy6EQHjM.mjs → BaseController-D-MHZ27_.mjs} +4 -4
  4. package/dist/{ResourceRegistry-5fKZwhK_.mjs → ResourceRegistry-7RnrEWNP.mjs} +1 -1
  5. package/dist/{actionPermissions-h7DYDNMg.mjs → actionPermissions-C9RMiIpc.mjs} +2 -1
  6. package/dist/audit/index.mjs +1 -1
  7. package/dist/auth/index.d.mts +1 -1
  8. package/dist/auth/index.mjs +17 -6
  9. package/dist/{buildHandler-B0qvAguf.mjs → buildHandler-D_2ULWxZ.mjs} +3 -3
  10. package/dist/cache/index.mjs +2 -2
  11. package/dist/cli/commands/describe.d.mts +1 -1
  12. package/dist/cli/commands/describe.mjs +2 -2
  13. package/dist/cli/commands/docs.mjs +2 -2
  14. package/dist/cli/commands/generate.mjs +1 -1
  15. package/dist/cli/commands/init.mjs +1 -1
  16. package/dist/cli/commands/introspect.mjs +4 -13
  17. package/dist/cli/index.mjs +2 -2
  18. package/dist/{constants-gaEeMlz-.mjs → constants-DNre0wRg.mjs} +1 -1
  19. package/dist/core/index.d.mts +2 -2
  20. package/dist/core/index.mjs +4 -4
  21. package/dist/{core-ZnQBa5rh.mjs → core-CWhyGkiM.mjs} +136 -59
  22. package/dist/{createActionRouter-CagItpxI.mjs → createActionRouter-HlQso0jO.mjs} +8 -7
  23. package/dist/{createAggregationRouter-DVKroXhC.mjs → createAggregationRouter-5LzPzgw1.mjs} +5 -4
  24. package/dist/{createApp--703hQE1.mjs → createApp-D6uwzCKu.mjs} +210 -17
  25. package/dist/docs/index.d.mts +1 -1
  26. package/dist/docs/index.mjs +1 -1
  27. package/dist/encryption/index.d.mts +222 -0
  28. package/dist/encryption/index.mjs +270 -0
  29. package/dist/events/index.mjs +9 -6
  30. package/dist/factory/index.d.mts +3 -2
  31. package/dist/factory/index.mjs +2 -2
  32. package/dist/{fastify-augmentation-Cxb0ROIw.d.mts → fastify-augmentation-CCQJEDgh.d.mts} +12 -1
  33. package/dist/{generate-Brans3fW.mjs → generate-usRSdQmP.mjs} +4 -3
  34. package/dist/hooks/index.d.mts +1 -1
  35. package/dist/idempotency/index.mjs +1 -1
  36. package/dist/{index-CRKiiP5o.d.mts → index-CdFkIMEd.d.mts} +5 -0
  37. package/dist/{index-Dh0_RSTd.d.mts → index-DUa8WHz3.d.mts} +7 -7
  38. package/dist/{index-CHNQIhcb.d.mts → index-_--GyzcO.d.mts} +111 -2
  39. package/dist/index.d.mts +3 -3
  40. package/dist/index.mjs +6 -6
  41. package/dist/{init-Bqpt1Hxf.mjs → init-YH_nslQR.mjs} +87 -5
  42. package/dist/integrations/index.d.mts +1 -1
  43. package/dist/integrations/mcp/index.d.mts +2 -2
  44. package/dist/integrations/mcp/index.mjs +1 -1
  45. package/dist/integrations/mcp/testing.d.mts +1 -1
  46. package/dist/integrations/mcp/testing.mjs +1 -1
  47. package/dist/integrations/streamline.d.mts +17 -5
  48. package/dist/integrations/streamline.mjs +9 -5
  49. package/dist/{loadResourcesFromEntry-BTlFEx05.mjs → loadResourcesFromEntry-OMCvIEE7.mjs} +1 -1
  50. package/dist/{mcpPlugin-D1wuRD2c.mjs → mcpPlugin-CuW6L_uD.mjs} +148 -49
  51. package/dist/middleware/index.d.mts +1 -1
  52. package/dist/middleware/index.mjs +1 -1
  53. package/dist/module-JYH9V0I8.d.mts +384 -0
  54. package/dist/{openapi-xVJibapr.mjs → openapi-Dq6RbEGK.mjs} +3 -3
  55. package/dist/permissions/index.d.mts +1 -1
  56. package/dist/permissions/index.mjs +1 -1
  57. package/dist/{permissions-BPB1xqGf.mjs → permissions-CgbOLJZG.mjs} +10 -2
  58. package/dist/pipeline/index.d.mts +1 -1
  59. package/dist/plugins/index.d.mts +10 -5
  60. package/dist/plugins/index.mjs +3 -3
  61. package/dist/plugins/tracing-entry.mjs +1 -1
  62. package/dist/{pluralize-DccFV9R7.mjs → pluralize-BhrgwZb9.mjs} +3 -3
  63. package/dist/presets/filesUpload.d.mts +1 -1
  64. package/dist/presets/filesUpload.mjs +1 -1
  65. package/dist/presets/index.d.mts +16 -2
  66. package/dist/presets/index.mjs +1 -1
  67. package/dist/presets/multiTenant.d.mts +1 -1
  68. package/dist/presets/multiTenant.mjs +1 -1
  69. package/dist/presets/search.d.mts +1 -1
  70. package/dist/presets/search.mjs +3 -2
  71. package/dist/{presets-BtyFwTO4.mjs → presets-DWsYQdbV.mjs} +1 -1
  72. package/dist/{queryCachePlugin-emHb0CuX.mjs → queryCachePlugin-DzqLN904.mjs} +2 -2
  73. package/dist/{registerRoute-CPmdkZGu.mjs → registerRoute-DGSREwPw.mjs} +20 -2
  74. package/dist/registry/index.d.mts +1 -1
  75. package/dist/registry/index.mjs +2 -2
  76. package/dist/{registry-Bm9LjWKs.mjs → registry-vcOCtPij.mjs} +1 -1
  77. package/dist/{schemaIR-D1MJwqmp.mjs → schemaIR-ajetw3Fv.mjs} +1 -42
  78. package/dist/scim/index.mjs +2 -2
  79. package/dist/sync/changelog.d.mts +2 -0
  80. package/dist/sync/changelog.mjs +2 -0
  81. package/dist/testing/index.d.mts +2 -2
  82. package/dist/testing/index.mjs +2 -2
  83. package/dist/types/index.d.mts +2 -2
  84. package/dist/{types-CmkknLzI.d.mts → types-BHMKlMBU.d.mts} +53 -195
  85. package/dist/{types-D60PFwfG.d.mts → types-C9b2HR5W.d.mts} +1 -1
  86. package/dist/utils/index.d.mts +1 -1
  87. package/dist/utils/index.mjs +1 -1
  88. package/dist/{utils-C6QQ9-hd.mjs → utils-ChjYQpjn.mjs} +1400 -1400
  89. package/dist/{validate-CRcaWzp2.mjs → validate-C2mGgSXg.mjs} +3 -3
  90. package/package.json +22 -8
  91. package/skills/arc/SKILL.md +10 -0
  92. package/skills/arc/references/mcp.md +2 -0
@@ -1,18 +1,57 @@
1
- import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-gaEeMlz-.mjs";
1
+ import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-DNre0wRg.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
3
  import { r as ForbiddenError } from "./errors-DkO8Nk6-.mjs";
4
4
  import { b as isElevated, n as PUBLIC_SCOPE, x as isMember } from "./types-bHblZ9YN.mjs";
5
- import { p as assertValidConfig, x as getDefaultCrudSchemas } from "./utils-C6QQ9-hd.mjs";
6
- import { d as DEFAULT_FIELD_WRITE_DENIAL_POLICY, t as BaseController } from "./BaseController-Dy6EQHjM.mjs";
7
- import { F as applyFieldWritePermissions, L as resolveEffectiveRoles } from "./permissions-BPB1xqGf.mjs";
5
+ import { A as assertValidConfig, l as getDefaultCrudSchemas } from "./utils-ChjYQpjn.mjs";
6
+ import { d as DEFAULT_FIELD_WRITE_DENIAL_POLICY, t as BaseController } from "./BaseController-D-MHZ27_.mjs";
7
+ import { a as selectPluginMw, c as buildPreHandlerChain, f as buildArcDecorator, i as resolveRouterPluginMw, l as resolveRoutePreHandlers, n as buildRateLimitConfig, r as buildRouteConfig, s as buildCrudPermissionMw, t as tryRegisterRoute, u as buildAuthMiddleware } from "./registerRoute-DGSREwPw.mjs";
8
+ import { F as applyFieldWritePermissions, L as resolveEffectiveRoles } from "./permissions-CgbOLJZG.mjs";
8
9
  import { t as getUserRoles } from "./types-XTApa8b6.mjs";
10
+ import { t as applyPresets } from "./presets-DWsYQdbV.mjs";
9
11
  import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-U0MnO9vd.mjs";
10
12
  import { i as pipeUIMessageStreamToReply, r as isReadableStream } from "./streaming-BQcW6_Kq.mjs";
11
- import { a as createCrudHandlers, d as buildRequestScopeProjection, i as resolvePipelineSteps, o as createFastifyHandler, r as buildPipelineHandler, s as createRequestContext, t as resolveActionPermission } from "./actionPermissions-h7DYDNMg.mjs";
12
- import { c as resolveRoutePreHandlers, d as buildArcDecorator, i as selectPluginMw, l as buildAuthMiddleware, n as buildRateLimitConfig, o as buildCrudPermissionMw, r as resolveRouterPluginMw, s as buildPreHandlerChain, t as tryRegisterRoute } from "./registerRoute-CPmdkZGu.mjs";
13
- import { t as applyPresets } from "./presets-BtyFwTO4.mjs";
14
13
  import { t as hasEvents } from "./typeGuards-C9KXUly6.mjs";
15
- import { t as pluralize } from "./pluralize-DccFV9R7.mjs";
14
+ import { a as createCrudHandlers, d as buildRequestScopeProjection, i as resolvePipelineSteps, o as createFastifyHandler, r as buildPipelineHandler, s as createRequestContext, t as resolveActionPermission } from "./actionPermissions-C9RMiIpc.mjs";
15
+ import { t as pluralize } from "./pluralize-BhrgwZb9.mjs";
16
+ //#region src/core/middlewares/fieldWrite.ts
17
+ /**
18
+ * Build a preHandler that enforces field-write permissions on the request body.
19
+ *
20
+ * Auto-CRUD's create/update routes get this for free via `BodySanitizer.sanitize()`
21
+ * inside `BaseController`. Custom routes (`config.routes`, presets, action endpoints)
22
+ * never went through that path — so a host that declared
23
+ * `fields: { role: fields.writableBy(['admin']) }` and a custom
24
+ * `POST /users/promote` happily accepted `{ role: 'admin' }` from any caller.
25
+ *
26
+ * This preHandler closes that gap. It applies ONLY the `applyFieldWritePermissions`
27
+ * step — no `SYSTEM_FIELDS` strip, no `fieldRules` strip — because custom-route
28
+ * bodies don't necessarily mirror the resource's adapter shape, and we'd
29
+ * generate surprising 403s by enforcing rules tied to that shape.
30
+ *
31
+ * Returns `null` when the resource has no field permissions configured — the
32
+ * caller should then skip wiring it into the preHandler chain.
33
+ */
34
+ function buildFieldWritePreHandler(fieldPermissions, policy = DEFAULT_FIELD_WRITE_DENIAL_POLICY) {
35
+ if (!fieldPermissions || Object.keys(fieldPermissions).length === 0) return null;
36
+ return async (request, _reply) => {
37
+ const body = request.body;
38
+ if (!body || typeof body !== "object" || Array.isArray(body)) return;
39
+ const scope = request.scope ?? PUBLIC_SCOPE;
40
+ if (isElevated(scope)) return;
41
+ const { body: filtered, deniedFields } = applyFieldWritePermissions(body, fieldPermissions, resolveEffectiveRoles(getUserRoles(request.user), isMember(scope) ? scope.orgRoles : []));
42
+ if (deniedFields.length > 0 && policy === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
43
+ request.body = filtered;
44
+ };
45
+ }
46
+ /**
47
+ * True for HTTP methods that carry a request body and should run the
48
+ * field-write preHandler. GET/DELETE/HEAD/OPTIONS skip enforcement.
49
+ */
50
+ function methodCarriesBody(method) {
51
+ const m = method.toUpperCase();
52
+ return m === "POST" || m === "PUT" || m === "PATCH";
53
+ }
54
+ //#endregion
16
55
  //#region src/core/aggregation/defineAggregation.ts
17
56
  /**
18
57
  * Declare a single resource aggregation. Exported configs flow into
@@ -59,52 +98,13 @@ function defineAggregation(config) {
59
98
  return config;
60
99
  }
61
100
  //#endregion
62
- //#region src/core/middlewares/fieldWrite.ts
63
- /**
64
- * Build a preHandler that enforces field-write permissions on the request body.
65
- *
66
- * Auto-CRUD's create/update routes get this for free via `BodySanitizer.sanitize()`
67
- * inside `BaseController`. Custom routes (`config.routes`, presets, action endpoints)
68
- * never went through that path — so a host that declared
69
- * `fields: { role: fields.writableBy(['admin']) }` and a custom
70
- * `POST /users/promote` happily accepted `{ role: 'admin' }` from any caller.
71
- *
72
- * This preHandler closes that gap. It applies ONLY the `applyFieldWritePermissions`
73
- * step — no `SYSTEM_FIELDS` strip, no `fieldRules` strip — because custom-route
74
- * bodies don't necessarily mirror the resource's adapter shape, and we'd
75
- * generate surprising 403s by enforcing rules tied to that shape.
76
- *
77
- * Returns `null` when the resource has no field permissions configured — the
78
- * caller should then skip wiring it into the preHandler chain.
79
- */
80
- function buildFieldWritePreHandler(fieldPermissions, policy = DEFAULT_FIELD_WRITE_DENIAL_POLICY) {
81
- if (!fieldPermissions || Object.keys(fieldPermissions).length === 0) return null;
82
- return async (request, _reply) => {
83
- const body = request.body;
84
- if (!body || typeof body !== "object" || Array.isArray(body)) return;
85
- const scope = request.scope ?? PUBLIC_SCOPE;
86
- if (isElevated(scope)) return;
87
- const { body: filtered, deniedFields } = applyFieldWritePermissions(body, fieldPermissions, resolveEffectiveRoles(getUserRoles(request.user), isMember(scope) ? scope.orgRoles : []));
88
- if (deniedFields.length > 0 && policy === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
89
- request.body = filtered;
90
- };
91
- }
92
- /**
93
- * True for HTTP methods that carry a request body and should run the
94
- * field-write preHandler. GET/DELETE/HEAD/OPTIONS skip enforcement.
95
- */
96
- function methodCarriesBody(method) {
97
- const m = method.toUpperCase();
98
- return m === "POST" || m === "PUT" || m === "PATCH";
99
- }
100
- //#endregion
101
101
  //#region src/core/createCrudRouter.ts
102
102
  /**
103
103
  * Mount custom routes (from presets or user-defined `routes`) on Fastify.
104
104
  * `wrapHandler` is derived inline from `!route.raw`.
105
105
  */
106
106
  function createCustomRoutes(fastify, routes, controller, options) {
107
- const { tag, resourceName, arcDecorator, rateLimitConfig, pluginMw, pipeline, routeGuards, tenantScopeMw, fieldPermissions, onFieldWriteDenied } = options;
107
+ const { tag, resourceName, arcDecorator, rateLimitConfig, pluginMw, pipeline, routeGuards, tenantScopeMw, fieldPermissions, onFieldWriteDenied, extensions } = options;
108
108
  for (const route of routes) {
109
109
  const routeWithRefs = route;
110
110
  const hasHandler = route.handler !== void 0;
@@ -177,7 +177,7 @@ function createCustomRoutes(fastify, routes, controller, options) {
177
177
  }
178
178
  return result;
179
179
  } : handler,
180
- ...rateLimitConfig ? { config: rateLimitConfig } : {}
180
+ ...buildRouteConfig(route.rateLimit !== void 0 ? buildRateLimitConfig(route.rateLimit) : rateLimitConfig, extensions)
181
181
  }, {
182
182
  resourceName,
183
183
  op: opName
@@ -194,7 +194,7 @@ function createCustomRoutes(fastify, routes, controller, options) {
194
194
  * @param options - Router configuration
195
195
  */
196
196
  function createCrudRouter(fastify, controller, options = {}) {
197
- 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;
197
+ const { tag = "Resource", schemas = {}, permissions = {}, middlewares = {}, routeGuards = [], routes: customRoutes = [], disableDefaultRoutes = false, disabledRoutes = [], resourceName = "unknown", schemaOptions, rateLimit, pipe: pipeline, fields: fieldPermissions, onFieldWriteDenied, updateMethod = DEFAULT_UPDATE_METHOD, idField, extensions } = options;
198
198
  const rateLimitConfig = buildRateLimitConfig(rateLimit);
199
199
  const resourceHasQueryCache = fastify.hasDecorator("queryCache") && controller && typeof controller._cacheConfig !== "undefined" && controller._cacheConfig !== void 0;
200
200
  const pluginMw = resolveRouterPluginMw(fastify, Boolean(resourceHasQueryCache));
@@ -295,7 +295,7 @@ function createCrudRouter(fastify, controller, options = {}) {
295
295
  }, defaultSchemas[spec.op], schemas[spec.op]),
296
296
  preHandler: preHandler.length > 0 ? preHandler : void 0,
297
297
  handler: handlers[spec.op],
298
- ...rateLimitConfig ? { config: rateLimitConfig } : {}
298
+ ...buildRouteConfig(rateLimitConfig, extensions)
299
299
  }, {
300
300
  resourceName,
301
301
  op: spec.op
@@ -313,7 +313,8 @@ function createCrudRouter(fastify, controller, options = {}) {
313
313
  routeGuards,
314
314
  tenantScopeMw,
315
315
  fieldPermissions,
316
- onFieldWriteDenied
316
+ onFieldWriteDenied,
317
+ extensions
317
318
  });
318
319
  }
319
320
  function buildCrudHandlers(ctrl, pipeline, resourceName) {
@@ -948,6 +949,42 @@ function computeHasCrudRoutes(config) {
948
949
  const disabled = new Set(config.disabledRoutes ?? []);
949
950
  return !config.disableDefaultRoutes && CRUD_OPERATIONS.some((op) => !disabled.has(op));
950
951
  }
952
+ const WRITE_OPERATIONS = /* @__PURE__ */ new Set([
953
+ "create",
954
+ "update",
955
+ "delete"
956
+ ]);
957
+ /**
958
+ * Public-by-omission CRUD diagnostic (post-preset).
959
+ *
960
+ * An omitted CRUD permission mounts the route with NO permission
961
+ * middleware at all — unlike custom routes and actions, which fail boot
962
+ * without one. That asymmetry silently shipped unauthenticated writes
963
+ * (`permissions: readOnly()` pre-2.20 being the canonical victim).
964
+ *
965
+ * Runs against `resolvedConfig` — never the raw user config — so
966
+ * permissions injected by presets (ownedByUser, multiTenant policy
967
+ * wiring, ...) count as gates and don't false-positive.
968
+ *
969
+ * Severity ladder:
970
+ * - `warn` when any enabled WRITE op (create/update/delete) is ungated —
971
+ * this is real exposure the host must acknowledge.
972
+ * - `info` when only reads are ungated — public catalogs
973
+ * (`referenceData: true`) are a legitimate shape, but the host should
974
+ * still state intent with `permissions.fullPublic()` / `allowPublic()`.
975
+ */
976
+ function collectUngatedCrudDiagnostics(config) {
977
+ if (config.disableDefaultRoutes) return [];
978
+ const disabled = new Set(config.disabledRoutes ?? []);
979
+ const permissions = config.permissions ?? {};
980
+ const ungated = CRUD_OPERATIONS.filter((op) => !disabled.has(op) && typeof permissions[op] !== "function");
981
+ if (ungated.length === 0) return [];
982
+ return [{
983
+ severity: ungated.some((op) => WRITE_OPERATIONS.has(op)) ? "warn" : "info",
984
+ code: "crud-public-by-omission",
985
+ message: `[Arc] Resource '${config.name}' mounts ${ungated.length} CRUD route(s) with no permission gate: ${ungated.join(", ")}.\nAn omitted permission mounts the route WITHOUT auth. State intent explicitly:\n permissions: permissions.fullPublic() // intentionally public\n permissions: permissions.authenticated() // require auth on every op\n permissions: { ${ungated[0]}: requireAuth(), ... } // per-operation`
986
+ }];
987
+ }
951
988
  //#endregion
952
989
  //#region src/registry/resolveTenantPurge.ts
953
990
  const DEFAULT_PRIORITY = 100;
@@ -1101,7 +1138,7 @@ function deepMergeSchemas(base, override) {
1101
1138
  if (!override) return base;
1102
1139
  if (!base) return override;
1103
1140
  const result = { ...base };
1104
- for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [...new Set([...result[key], ...value])];
1141
+ for (const [key, value] of Object.entries(override)) if (Array.isArray(value) && Array.isArray(result[key])) result[key] = [.../* @__PURE__ */ new Set([...result[key], ...value])];
1105
1142
  else if (value && typeof value === "object" && !Array.isArray(value)) result[key] = deepMergeSchemas(result[key], value);
1106
1143
  else result[key] = value;
1107
1144
  return result;
@@ -1242,10 +1279,11 @@ function buildResourcePlugin(resource) {
1242
1279
  pipe: resource.pipe,
1243
1280
  fields: resource.fields,
1244
1281
  onFieldWriteDenied: resource.onFieldWriteDenied,
1245
- idField: resource.idField
1282
+ idField: resource.idField,
1283
+ extensions: resource.extensions
1246
1284
  });
1247
1285
  if (resource.actions && Object.keys(resource.actions).length > 0) {
1248
- const { createActionRouter } = await import("./createActionRouter-CagItpxI.mjs").then((n) => n.n);
1286
+ const { createActionRouter } = await import("./createActionRouter-HlQso0jO.mjs").then((n) => n.n);
1249
1287
  createActionRouter(typedInstance, {
1250
1288
  ...normalizeActionsToRouterConfig(resource.actions, resource.actionPermissions, resource.tag, resource.permissions, resource.name, typedInstance.log),
1251
1289
  resourceName: resource.name,
@@ -1255,11 +1293,12 @@ function buildResourcePlugin(resource) {
1255
1293
  permissions: resource.permissions,
1256
1294
  routeGuards: resource.routeGuards,
1257
1295
  pipeline: resource.pipe,
1258
- rateLimit: resource.rateLimit
1296
+ rateLimit: resource.rateLimit,
1297
+ extensions: resource.extensions
1259
1298
  });
1260
1299
  }
1261
1300
  if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
1262
- const [{ createAggregationRouter }, { adapterSupportsAggregate, ArcAggregationConfigError }] = await Promise.all([import("./createAggregationRouter-DVKroXhC.mjs"), import("./validate-CRcaWzp2.mjs").then((n) => n.r)]);
1301
+ const [{ createAggregationRouter }, { adapterSupportsAggregate, ArcAggregationConfigError }] = await Promise.all([import("./createAggregationRouter-5LzPzgw1.mjs"), import("./validate-C2mGgSXg.mjs").then((n) => n.r)]);
1263
1302
  const repoForAgg = resource.controller?.repository ?? resource.repository;
1264
1303
  if (!adapterSupportsAggregate(repoForAgg)) {
1265
1304
  const undispatchable = Object.entries(resource.aggregations).filter(([, cfg]) => !cfg.materialized).map(([name]) => name);
@@ -1332,6 +1371,7 @@ var ResourceDefinition = class {
1332
1371
  */
1333
1372
  onFieldWriteDenied;
1334
1373
  cache;
1374
+ extensions;
1335
1375
  referenceData;
1336
1376
  /**
1337
1377
  * Per-resource MCP opt-out. `false` keeps the resource out of every
@@ -1429,6 +1469,7 @@ var ResourceDefinition = class {
1429
1469
  this.fields = config.fields;
1430
1470
  this.onFieldWriteDenied = config.onFieldWriteDenied;
1431
1471
  this.cache = config.cache;
1472
+ this.extensions = config.extensions ? Object.freeze({ ...config.extensions }) : void 0;
1432
1473
  this.mcp = config.mcp !== false;
1433
1474
  this.referenceData = config.referenceData === true;
1434
1475
  this.tenantField = config.tenantField;
@@ -1713,7 +1754,7 @@ function mergeUserOpenApiOverrides(openApiSchemas, userOverrides) {
1713
1754
  * call) since the set is fixed and the cost of re-allocating is a
1714
1755
  * pointless boot tax for hosts with hundreds of resources.
1715
1756
  */
1716
- const CRUD_OP_NAMES = new Set([
1757
+ const CRUD_OP_NAMES = /* @__PURE__ */ new Set([
1717
1758
  "create",
1718
1759
  "update",
1719
1760
  "delete",
@@ -1733,7 +1774,42 @@ function validateDefineResourceConfig(config) {
1733
1774
  validatePermissionsShape(config);
1734
1775
  validateCustomRoutePermissions(config);
1735
1776
  validateActionsShape(config);
1736
- return collectRedundantFieldRuleDiagnostics(config);
1777
+ return [...collectRedundantFieldRuleDiagnostics(config), ...collectReservedFilterNameDiagnostics(config)];
1778
+ }
1779
+ /**
1780
+ * Query-string param names arc's `QueryParser` reserves for
1781
+ * pagination/projection (`q.after ?? q.cursor` is the keyset cursor). A
1782
+ * document field with one of these names can never be filtered via the query
1783
+ * string — the parser consumes the name before it reaches the filter map — and
1784
+ * the same reservation flows into the MCP list-tool schema. Silent pre-2.20;
1785
+ * now surfaced.
1786
+ */
1787
+ const RESERVED_QUERY_PARAMS = /* @__PURE__ */ new Set([
1788
+ "page",
1789
+ "limit",
1790
+ "cursor",
1791
+ "after",
1792
+ "sort",
1793
+ "search",
1794
+ "select",
1795
+ "populate"
1796
+ ]);
1797
+ /**
1798
+ * Warn when a declared filterable field's NAME collides with a reserved query
1799
+ * param. Verified empirically (tests/e2e/reserved-field-name-collision.test.ts):
1800
+ * a field named `cursor`/`page` is silently unfilterable. Boot diagnostic, not
1801
+ * a hard error — the resource still works, the field just isn't query-filterable.
1802
+ */
1803
+ function collectReservedFilterNameDiagnostics(config) {
1804
+ const filterable = config.schemaOptions?.filterableFields;
1805
+ if (!filterable) return [];
1806
+ const collisions = filterable.filter((f) => RESERVED_QUERY_PARAMS.has(f));
1807
+ if (collisions.length === 0) return [];
1808
+ return [{
1809
+ severity: "warn",
1810
+ code: "filter-field-reserved-name",
1811
+ message: `[Arc] Resource '${config.name}': filterable field(s) [${collisions.join(", ")}] collide with reserved query params (page, limit, cursor, after, sort, search, select, populate). The query parser consumes these names for pagination/projection, so filtering by them via the query string — and via the MCP list tool — will NOT work. Rename the field(s), or drop them from \`filterableFields\` if the collision is intended.`
1812
+ }];
1737
1813
  }
1738
1814
  /** Permissions must be `PermissionCheck` functions, not arbitrary values. */
1739
1815
  function validatePermissionsShape(config) {
@@ -1815,6 +1891,7 @@ function defineResource(config) {
1815
1891
  const configWithId = resolveIdField(normalisedConfig, repository);
1816
1892
  const resolvedConfig = applyPresetsAndAutoInject(configWithId);
1817
1893
  const hasCrudRoutes = computeHasCrudRoutes(resolvedConfig);
1894
+ if (!normalisedConfig.skipValidation) diagnostics = diagnostics.concat(collectUngatedCrudDiagnostics(resolvedConfig));
1818
1895
  const narrowedConfig = resolvedConfig;
1819
1896
  const narrowedAdapter = configWithId.adapter;
1820
1897
  const controller = resolveOrAutoCreateController(narrowedConfig, narrowedAdapter, repository, hasCrudRoutes);
@@ -1,9 +1,9 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
- import "./constants-gaEeMlz-.mjs";
2
+ import "./constants-DNre0wRg.mjs";
3
3
  import { f as createError } from "./errors-DkO8Nk6-.mjs";
4
- import { i as resolvePipelineSteps, n as buildActionPipelineHandler, u as sendControllerResponse } from "./actionPermissions-h7DYDNMg.mjs";
5
- import { a as buildActionPermissionMw, d as buildArcDecorator, i as selectPluginMw, n as buildRateLimitConfig, r as resolveRouterPluginMw, s as buildPreHandlerChain, t as tryRegisterRoute, u as buildAuthMiddlewareForPermissions } from "./registerRoute-CPmdkZGu.mjs";
6
- import { n as schemaIRToJsonSchemaBranch, t as normalizeSchemaIR } from "./schemaIR-D1MJwqmp.mjs";
4
+ import { a as selectPluginMw, c as buildPreHandlerChain, d as buildAuthMiddlewareForPermissions, f as buildArcDecorator, i as resolveRouterPluginMw, n as buildRateLimitConfig, o as buildActionPermissionMw, r as buildRouteConfig, t as tryRegisterRoute } from "./registerRoute-DGSREwPw.mjs";
5
+ import { i as resolvePipelineSteps, n as buildActionPipelineHandler, u as sendControllerResponse } from "./actionPermissions-C9RMiIpc.mjs";
6
+ import { n as schemaIRToJsonSchemaBranch, t as normalizeSchemaIR } from "./schemaIR-ajetw3Fv.mjs";
7
7
  //#region src/core/createActionRouter.ts
8
8
  var createActionRouter_exports = /* @__PURE__ */ __exportAll({
9
9
  buildActionBodySchema: () => buildActionBodySchema,
@@ -18,7 +18,7 @@ var createActionRouter_exports = /* @__PURE__ */ __exportAll({
18
18
  * (keyed by `body.action` at request time).
19
19
  */
20
20
  function createActionRouter(fastify, config) {
21
- const { tag, resourceName = tag ?? "action", actions, actionPermissions = {}, actionSchemas = {}, globalAuth, onError, fields: fieldPermissions, schemaOptions, idField = "_id", permissions: resourcePermissions, routeGuards = [], pipeline, rateLimit, idLessActionNames = [] } = config;
21
+ const { tag, resourceName = tag ?? "action", actions, actionPermissions = {}, actionSchemas = {}, globalAuth, onError, fields: fieldPermissions, schemaOptions, idField = "_id", permissions: resourcePermissions, routeGuards = [], pipeline, rateLimit, idLessActionNames = [], extensions } = config;
22
22
  const allActionNames = Object.keys(actions);
23
23
  if (allActionNames.length === 0) {
24
24
  fastify.log.warn("[createActionRouter] No actions defined, skipping route creation");
@@ -121,7 +121,7 @@ function createActionRouter(fastify, config) {
121
121
  schema: routeSchema,
122
122
  attachValidation: true,
123
123
  preHandler: preHandler.length > 0 ? preHandler : void 0,
124
- ...rateLimitConfig ? { config: rateLimitConfig } : {},
124
+ ...buildRouteConfig(rateLimitConfig, extensions),
125
125
  handler: async (req, reply) => {
126
126
  const { action, ...data } = req.body;
127
127
  const id = hasId ? req.params.id : "";
@@ -135,7 +135,8 @@ function createActionRouter(fastify, config) {
135
135
  const handler = wrappedHandlers.get(action);
136
136
  if (!handler) throw createError(400, `Invalid action '${action}'. Valid actions: ${actionSubset.join(", ")}`, { validActions: actionSubset });
137
137
  try {
138
- return sendControllerResponse(reply, await handler(id, data, req), req);
138
+ sendControllerResponse(reply, await handler(id, data, req), req);
139
+ return reply;
139
140
  } catch (error) {
140
141
  if (onError) {
141
142
  const { statusCode, error: errorMsg, code } = onError(error, action, id);
@@ -1,7 +1,7 @@
1
1
  import { f as createError, l as UnauthorizedError, r as ForbiddenError } from "./errors-DkO8Nk6-.mjs";
2
- import { d as buildArcDecorator, i as selectPluginMw, l as buildAuthMiddleware, n as buildRateLimitConfig, r as resolveRouterPluginMw, s as buildPreHandlerChain } from "./registerRoute-CPmdkZGu.mjs";
3
- import { n as validateAggregations } from "./validate-CRcaWzp2.mjs";
4
- import { t as buildAggregationHandler } from "./buildHandler-B0qvAguf.mjs";
2
+ import { a as selectPluginMw, c as buildPreHandlerChain, f as buildArcDecorator, i as resolveRouterPluginMw, n as buildRateLimitConfig, u as buildAuthMiddleware } from "./registerRoute-DGSREwPw.mjs";
3
+ import { n as validateAggregations } from "./validate-C2mGgSXg.mjs";
4
+ import { t as buildAggregationHandler } from "./buildHandler-D_2ULWxZ.mjs";
5
5
  //#region src/core/aggregation/createAggregationRouter.ts
6
6
  /**
7
7
  * Register one Fastify route per aggregation. No-op when the map is
@@ -39,7 +39,8 @@ function registerOne(fastify, normalized, ctx) {
39
39
  const authMw = buildAuthMiddleware(fastify, config.permissions);
40
40
  const permissionFn = config.permissions;
41
41
  const permissionMw = async (req, _reply) => {
42
- const raw = await permissionFn(buildPermissionContextLite(req, normalized.name));
42
+ const ctx = buildPermissionContextLite(req, normalized.name);
43
+ const raw = await permissionFn(ctx);
43
44
  if (!normalizePermissionGranted(raw)) {
44
45
  const status = req.user ? 403 : 401;
45
46
  const reason = normalizePermissionReason(raw);