@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
@@ -1,6 +1,6 @@
1
1
  import { t as __exportAll } from "./chunk-BpYLSNr0.mjs";
2
2
  import { C as isService, S as isOrgInScope, a as getDPoPJkt, b as isElevated, d as getScopeContext, f as getScopeContextMap, g as getUserId, m as getTeamId, o as getMandate, p as getServiceScopes, u as getRequestScope, v as hasOrgAccess, x as isMember } from "./types-Bi0r0vjG.mjs";
3
- import { t as getUserRoles } from "./types-D57iXYb8.mjs";
3
+ import { t as getUserRoles } from "./types-DV9WDfeg.mjs";
4
4
  import { t as MemoryCacheStore } from "./memory-QOLe11D5.mjs";
5
5
  import { randomUUID } from "node:crypto";
6
6
  //#region src/permissions/applyPermissionResult.ts
@@ -99,6 +99,173 @@ async function evaluateAndApplyPermission(check, context, request, reply, opts)
99
99
  return true;
100
100
  }
101
101
  //#endregion
102
+ //#region src/permissions/fields.ts
103
+ /**
104
+ * Field-Level Permissions
105
+ *
106
+ * Control field visibility and writability per role.
107
+ * Integrated into the response path (read) and sanitization path (write).
108
+ *
109
+ * @example
110
+ * ```typescript
111
+ * import { fields, defineResource } from '@classytic/arc';
112
+ *
113
+ * const userResource = defineResource({
114
+ * name: 'user',
115
+ * adapter: userAdapter,
116
+ * fields: {
117
+ * salary: fields.visibleTo(['admin', 'hr']),
118
+ * internalNotes: fields.writableBy(['admin']),
119
+ * email: fields.redactFor(['viewer']),
120
+ * password: fields.hidden(),
121
+ * },
122
+ * });
123
+ * ```
124
+ */
125
+ /** Type guard for Mongoose-like documents with toObject() */
126
+ function isMongooseDoc(obj) {
127
+ return !!obj && typeof obj === "object" && "toObject" in obj && typeof obj.toObject === "function";
128
+ }
129
+ const fields = {
130
+ /**
131
+ * Field is never included in responses. Not writable via API.
132
+ *
133
+ * @example
134
+ * ```typescript
135
+ * fields: { password: fields.hidden() }
136
+ * ```
137
+ */
138
+ hidden() {
139
+ return { _type: "hidden" };
140
+ },
141
+ /**
142
+ * Field is only visible to users with specified roles.
143
+ * Other users don't see the field at all.
144
+ *
145
+ * @example
146
+ * ```typescript
147
+ * fields: { salary: fields.visibleTo(['admin', 'hr']) }
148
+ * ```
149
+ */
150
+ visibleTo(roles) {
151
+ return {
152
+ _type: "visibleTo",
153
+ roles
154
+ };
155
+ },
156
+ /**
157
+ * Field is only writable by users with specified roles.
158
+ * All users can still read the field. Users without the role
159
+ * have the field silently stripped from write operations.
160
+ *
161
+ * @example
162
+ * ```typescript
163
+ * fields: { role: fields.writableBy(['admin']) }
164
+ * ```
165
+ */
166
+ writableBy(roles) {
167
+ return {
168
+ _type: "writableBy",
169
+ roles
170
+ };
171
+ },
172
+ /**
173
+ * Field is redacted (replaced with a placeholder) for specified roles.
174
+ * Other users see the real value.
175
+ *
176
+ * @param roles - Roles that see the redacted value
177
+ * @param redactValue - Replacement value (default: '***')
178
+ *
179
+ * @example
180
+ * ```typescript
181
+ * fields: {
182
+ * email: fields.redactFor(['viewer']),
183
+ * ssn: fields.redactFor(['basic'], '***-**-****'),
184
+ * }
185
+ * ```
186
+ */
187
+ redactFor(roles, redactValue = "***") {
188
+ return {
189
+ _type: "redactFor",
190
+ roles,
191
+ redactValue
192
+ };
193
+ }
194
+ };
195
+ /**
196
+ * Apply field-level READ permissions to a response object.
197
+ * Strips hidden fields, enforces visibility, and applies redaction.
198
+ *
199
+ * @param data - The response object (mutated in place for performance)
200
+ * @param fieldPermissions - Field permission map from resource config
201
+ * @param userRoles - Current user's roles (empty array for unauthenticated)
202
+ * @returns The filtered object
203
+ */
204
+ function applyFieldReadPermissions(data, fieldPermissions, userRoles) {
205
+ if (!data || typeof data !== "object") return data;
206
+ const result = { ...isMongooseDoc(data) ? data.toObject() : data };
207
+ for (const [field, perm] of Object.entries(fieldPermissions)) switch (perm._type) {
208
+ case "hidden":
209
+ delete result[field];
210
+ break;
211
+ case "visibleTo":
212
+ if (!perm.roles?.some((r) => userRoles.includes(r))) delete result[field];
213
+ break;
214
+ case "redactFor":
215
+ if (perm.roles?.some((r) => userRoles.includes(r))) result[field] = perm.redactValue ?? "***";
216
+ break;
217
+ case "writableBy": break;
218
+ }
219
+ return result;
220
+ }
221
+ /**
222
+ * Apply field-level WRITE permissions to request body.
223
+ *
224
+ * Returns both the filtered body and the list of denied fields. Callers are
225
+ * expected to reject the request when `deniedFields.length > 0` — silently
226
+ * stripping fields hides misconfigurations and real attacks. See
227
+ * `BodySanitizer` for the default policy.
228
+ *
229
+ * @param body - The request body (returns a new filtered copy)
230
+ * @param fieldPermissions - Field permission map from resource config
231
+ * @param userRoles - Current user's roles
232
+ */
233
+ function applyFieldWritePermissions(body, fieldPermissions, userRoles) {
234
+ const result = { ...body };
235
+ const deniedFields = [];
236
+ for (const [field, perm] of Object.entries(fieldPermissions)) switch (perm._type) {
237
+ case "hidden":
238
+ if (field in result) {
239
+ deniedFields.push(field);
240
+ delete result[field];
241
+ }
242
+ break;
243
+ case "writableBy":
244
+ if (field in result && !perm.roles?.some((r) => userRoles.includes(r))) {
245
+ deniedFields.push(field);
246
+ delete result[field];
247
+ }
248
+ break;
249
+ }
250
+ return {
251
+ body: result,
252
+ deniedFields
253
+ };
254
+ }
255
+ /**
256
+ * Resolve effective roles by merging global user roles with org-level roles.
257
+ *
258
+ * Global roles come from `req.user.role` (normalized via getUserRoles()).
259
+ * Org roles come from `req.context.orgRoles` (set by BA adapter's org bridge).
260
+ *
261
+ * When no org context exists, returns global roles only — backward compatible.
262
+ */
263
+ function resolveEffectiveRoles(userRoles, orgRoles) {
264
+ if (orgRoles.length === 0) return [...userRoles];
265
+ if (userRoles.length === 0) return [...orgRoles];
266
+ return [...new Set([...userRoles, ...orgRoles])];
267
+ }
268
+ //#endregion
102
269
  //#region src/permissions/agent.ts
103
270
  /**
104
271
  * Agent-Auth Permission Helpers — DPoP + capability mandates for AI-agent flows
@@ -1148,173 +1315,6 @@ function createDynamicPermissionMatrix(config) {
1148
1315
  };
1149
1316
  }
1150
1317
  //#endregion
1151
- //#region src/permissions/fields.ts
1152
- /**
1153
- * Field-Level Permissions
1154
- *
1155
- * Control field visibility and writability per role.
1156
- * Integrated into the response path (read) and sanitization path (write).
1157
- *
1158
- * @example
1159
- * ```typescript
1160
- * import { fields, defineResource } from '@classytic/arc';
1161
- *
1162
- * const userResource = defineResource({
1163
- * name: 'user',
1164
- * adapter: userAdapter,
1165
- * fields: {
1166
- * salary: fields.visibleTo(['admin', 'hr']),
1167
- * internalNotes: fields.writableBy(['admin']),
1168
- * email: fields.redactFor(['viewer']),
1169
- * password: fields.hidden(),
1170
- * },
1171
- * });
1172
- * ```
1173
- */
1174
- /** Type guard for Mongoose-like documents with toObject() */
1175
- function isMongooseDoc(obj) {
1176
- return !!obj && typeof obj === "object" && "toObject" in obj && typeof obj.toObject === "function";
1177
- }
1178
- const fields = {
1179
- /**
1180
- * Field is never included in responses. Not writable via API.
1181
- *
1182
- * @example
1183
- * ```typescript
1184
- * fields: { password: fields.hidden() }
1185
- * ```
1186
- */
1187
- hidden() {
1188
- return { _type: "hidden" };
1189
- },
1190
- /**
1191
- * Field is only visible to users with specified roles.
1192
- * Other users don't see the field at all.
1193
- *
1194
- * @example
1195
- * ```typescript
1196
- * fields: { salary: fields.visibleTo(['admin', 'hr']) }
1197
- * ```
1198
- */
1199
- visibleTo(roles) {
1200
- return {
1201
- _type: "visibleTo",
1202
- roles
1203
- };
1204
- },
1205
- /**
1206
- * Field is only writable by users with specified roles.
1207
- * All users can still read the field. Users without the role
1208
- * have the field silently stripped from write operations.
1209
- *
1210
- * @example
1211
- * ```typescript
1212
- * fields: { role: fields.writableBy(['admin']) }
1213
- * ```
1214
- */
1215
- writableBy(roles) {
1216
- return {
1217
- _type: "writableBy",
1218
- roles
1219
- };
1220
- },
1221
- /**
1222
- * Field is redacted (replaced with a placeholder) for specified roles.
1223
- * Other users see the real value.
1224
- *
1225
- * @param roles - Roles that see the redacted value
1226
- * @param redactValue - Replacement value (default: '***')
1227
- *
1228
- * @example
1229
- * ```typescript
1230
- * fields: {
1231
- * email: fields.redactFor(['viewer']),
1232
- * ssn: fields.redactFor(['basic'], '***-**-****'),
1233
- * }
1234
- * ```
1235
- */
1236
- redactFor(roles, redactValue = "***") {
1237
- return {
1238
- _type: "redactFor",
1239
- roles,
1240
- redactValue
1241
- };
1242
- }
1243
- };
1244
- /**
1245
- * Apply field-level READ permissions to a response object.
1246
- * Strips hidden fields, enforces visibility, and applies redaction.
1247
- *
1248
- * @param data - The response object (mutated in place for performance)
1249
- * @param fieldPermissions - Field permission map from resource config
1250
- * @param userRoles - Current user's roles (empty array for unauthenticated)
1251
- * @returns The filtered object
1252
- */
1253
- function applyFieldReadPermissions(data, fieldPermissions, userRoles) {
1254
- if (!data || typeof data !== "object") return data;
1255
- const result = { ...isMongooseDoc(data) ? data.toObject() : data };
1256
- for (const [field, perm] of Object.entries(fieldPermissions)) switch (perm._type) {
1257
- case "hidden":
1258
- delete result[field];
1259
- break;
1260
- case "visibleTo":
1261
- if (!perm.roles?.some((r) => userRoles.includes(r))) delete result[field];
1262
- break;
1263
- case "redactFor":
1264
- if (perm.roles?.some((r) => userRoles.includes(r))) result[field] = perm.redactValue ?? "***";
1265
- break;
1266
- case "writableBy": break;
1267
- }
1268
- return result;
1269
- }
1270
- /**
1271
- * Apply field-level WRITE permissions to request body.
1272
- *
1273
- * Returns both the filtered body and the list of denied fields. Callers are
1274
- * expected to reject the request when `deniedFields.length > 0` — silently
1275
- * stripping fields hides misconfigurations and real attacks. See
1276
- * `BodySanitizer` for the default policy.
1277
- *
1278
- * @param body - The request body (returns a new filtered copy)
1279
- * @param fieldPermissions - Field permission map from resource config
1280
- * @param userRoles - Current user's roles
1281
- */
1282
- function applyFieldWritePermissions(body, fieldPermissions, userRoles) {
1283
- const result = { ...body };
1284
- const deniedFields = [];
1285
- for (const [field, perm] of Object.entries(fieldPermissions)) switch (perm._type) {
1286
- case "hidden":
1287
- if (field in result) {
1288
- deniedFields.push(field);
1289
- delete result[field];
1290
- }
1291
- break;
1292
- case "writableBy":
1293
- if (field in result && !perm.roles?.some((r) => userRoles.includes(r))) {
1294
- deniedFields.push(field);
1295
- delete result[field];
1296
- }
1297
- break;
1298
- }
1299
- return {
1300
- body: result,
1301
- deniedFields
1302
- };
1303
- }
1304
- /**
1305
- * Resolve effective roles by merging global user roles with org-level roles.
1306
- *
1307
- * Global roles come from `req.user.role` (normalized via getUserRoles()).
1308
- * Org roles come from `req.context.orgRoles` (set by BA adapter's org bridge).
1309
- *
1310
- * When no org context exists, returns global roles only — backward compatible.
1311
- */
1312
- function resolveEffectiveRoles(userRoles, orgRoles) {
1313
- if (orgRoles.length === 0) return [...userRoles];
1314
- if (userRoles.length === 0) return [...orgRoles];
1315
- return [...new Set([...userRoles, ...orgRoles])];
1316
- }
1317
- //#endregion
1318
1318
  //#region src/permissions/roleHierarchy.ts
1319
1319
  /**
1320
1320
  * Create a role hierarchy from a parent → children map.
@@ -1490,4 +1490,4 @@ function readOnly(overrides) {
1490
1490
  }, overrides);
1491
1491
  }
1492
1492
  //#endregion
1493
- export { roles as A, allowPublic as C, requireAuth as D, not as E, applyPermissionResult as F, evaluateAndApplyPermission as I, normalizePermissionResult as L, requireAgentScope as M, requireDPoP as N, requireOwnership as O, requireMandate as P, allOf as S, denyAll as T, requireOrgMembership as _, presets_exports as a, requireServiceScope as b, readOnly as c, applyFieldWritePermissions as d, fields as f, requireOrgInScope as g, createOrgPermissions as h, ownerWithAdminBypass as i, when as j, requireRoles as k, createRoleHierarchy as l, createDynamicPermissionMatrix as m, authenticated as n, publicRead as o, resolveEffectiveRoles as p, fullPublic as r, publicReadAdminWrite as s, adminOnly as t, applyFieldReadPermissions as u, requireOrgRole as v, anyOf as w, requireTeamMembership as x, requireScopeContext as y };
1493
+ export { requireMandate as A, requireAuth as C, when as D, roles as E, applyPermissionResult as F, evaluateAndApplyPermission as I, normalizePermissionResult as L, applyFieldWritePermissions as M, fields as N, requireAgentScope as O, resolveEffectiveRoles as P, not as S, requireRoles as T, requireTeamMembership as _, presets_exports as a, anyOf as b, readOnly as c, createOrgPermissions as d, requireOrgInScope as f, requireServiceScope as g, requireScopeContext as h, ownerWithAdminBypass as i, applyFieldReadPermissions as j, requireDPoP as k, createRoleHierarchy as l, requireOrgRole as m, authenticated as n, publicRead as o, requireOrgMembership as p, fullPublic as r, publicReadAdminWrite as s, adminOnly as t, createDynamicPermissionMatrix as u, allOf as v, requireOwnership as w, denyAll as x, allowPublic as y };
@@ -1,4 +1,4 @@
1
- import { Ft as OperationFilter, It as PipelineConfig, Lt as PipelineContext, Mt as Guard, Nt as Interceptor, Pt as NextFunction, Rt as PipelineStep, yt as IControllerResponse, zt as Transform } from "../index-DBDgy6Hw.mjs";
1
+ import { Ft as OperationFilter, It as PipelineConfig, Lt as PipelineContext, Mt as Guard, Nt as Interceptor, Pt as NextFunction, Rt as PipelineStep, yt as IControllerResponse, zt as Transform } from "../index-C1JSBan-.mjs";
2
2
 
3
3
  //#region src/pipeline/guard.d.ts
4
4
  interface GuardOptions {
@@ -1,5 +1,5 @@
1
- import { Kt as AnyRecord, Q as MiddlewareConfig, dt as RouteDefinition, mt as RouteSchemaOptions, nt as PresetHook } from "../index-DBDgy6Hw.mjs";
2
- import { a as arcCorePlugin, i as _default, n as ArcCorePluginOptions, r as PluginMeta, t as ArcCore } from "../fastify-augmentation-CJm0vh-l.mjs";
1
+ import { Kt as AnyRecord, Q as MiddlewareConfig, dt as RouteDefinition, mt as RouteSchemaOptions, nt as PresetHook } from "../index-C1JSBan-.mjs";
2
+ import { a as arcCorePlugin, i as _default, n as ArcCorePluginOptions, r as PluginMeta, t as ArcCore } from "../fastify-augmentation-7LIR0nDv.mjs";
3
3
  import { _ as HealthOptions, a as MetricsCollector, c as metricsPlugin, d as ssePlugin, f as CachingOptions, g as HealthCheck, h as cachingPlugin, i as MetricEntry, l as SSEOptions, m as _default$1, n as _default$7, o as MetricsOptions, p as CachingRule, r as versioningPlugin, s as _default$4, t as VersioningOptions, u as _default$6, v as _default$3, y as healthPlugin } from "../versioning-5H4930tj.mjs";
4
4
  import { i as errorHandlerPlugin, n as ErrorMapper, r as defaultIsDuplicateKeyError, t as ErrorHandlerOptions } from "../errorHandler-DuqTqeqn.mjs";
5
5
  import { t as TracingOptions } from "../tracing-bRqqUxFO.mjs";
@@ -5,7 +5,7 @@ import { t as hasEvents } from "../typeGuards-BzkXkvVv.mjs";
5
5
  import { t as HookSystem } from "../HookSystem-Cmf7-Etp.mjs";
6
6
  import { t as ResourceRegistry } from "../ResourceRegistry-CzSzVK1m.mjs";
7
7
  import { n as caching_default, t as cachingPlugin } from "../caching-8MfrrUYU.mjs";
8
- import { n as errorHandlerPlugin, t as defaultIsDuplicateKeyError } from "../errorHandler-DNPzqaTT.mjs";
8
+ import { n as errorHandlerPlugin, t as defaultIsDuplicateKeyError } from "../errorHandler-wQejMBT3.mjs";
9
9
  import { n as metrics_default, t as metricsPlugin } from "../metrics-B-PU4-Yu.mjs";
10
10
  import { t as replyHelpersPlugin } from "../replyHelpers-boS5Rx26.mjs";
11
11
  import { n as sse_default, t as ssePlugin } from "../sse-jh5OqFjR.mjs";
@@ -58,7 +58,7 @@ try {
58
58
  function createTracerProvider(options) {
59
59
  if (!isAvailable || !NodeTracerProvider || !BatchSpanProcessor || !OTLPTraceExporter) return null;
60
60
  const { serviceName = "@classytic/arc", serviceVersion, exporterUrl = "http://localhost:4318/v1/traces" } = options;
61
- const resolvedVersion = serviceVersion ?? "2.16.1";
61
+ const resolvedVersion = serviceVersion ?? "2.17.0";
62
62
  const exporter = new OTLPTraceExporter({ url: exporterUrl });
63
63
  const provider = new NodeTracerProvider({ resource: { attributes: {
64
64
  "service.name": serviceName,
@@ -1,4 +1,4 @@
1
- import { rt as PresetResult } from "../index-DBDgy6Hw.mjs";
1
+ import { rt as PresetResult } from "../index-C1JSBan-.mjs";
2
2
  import { i as RequestScope } from "../types-CfapW2t8.mjs";
3
3
  import { c as PermissionCheck } from "../fields-DqZLp5d6.mjs";
4
4
  import { a as StorageReadResult, i as StorageReadRange, n as StorageContext, o as StorageUploadInput, r as StorageFile, t as Storage } from "../storage-HcAgP5Ni.mjs";
@@ -1,7 +1,7 @@
1
1
  import { i as NotFoundError, u as ValidationError } from "../errors-j4aJm1Wg.mjs";
2
2
  import { c as getOrgId, g as getUserId } from "../types-Bi0r0vjG.mjs";
3
- import { C as allowPublic, D as requireAuth } from "../permissions-CTxMrreC.mjs";
4
- import { t as multipartBody } from "../multipartBody-BOvVSVCD.mjs";
3
+ import { C as requireAuth, y as allowPublic } from "../permissions-B1Qy55fg.mjs";
4
+ import { t as multipartBody } from "../multipartBody-VjVWmZl8.mjs";
5
5
  //#region src/presets/filesUpload.ts
6
6
  const DEFAULT_FIELD_NAME = "file";
7
7
  const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
@@ -1,4 +1,4 @@
1
- import { Kt as AnyRecord, bt as IRequestContext, d as PaginationResult, rt as PresetResult, st as ResourceConfig, yt as IControllerResponse } from "../index-DBDgy6Hw.mjs";
1
+ import { Kt as AnyRecord, bt as IRequestContext, d as PaginationResult, rt as PresetResult, st as ResourceConfig, yt as IControllerResponse } from "../index-C1JSBan-.mjs";
2
2
  import { FilesUploadPresetOptions, FilesUploadPresetPermissions, FilesUploadPresetRoutes, filesUploadPreset } from "./filesUpload.mjs";
3
3
  import { MultiTenantOptions, TenantFieldSpec, multiTenantPreset } from "./multiTenant.mjs";
4
4
  import { SearchHandler, SearchPresetOptions, SearchRouteConfig, searchPreset } from "./search.mjs";
@@ -1,5 +1,5 @@
1
1
  import { multiTenantPreset } from "./multiTenant.mjs";
2
- import { a as registerPreset, c as auditedPreset, d as ownedByUserPreset, i as getPreset, l as softDeletePreset, n as flexibleMultiTenantPreset, o as treePreset, r as getAvailablePresets, s as bulkPreset, t as applyPresets, u as slugLookupPreset } from "../presets-C9BE6WaZ.mjs";
2
+ import { a as registerPreset, c as auditedPreset, d as ownedByUserPreset, i as getPreset, l as softDeletePreset, n as flexibleMultiTenantPreset, o as treePreset, r as getAvailablePresets, s as bulkPreset, t as applyPresets, u as slugLookupPreset } from "../presets-BYbPobbF.mjs";
3
3
  import { filesUploadPreset } from "./filesUpload.mjs";
4
4
  import { searchPreset } from "./search.mjs";
5
5
  export { applyPresets, auditedPreset, bulkPreset, filesUploadPreset, flexibleMultiTenantPreset, getAvailablePresets, getPreset, multiTenantPreset, ownedByUserPreset, registerPreset, searchPreset, slugLookupPreset, softDeletePreset, treePreset };
@@ -1,4 +1,4 @@
1
- import { J as CrudRouteKey, rt as PresetResult } from "../index-DBDgy6Hw.mjs";
1
+ import { J as CrudRouteKey, rt as PresetResult } from "../index-C1JSBan-.mjs";
2
2
 
3
3
  //#region src/presets/multiTenant.d.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { dt as RouteDefinition, ft as RouteMcpConfig, ht as ControllerHandler, rt as PresetResult } from "../index-DBDgy6Hw.mjs";
1
+ import { dt as RouteDefinition, ft as RouteMcpConfig, ht as ControllerHandler, rt as PresetResult } from "../index-C1JSBan-.mjs";
2
2
  import { c as PermissionCheck } from "../fields-DqZLp5d6.mjs";
3
3
 
4
4
  //#region src/presets/search.d.ts
@@ -1,4 +1,4 @@
1
- import { C as allowPublic, D as requireAuth } from "../permissions-CTxMrreC.mjs";
1
+ import { C as requireAuth, y as allowPublic } from "../permissions-B1Qy55fg.mjs";
2
2
  //#region src/presets/search.ts
3
3
  const BUILTINS = [
4
4
  {
@@ -1,5 +1,5 @@
1
1
  import { b as isElevated, n as PUBLIC_SCOPE } from "./types-Bi0r0vjG.mjs";
2
- import { C as allowPublic, D as requireAuth, k as requireRoles } from "./permissions-CTxMrreC.mjs";
2
+ import { C as requireAuth, T as requireRoles, y as allowPublic } from "./permissions-B1Qy55fg.mjs";
3
3
  import { multiTenantPreset } from "./presets/multiTenant.mjs";
4
4
  //#region src/presets/ownedByUser.ts
5
5
  /**
@@ -70,14 +70,16 @@ function softDeletePreset() {
70
70
  handler: "getDeleted",
71
71
  summary: "Get soft-deleted items",
72
72
  permissions: permissions.list ?? requireRoles(["admin"]),
73
- operation: "listDeleted"
73
+ operation: "listDeleted",
74
+ _presetSource: "softDelete"
74
75
  }, {
75
76
  method: "POST",
76
77
  path: "/:id/restore",
77
78
  handler: "restore",
78
79
  summary: "Restore soft-deleted item",
79
80
  permissions: permissions.update ?? requireRoles(["admin"]),
80
- operation: "restore"
81
+ operation: "restore",
82
+ _presetSource: "softDelete"
81
83
  }]
82
84
  };
83
85
  }
@@ -1,4 +1,4 @@
1
- import { J as CrudRouteKey, R as RegisterOptions, V as ResourceDefinition, at as ResolvedTenantPurge, k as IntrospectionPluginOptions, p as RegistryEntry, z as ResourceRegistry } from "../index-DBDgy6Hw.mjs";
1
+ import { J as CrudRouteKey, R as RegisterOptions, V as ResourceDefinition, at as ResolvedTenantPurge, k as IntrospectionPluginOptions, p as RegistryEntry, z as ResourceRegistry } from "../index-C1JSBan-.mjs";
2
2
  import { FastifyPluginAsync } from "fastify";
3
3
  import { TenantPurgeProgress, TenantPurgeStrategy } from "@classytic/repo-core/repository";
4
4
 
@@ -1,10 +1,63 @@
1
- import { f as createError } from "./errors-j4aJm1Wg.mjs";
1
+ import { h as SYSTEM_FIELDS } from "./constants-TrJVIJl0.mjs";
2
+ import { f as createError, r as ForbiddenError } from "./errors-j4aJm1Wg.mjs";
2
3
  import { b as isElevated, c as getOrgId, g as getUserId, n as PUBLIC_SCOPE, x as isMember } from "./types-Bi0r0vjG.mjs";
3
4
  import { t as requestContext } from "./requestContext-SSaaTgW8.mjs";
4
- import { I as evaluateAndApplyPermission, p as resolveEffectiveRoles, u as applyFieldReadPermissions } from "./permissions-CTxMrreC.mjs";
5
- import { t as getUserRoles } from "./types-D57iXYb8.mjs";
5
+ import { I as evaluateAndApplyPermission, M as applyFieldWritePermissions, P as resolveEffectiveRoles, j as applyFieldReadPermissions } from "./permissions-B1Qy55fg.mjs";
6
+ import { t as getUserRoles } from "./types-DV9WDfeg.mjs";
6
7
  import { t as executePipeline } from "./pipe-DiCyvyPN.mjs";
7
8
  import { isPaginatedResult, toCanonicalList } from "@classytic/repo-core/pagination";
9
+ //#region src/core/BodySanitizer.ts
10
+ /**
11
+ * BodySanitizer - Composable body sanitization logic extracted from BaseController.
12
+ *
13
+ * Strips readonly fields, system-managed fields, and applies field-level
14
+ * write permissions from request bodies before create/update operations.
15
+ *
16
+ * Designed to be used standalone or composed into controllers.
17
+ */
18
+ const DEFAULT_FIELD_WRITE_DENIAL_POLICY = "reject";
19
+ var BodySanitizer = class {
20
+ schemaOptions;
21
+ onFieldWriteDenied;
22
+ constructor(config) {
23
+ this.schemaOptions = config.schemaOptions;
24
+ this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
25
+ }
26
+ /**
27
+ * Strip readonly and system-managed fields from request body.
28
+ * Prevents clients from overwriting _id, timestamps, __v, etc.
29
+ *
30
+ * Also applies field-level write permissions when the request has
31
+ * field permission metadata.
32
+ */
33
+ sanitize(body, _operation, req, meta) {
34
+ let sanitized = { ...body };
35
+ for (const field of SYSTEM_FIELDS) delete sanitized[field];
36
+ const scopeForRules = req ? (meta ?? req.metadata)?._scope ?? PUBLIC_SCOPE : void 0;
37
+ const scopeIsElevated = scopeForRules ? isElevated(scopeForRules) : false;
38
+ const fieldRules = this.schemaOptions.fieldRules ?? {};
39
+ for (const [field, rules] of Object.entries(fieldRules)) {
40
+ const bypass = Boolean(rules.preserveForElevated) && scopeIsElevated;
41
+ if ((rules.systemManaged || rules.readonly) && !bypass) delete sanitized[field];
42
+ if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate) && !bypass) delete sanitized[field];
43
+ }
44
+ if (req) {
45
+ const arcContext = meta ?? req.metadata;
46
+ const scope = arcContext?._scope ?? PUBLIC_SCOPE;
47
+ if (!isElevated(scope)) {
48
+ const fieldPerms = arcContext?.arc?.fields;
49
+ if (fieldPerms) {
50
+ const effectiveRoles = resolveEffectiveRoles(getUserRoles(req.user), isMember(scope) ? scope.orgRoles : []);
51
+ const { body: filtered, deniedFields } = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
52
+ if (deniedFields.length > 0 && this.onFieldWriteDenied === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
53
+ sanitized = filtered;
54
+ }
55
+ }
56
+ }
57
+ return sanitized;
58
+ }
59
+ };
60
+ //#endregion
8
61
  //#region src/scope/projection.ts
9
62
  /**
10
63
  * Compute the request-scope projection. Returns `undefined` when no
@@ -597,5 +650,75 @@ function describeValue(v) {
597
650
  if (Array.isArray(v)) return `an array of length ${v.length}`;
598
651
  return `${typeof v} (${JSON.stringify(v).slice(0, 80)})`;
599
652
  }
653
+ /**
654
+ * Build a preHandler that enforces field-write permissions on the request body.
655
+ *
656
+ * Auto-CRUD's create/update routes get this for free via `BodySanitizer.sanitize()`
657
+ * inside `BaseController`. Custom routes (`config.routes`, presets, action endpoints)
658
+ * never went through that path — so a host that declared
659
+ * `fields: { role: fields.writableBy(['admin']) }` and a custom
660
+ * `POST /users/promote` happily accepted `{ role: 'admin' }` from any caller.
661
+ *
662
+ * This preHandler closes that gap. It applies ONLY the `applyFieldWritePermissions`
663
+ * step — no `SYSTEM_FIELDS` strip, no `fieldRules` strip — because custom-route
664
+ * bodies don't necessarily mirror the resource's adapter shape, and we'd
665
+ * generate surprising 403s by enforcing rules tied to that shape.
666
+ *
667
+ * Returns `null` when the resource has no field permissions configured — the
668
+ * caller should then skip wiring it into the preHandler chain.
669
+ */
670
+ function buildFieldWritePreHandler(fieldPermissions, policy = DEFAULT_FIELD_WRITE_DENIAL_POLICY) {
671
+ if (!fieldPermissions || Object.keys(fieldPermissions).length === 0) return null;
672
+ return async (request, _reply) => {
673
+ const body = request.body;
674
+ if (!body || typeof body !== "object" || Array.isArray(body)) return;
675
+ const scope = request.scope ?? PUBLIC_SCOPE;
676
+ if (isElevated(scope)) return;
677
+ const { body: filtered, deniedFields } = applyFieldWritePermissions(body, fieldPermissions, resolveEffectiveRoles(getUserRoles(request.user), isMember(scope) ? scope.orgRoles : []));
678
+ if (deniedFields.length > 0 && policy === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
679
+ request.body = filtered;
680
+ };
681
+ }
682
+ /**
683
+ * True for HTTP methods that carry a request body and should run the
684
+ * field-write preHandler. GET/DELETE/HEAD/OPTIONS skip enforcement.
685
+ */
686
+ function methodCarriesBody(method) {
687
+ const m = method.toUpperCase();
688
+ return m === "POST" || m === "PUT" || m === "PATCH";
689
+ }
690
+ /**
691
+ * Register a route with friendly handling of `FST_ERR_DUPLICATED_ROUTE`.
692
+ *
693
+ * Boot-time `validateRouteCrudCollisions()` catches custom-route → auto-CRUD
694
+ * overlaps before Fastify ever runs, but a few cases still slip through to
695
+ * Fastify itself: two presets contributing the same `routes` entry, hosts
696
+ * mounting two resources at the same prefix, custom routes that share a
697
+ * URL with another plugin. Fastify's default message ("Method 'GET'
698
+ * already declared for route '/'") gives no hint at the resource-shaped
699
+ * fix — `disabledRoutes: ['list']` — so wrap it with one.
700
+ */
701
+ function tryRegisterRoute(fastify, opts, context) {
702
+ try {
703
+ fastify.route(opts);
704
+ } catch (err) {
705
+ const fastifyErr = err;
706
+ if (fastifyErr?.code !== "FST_ERR_DUPLICATED_ROUTE") throw err;
707
+ const method = opts.method;
708
+ const url = opts.url;
709
+ const opHint = context.op ? ` (op: ${context.op})` : "";
710
+ const fixHint = context.op && [
711
+ "list",
712
+ "get",
713
+ "create",
714
+ "update",
715
+ "delete"
716
+ ].includes(context.op) ? ` Suppress the auto-CRUD route with \`disabledRoutes: ['${context.op}']\`.` : " If a custom route in `routes:` collides with auto-CRUD, add `disabledRoutes: ['list' | 'get' | 'create' | 'update' | 'delete']` to `defineResource()` to suppress the matching auto-CRUD route.";
717
+ const enhanced = /* @__PURE__ */ new Error(`[arc] Duplicate route ${method} ${url} on resource "${context.resourceName}"${opHint}.${fixHint} Original: ${fastifyErr.message ?? "FST_ERR_DUPLICATED_ROUTE"}`);
718
+ enhanced.code = "FST_ERR_DUPLICATED_ROUTE";
719
+ enhanced.cause = err;
720
+ throw enhanced;
721
+ }
722
+ }
600
723
  //#endregion
601
- export { getControllerContext as _, buildAuthMiddlewareForPermissions as a, buildRequestScopeProjection as b, buildPreHandlerChain as c, resolveRoutePreHandlers as d, resolveRouterPluginMw as f, createRequestContext as g, createFastifyHandler as h, buildAuthMiddleware as i, buildRateLimitConfig as l, createCrudHandlers as m, buildActionPipelineHandler as n, buildCrudPermissionMw as o, selectPluginMw as p, buildArcDecorator as r, buildPipelineHandler as s, buildActionPermissionMw as t, resolvePipelineSteps as u, getControllerScope as v, sendControllerResponse as y };
724
+ export { buildRequestScopeProjection as C, sendControllerResponse as S, createCrudHandlers as _, buildAuthMiddlewareForPermissions as a, getControllerContext as b, buildPipelineHandler as c, methodCarriesBody as d, resolvePipelineSteps as f, tryRegisterRoute as g, selectPluginMw as h, buildAuthMiddleware as i, buildPreHandlerChain as l, resolveRouterPluginMw as m, buildActionPipelineHandler as n, buildCrudPermissionMw as o, resolveRoutePreHandlers as p, buildArcDecorator as r, buildFieldWritePreHandler as s, buildActionPermissionMw as t, buildRateLimitConfig as u, createFastifyHandler as v, BodySanitizer as w, getControllerScope as x, createRequestContext as y };
@@ -1,6 +1,6 @@
1
1
  import { C as isService, D as requireUserId, E as requireTeamId, S as isOrgInScope, T as requireOrgId, _ as getUserRoles, a as getDPoPJkt, b as isElevated, c as getOrgId, d as getScopeContext, f as getScopeContextMap, g as getUserId, h as getTenantFromRequest, i as getClientId, l as getOrgRoles, m as getTeamId, n as PUBLIC_SCOPE, o as getMandate, p as getServiceScopes, r as getAncestorOrgIds, s as getOrgContext, t as AUTHENTICATED_SCOPE, u as getRequestScope, v as hasOrgAccess, w as requireClientId, x as isMember, y as isAuthenticated } from "../types-Bi0r0vjG.mjs";
2
- import { n as normalizeRoles } from "../types-D57iXYb8.mjs";
3
- import { n as elevation_default, t as elevationPlugin } from "../elevation-Dci0AYLT.mjs";
2
+ import { n as normalizeRoles } from "../types-DV9WDfeg.mjs";
3
+ import { n as elevation_default, t as elevationPlugin } from "../elevation-6mfTLBC-.mjs";
4
4
  //#region src/scope/rateLimitKey.ts
5
5
  function createTenantKeyGenerator(opts) {
6
6
  if (opts?.strategy) return opts.strategy;
@@ -1,5 +1,5 @@
1
- import { Kt as AnyRecord, V as ResourceDefinition } from "../index-DBDgy6Hw.mjs";
2
- import { d as preloadResourcesAsync, r as CreateAppOptions, u as preloadResources } from "../types-Dw--SyTq.mjs";
1
+ import { Kt as AnyRecord, V as ResourceDefinition } from "../index-C1JSBan-.mjs";
2
+ import { d as preloadResourcesAsync, r as CreateAppOptions, u as preloadResources } from "../types-BRmS8TNa.mjs";
3
3
  import { StorageContractSetup, StorageContractSetupResult, runStorageContract } from "./storageContract.mjs";
4
4
  import { FastifyInstance, FastifyServerOptions } from "fastify";
5
5
  import { Mock } from "vitest";
@@ -1036,7 +1036,7 @@ function pickDefaultAuth(authMode, callerAuth) {
1036
1036
  };
1037
1037
  }
1038
1038
  async function createTestApp(options = {}) {
1039
- const { createApp } = await import("../createApp-DdPBtmzp.mjs").then((n) => n.r);
1039
+ const { createApp } = await import("../createApp-CzCEEsEm.mjs").then((n) => n.r);
1040
1040
  const { resources = [], db = "in-memory", connectMongoose = false, authMode = "jwt", defaultOrgId, plugins, auth: callerAuth, ...appOptions } = options;
1041
1041
  let dbHandle;
1042
1042
  let dbUri;