@classytic/arc 2.8.5 → 2.10.3

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 (155) hide show
  1. package/README.md +50 -38
  2. package/dist/{BaseController-DAGGc5Xn.mjs → BaseController-CbKKIflT.mjs} +193 -143
  3. package/dist/EventTransport-CUw5NNWe.d.mts +293 -0
  4. package/dist/{ResourceRegistry-C6uXlWe3.mjs → ResourceRegistry-BPd6NQDm.mjs} +1 -1
  5. package/dist/adapters/index.d.mts +3 -3
  6. package/dist/adapters/index.mjs +2 -2
  7. package/dist/{adapters-BBqAVvPK.mjs → adapters-BXY4i-hw.mjs} +210 -41
  8. package/dist/audit/index.d.mts +135 -11
  9. package/dist/audit/index.mjs +107 -20
  10. package/dist/auth/index.d.mts +17 -9
  11. package/dist/auth/index.mjs +14 -7
  12. package/dist/auth/redis-session.d.mts +1 -1
  13. package/dist/{betterAuthOpenApi-BuUcUEJq.mjs → betterAuthOpenApi-BBRVhjQN.mjs} +1 -1
  14. package/dist/cache/index.d.mts +17 -15
  15. package/dist/cache/index.mjs +15 -14
  16. package/dist/{caching-IMuYVjTL.mjs → caching-CBpK_SCM.mjs} +8 -3
  17. package/dist/cli/commands/describe.mjs +1 -1
  18. package/dist/cli/commands/docs.mjs +2 -2
  19. package/dist/cli/commands/generate.mjs +1 -1
  20. package/dist/cli/commands/init.mjs +1 -1
  21. package/dist/cli/commands/introspect.mjs +1 -1
  22. package/dist/core/index.d.mts +3 -3
  23. package/dist/core/index.mjs +4 -6
  24. package/dist/{defineResource-tcgySDo1.mjs → core-CcR01lup.mjs} +58 -61
  25. package/dist/{createActionRouter-BORM8f17.mjs → createActionRouter-Bp_5c_2b.mjs} +3 -3
  26. package/dist/{createApp-B1EY8zxa.mjs → createApp-BuvPma24.mjs} +15 -14
  27. package/dist/docs/index.d.mts +2 -2
  28. package/dist/docs/index.mjs +2 -2
  29. package/dist/{elevation-DtFxrG0s.mjs → elevation-C7hgL_aI.mjs} +22 -8
  30. package/dist/{errorHandler-f869_8PQ.mjs → errorHandler-Bb49BvPD.mjs} +59 -7
  31. package/dist/{errorHandler-Bah5JhBd.d.mts → errorHandler-DRQ3EqfL.d.mts} +37 -2
  32. package/dist/{eventPlugin-D9DKB2zM.d.mts → eventPlugin-CxWgpd6K.d.mts} +14 -2
  33. package/dist/{eventPlugin-CDjVTM82.mjs → eventPlugin-DCUjuiQT.mjs} +83 -5
  34. package/dist/events/index.d.mts +150 -36
  35. package/dist/events/index.mjs +355 -101
  36. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  37. package/dist/events/transports/redis.d.mts +1 -1
  38. package/dist/factory/index.d.mts +1 -1
  39. package/dist/factory/index.mjs +2 -2
  40. package/dist/{types-DZi1aYhm.d.mts → fields-Lo1VUDpt.d.mts} +121 -1
  41. package/dist/{fields-ipsbIRPK.mjs → fields-bxkeltzz.mjs} +18 -5
  42. package/dist/{filesUpload-C7r7HIeA.mjs → filesUpload-t21LS-py.mjs} +65 -7
  43. package/dist/hooks/index.d.mts +1 -1
  44. package/dist/hooks/index.mjs +1 -1
  45. package/dist/idempotency/index.d.mts +32 -5
  46. package/dist/idempotency/index.mjs +119 -12
  47. package/dist/idempotency/redis.d.mts +1 -1
  48. package/dist/{index-DtDzOBn8.d.mts → index-8qw4y6ff.d.mts} +4 -135
  49. package/dist/{index-BLXBmWud.d.mts → index-ChIw3776.d.mts} +283 -408
  50. package/dist/{interface-CMRutPfe.d.mts → index-Cl0uoKd5.d.mts} +1758 -2506
  51. package/dist/{index-C1meYuDn.d.mts → index-DStwgFUK.d.mts} +81 -7
  52. package/dist/index.d.mts +7 -8
  53. package/dist/index.mjs +11 -12
  54. package/dist/integrations/event-gateway.d.mts +1 -1
  55. package/dist/integrations/event-gateway.mjs +1 -1
  56. package/dist/integrations/index.d.mts +1 -1
  57. package/dist/integrations/mcp/index.d.mts +26 -8
  58. package/dist/integrations/mcp/index.mjs +96 -17
  59. package/dist/integrations/mcp/testing.d.mts +1 -1
  60. package/dist/integrations/mcp/testing.mjs +1 -1
  61. package/dist/integrations/webhooks.d.mts +5 -0
  62. package/dist/integrations/webhooks.mjs +6 -0
  63. package/dist/interface-D218ikEo.d.mts +77 -0
  64. package/dist/{memory-Cp7_cAko.mjs → memory-B5Amv9A1.mjs} +23 -8
  65. package/dist/{openapi-CbKUJY_m.mjs → openapi-B5F8AddX.mjs} +3 -3
  66. package/dist/org/index.d.mts +2 -2
  67. package/dist/permissions/index.d.mts +3 -4
  68. package/dist/permissions/index.mjs +5 -5
  69. package/dist/{permissions-CH4cNwJi.mjs → permissions-Dk6mshja.mjs} +315 -397
  70. package/dist/plugins/index.d.mts +7 -7
  71. package/dist/plugins/index.mjs +14 -16
  72. package/dist/plugins/response-cache.mjs +2 -2
  73. package/dist/plugins/tracing-entry.d.mts +1 -1
  74. package/dist/plugins/tracing-entry.mjs +1 -1
  75. package/dist/presets/filesUpload.d.mts +27 -5
  76. package/dist/presets/filesUpload.mjs +1 -1
  77. package/dist/presets/index.d.mts +3 -2
  78. package/dist/presets/index.mjs +4 -3
  79. package/dist/presets/multiTenant.d.mts +1 -1
  80. package/dist/presets/multiTenant.mjs +2 -2
  81. package/dist/presets/search.d.mts +178 -0
  82. package/dist/presets/search.mjs +150 -0
  83. package/dist/{presets-C2xgzW6x.mjs → presets-fLJVXdVn.mjs} +1 -1
  84. package/dist/{queryCachePlugin-BJJGBTlu.d.mts → queryCachePlugin-BKbWjgDG.d.mts} +1 -1
  85. package/dist/{queryCachePlugin-BH-fidlv.mjs → queryCachePlugin-DQCEfJis.mjs} +9 -9
  86. package/dist/{queryParser-CgCtsjti.mjs → queryParser-DBqBB6AC.mjs} +1 -1
  87. package/dist/{redis-BM00zaPB.d.mts → redis-DqyeggCa.d.mts} +1 -1
  88. package/dist/{redis-stream-CrsfUmPt.d.mts → redis-stream-CakIQmwR.d.mts} +1 -1
  89. package/dist/registry/index.d.mts +1 -1
  90. package/dist/registry/index.mjs +2 -2
  91. package/dist/{resourceToTools-8s-EsCCe.mjs → resourceToTools-BElv3xPT.mjs} +65 -48
  92. package/dist/{schemaConverter-Y7nCYaLJ.mjs → schemaConverter-BxFDdtXu.mjs} +1 -1
  93. package/dist/scope/index.d.mts +1 -1
  94. package/dist/scope/index.mjs +2 -2
  95. package/dist/{sse-Ad7ypl9e.mjs → sse-yBCgOLGu.mjs} +1 -1
  96. package/dist/store-helpers-ZCSMJJAX.mjs +57 -0
  97. package/dist/testing/index.d.mts +9 -17
  98. package/dist/testing/index.mjs +27 -83
  99. package/dist/testing/storageContract.d.mts +1 -1
  100. package/dist/types/index.d.mts +4 -4
  101. package/dist/types/index.mjs +1 -31
  102. package/dist/types/storage.d.mts +1 -1
  103. package/dist/{types-BsbNMEDR.d.mts → types-Btdda02s.d.mts} +1 -1
  104. package/dist/{types-Ch9pTQbf.d.mts → types-Co8k3NyS.d.mts} +11 -9
  105. package/dist/types-Csi3FLfq.mjs +27 -0
  106. package/dist/utils/index.d.mts +208 -4
  107. package/dist/utils/index.mjs +5 -6
  108. package/dist/{utils-yYT3HDXt.mjs → utils-B2fNOD_i.mjs} +285 -2
  109. package/dist/{versioning-CDugduqI.mjs → versioning-C2U_bLY0.mjs} +3 -5
  110. package/package.json +20 -26
  111. package/skills/arc/SKILL.md +97 -23
  112. package/skills/arc/references/auth.md +94 -0
  113. package/skills/arc/references/events.md +200 -12
  114. package/skills/arc/references/mcp.md +4 -17
  115. package/skills/arc/references/multi-tenancy.md +43 -0
  116. package/skills/arc/references/production.md +34 -60
  117. package/dist/EventTransport-BXja8NOc.d.mts +0 -135
  118. package/dist/audit/mongodb.d.mts +0 -2
  119. package/dist/audit/mongodb.mjs +0 -2
  120. package/dist/circuitBreaker-cmi5XDv5.mjs +0 -284
  121. package/dist/circuitBreaker-dTtG-UyS.d.mts +0 -206
  122. package/dist/core-F0QoWBt2.mjs +0 -34
  123. package/dist/dynamic/index.d.mts +0 -93
  124. package/dist/dynamic/index.mjs +0 -122
  125. package/dist/fields-DpZQa_Q3.d.mts +0 -109
  126. package/dist/idempotency/mongodb.d.mts +0 -2
  127. package/dist/idempotency/mongodb.mjs +0 -123
  128. package/dist/interface-4y979v99.d.mts +0 -54
  129. package/dist/mongodb-BsP-WbhN.d.mts +0 -127
  130. package/dist/mongodb-CTcp0hQZ.d.mts +0 -80
  131. package/dist/mongodb-Utc5k_-0.mjs +0 -90
  132. package/dist/policies/index.d.mts +0 -432
  133. package/dist/policies/index.mjs +0 -318
  134. package/dist/rpc/index.d.mts +0 -90
  135. package/dist/rpc/index.mjs +0 -248
  136. /package/dist/{HookSystem-HprTmvVY.mjs → HookSystem-BNYKnrXF.mjs} +0 -0
  137. /package/dist/{applyPermissionResult-D6GPMsvh.mjs → applyPermissionResult-QhV1Pa-g.mjs} +0 -0
  138. /package/dist/{constants-Cxde4rpC.mjs → constants-BhY1OHoH.mjs} +0 -0
  139. /package/dist/{elevation-B6S5csVA.d.mts → elevation-C5SwtkAn.d.mts} +0 -0
  140. /package/dist/{errors-Ck2h67pm.d.mts → errors-CCSsMpXE.d.mts} +0 -0
  141. /package/dist/{errors-BF2bIOIS.mjs → errors-D5c-5BJL.mjs} +0 -0
  142. /package/dist/{externalPaths-BnkYrNzp.d.mts → externalPaths-BQ8QijNH.d.mts} +0 -0
  143. /package/dist/{interface-DfLGcus7.d.mts → interface-CSbZdv_3.d.mts} +0 -0
  144. /package/dist/{loadResources-PWd0OCpV.mjs → loadResources-BAzJItAJ.mjs} +0 -0
  145. /package/dist/{logger-D1YrIImS.mjs → logger-DLg8-Ueg.mjs} +0 -0
  146. /package/dist/{metrics-B-PU4-Yu.mjs → metrics-DuhiSEZI.mjs} +0 -0
  147. /package/dist/{pluralize-CWP6MB39.mjs → pluralize-A0tWEl1K.mjs} +0 -0
  148. /package/dist/{registry-BiTKT1Dg.mjs → registry-B3lRFBWo.mjs} +0 -0
  149. /package/dist/{replyHelpers-CxkYGT81.mjs → replyHelpers-CXtJDAZ0.mjs} +0 -0
  150. /package/dist/{requestContext-DYvHl113.mjs → requestContext-xHIKedG6.mjs} +0 -0
  151. /package/dist/{sessionManager-DDCmiNIo.d.mts → sessionManager-BkzVU8h2.d.mts} +0 -0
  152. /package/dist/{storage-Dfzt4VTl.d.mts → storage-CVk_SEn2.d.mts} +0 -0
  153. /package/dist/{tracing-DdN2-wHJ.d.mts → tracing-65B51Dw3.d.mts} +0 -0
  154. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-Cj5Rgvlg.mjs} +0 -0
  155. /package/dist/{types-ZUu_h0jp.mjs → types-DV9WDfeg.mjs} +0 -0
@@ -77,24 +77,37 @@ function applyFieldReadPermissions(data, fieldPermissions, userRoles) {
77
77
  }
78
78
  /**
79
79
  * Apply field-level WRITE permissions to request body.
80
- * Strips fields that the user doesn't have permission to write.
80
+ *
81
+ * Returns both the filtered body and the list of denied fields. Callers are
82
+ * expected to reject the request when `deniedFields.length > 0` — silently
83
+ * stripping fields hides misconfigurations and real attacks. See
84
+ * `BodySanitizer` for the default policy.
81
85
  *
82
86
  * @param body - The request body (returns a new filtered copy)
83
87
  * @param fieldPermissions - Field permission map from resource config
84
88
  * @param userRoles - Current user's roles
85
- * @returns Filtered body
86
89
  */
87
90
  function applyFieldWritePermissions(body, fieldPermissions, userRoles) {
88
91
  const result = { ...body };
92
+ const deniedFields = [];
89
93
  for (const [field, perm] of Object.entries(fieldPermissions)) switch (perm._type) {
90
94
  case "hidden":
91
- delete result[field];
95
+ if (field in result) {
96
+ deniedFields.push(field);
97
+ delete result[field];
98
+ }
92
99
  break;
93
100
  case "writableBy":
94
- if (field in result && !perm.roles?.some((r) => userRoles.includes(r))) delete result[field];
101
+ if (field in result && !perm.roles?.some((r) => userRoles.includes(r))) {
102
+ deniedFields.push(field);
103
+ delete result[field];
104
+ }
95
105
  break;
96
106
  }
97
- return result;
107
+ return {
108
+ body: result,
109
+ deniedFields
110
+ };
98
111
  }
99
112
  /**
100
113
  * Resolve effective roles by merging global user roles with org-level roles.
@@ -1,11 +1,42 @@
1
1
  import { o as getOrgId, p as getUserId } from "./types-AOD8fxIw.mjs";
2
- import { i as NotFoundError, u as ValidationError } from "./errors-BF2bIOIS.mjs";
3
- import { n as allowPublic, s as requireAuth } from "./permissions-CH4cNwJi.mjs";
2
+ import { i as NotFoundError, u as ValidationError } from "./errors-D5c-5BJL.mjs";
3
+ import { C as requireAuth, y as allowPublic } from "./permissions-Dk6mshja.mjs";
4
4
  //#region src/middleware/multipartBody.ts
5
5
  const DEFAULT_MAX_FILE_SIZE$1 = 10 * 1024 * 1024;
6
6
  const DEFAULT_MAX_FILES = 5;
7
7
  const DEFAULT_FILES_KEY = "_files";
8
8
  /**
9
+ * Build a matcher for MIME allow-lists that supports exact (e.g. `image/png`),
10
+ * subtype wildcards (e.g. `image/\*`), and total wildcards (`\*` or `\*\/\*`).
11
+ *
12
+ * Returns `undefined` when no filter is needed — either because the option
13
+ * was omitted or because a total wildcard was present.
14
+ */
15
+ function buildMimeMatcher(allowed) {
16
+ if (!allowed || allowed.length === 0) return void 0;
17
+ const exact = /* @__PURE__ */ new Set();
18
+ const prefixes = [];
19
+ for (const entry of allowed) {
20
+ const value = entry.trim().toLowerCase();
21
+ if (!value) continue;
22
+ if (value === "*" || value === "*/*") return void 0;
23
+ if (value.endsWith("/*")) prefixes.push(value.slice(0, -1));
24
+ else exact.add(value);
25
+ }
26
+ if (exact.size === 0 && prefixes.length === 0) return void 0;
27
+ return {
28
+ matches(mime) {
29
+ const m = mime.toLowerCase();
30
+ if (exact.has(m)) return true;
31
+ for (const p of prefixes) if (m.startsWith(p)) return true;
32
+ return false;
33
+ },
34
+ describe() {
35
+ return [...exact, ...prefixes.map((p) => `${p}*`)].join(", ");
36
+ }
37
+ };
38
+ }
39
+ /**
9
40
  * Create a multipart body parsing middleware.
10
41
  *
11
42
  * When a request has `content-type: multipart/form-data`, this middleware:
@@ -20,7 +51,7 @@ const DEFAULT_FILES_KEY = "_files";
20
51
  function multipartBody(options = {}) {
21
52
  const maxFileSize = options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE$1;
22
53
  const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
23
- const allowedMimeTypes = options.allowedMimeTypes ? new Set(options.allowedMimeTypes) : void 0;
54
+ const mimeMatcher = buildMimeMatcher(options.allowedMimeTypes);
24
55
  const filesKey = options.filesKey ?? DEFAULT_FILES_KEY;
25
56
  const requiredFields = options.requiredFields && options.requiredFields.length > 0 ? options.requiredFields : void 0;
26
57
  return async function parseMultipartBody(request, reply) {
@@ -36,9 +67,9 @@ function multipartBody(options = {}) {
36
67
  const parts = request.parts();
37
68
  for await (const part of parts) if (part.type === "file") {
38
69
  if (fileCount >= maxFiles) continue;
39
- if (allowedMimeTypes && !allowedMimeTypes.has(part.mimetype)) return reply.code(415).send({
70
+ if (mimeMatcher && !mimeMatcher.matches(part.mimetype)) return reply.code(415).send({
40
71
  success: false,
41
- error: `File type '${part.mimetype}' not allowed. Accepted: ${[...allowedMimeTypes].join(", ")}`
72
+ error: `File type '${part.mimetype}' not allowed. Accepted: ${mimeMatcher.describe()}`
42
73
  });
43
74
  const buffer = await part.toBuffer();
44
75
  if (buffer.length > maxFileSize) return reply.code(413).send({
@@ -155,14 +186,40 @@ function parseRangeHeader(header, totalSize) {
155
186
  end
156
187
  };
157
188
  }
189
+ /**
190
+ * Strict policy — rejects filenames that could escape a storage root or
191
+ * confuse a filesystem. Safe default for disk/S3 adapters that compose
192
+ * `${prefix}/${filename}` or `path.join(root, filename)`.
193
+ */
194
+ function strictFilenamePolicy(filename) {
195
+ if (filename.length === 0) throw new ValidationError("Upload filename is empty");
196
+ if (filename.length > 255) throw new ValidationError("Upload filename exceeds 255 characters");
197
+ if (filename.includes("\0")) throw new ValidationError("Upload filename contains a NUL byte");
198
+ if (filename.includes("/") || filename.includes("\\")) throw new ValidationError("Upload filename contains a path separator");
199
+ if (filename === "." || filename === "..") throw new ValidationError("Upload filename is a path traversal component");
200
+ return filename;
201
+ }
202
+ /** Resolve the user-supplied policy option into a concrete validator. */
203
+ function resolveFilenamePolicy(policy) {
204
+ if (policy === void 0 || policy === true) return strictFilenamePolicy;
205
+ if (policy === false || policy === "*") return (f) => f;
206
+ if (typeof policy === "function") return (filename) => {
207
+ const result = policy(filename);
208
+ if (result === false) throw new ValidationError(`Upload filename rejected: ${filename}`);
209
+ if (typeof result === "string") return result;
210
+ return filename;
211
+ };
212
+ return strictFilenamePolicy;
213
+ }
158
214
  function makeUploadHandler(deps) {
159
215
  return async function uploadHandler(request, reply) {
160
216
  const file = (request.body?._files)?.[deps.fieldName];
161
217
  if (!file) throw new ValidationError(`Missing file field '${deps.fieldName}' in multipart body`);
218
+ const filename = deps.applyFilenamePolicy(file.filename);
162
219
  const ctx = buildStorageContext(request, deps.contextFrom);
163
220
  const result = await deps.storage.upload({
164
221
  buffer: file.buffer,
165
- filename: file.filename,
222
+ filename,
166
223
  mimeType: file.mimetype,
167
224
  size: file.size
168
225
  }, ctx);
@@ -263,7 +320,8 @@ function filesUploadPreset(options) {
263
320
  const deps = {
264
321
  storage: options.storage,
265
322
  fieldName: options.fieldName ?? DEFAULT_FIELD_NAME,
266
- contextFrom: options.contextFrom ?? defaultContextFrom
323
+ contextFrom: options.contextFrom ?? defaultContextFrom,
324
+ applyFilenamePolicy: resolveFilenamePolicy(options.sanitizeFilename)
267
325
  };
268
326
  const maxFileSize = options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
269
327
  const allowedMimeTypes = options.allowedMimeTypes;
@@ -1,2 +1,2 @@
1
- import { An as beforeCreate, Cn as HookPhase, Dn as afterCreate, En as HookSystemOptions, Mn as beforeUpdate, Nn as createHookSystem, On as afterDelete, Pn as defineHook, Sn as HookOperation, Tn as HookSystem, bn as HookContext, jn as beforeDelete, kn as afterUpdate, wn as HookRegistration, xn as HookHandler, yn as DefineHookOptions } from "../interface-CMRutPfe.mjs";
1
+ import { $t as HookPhase, Qt as HookOperation, Xt as HookContext, Yt as DefineHookOptions, Zt as HookHandler, an as afterUpdate, cn as beforeUpdate, en as HookRegistration, in as afterDelete, ln as createHookSystem, nn as HookSystemOptions, on as beforeCreate, rn as afterCreate, sn as beforeDelete, tn as HookSystem, un as defineHook } from "../index-Cl0uoKd5.mjs";
2
2
  export { type DefineHookOptions, type HookContext, type HookHandler, type HookOperation, type HookPhase, type HookRegistration, HookSystem, type HookSystemOptions, afterCreate, afterDelete, afterUpdate, beforeCreate, beforeDelete, beforeUpdate, createHookSystem, defineHook };
@@ -1,2 +1,2 @@
1
- import { a as beforeCreate, c as createHookSystem, i as afterUpdate, l as defineHook, n as afterCreate, o as beforeDelete, r as afterDelete, s as beforeUpdate, t as HookSystem } from "../HookSystem-HprTmvVY.mjs";
1
+ import { a as beforeCreate, c as createHookSystem, i as afterUpdate, l as defineHook, n as afterCreate, o as beforeDelete, r as afterDelete, s as beforeUpdate, t as HookSystem } from "../HookSystem-BNYKnrXF.mjs";
2
2
  export { HookSystem, afterCreate, afterDelete, afterUpdate, beforeCreate, beforeDelete, beforeUpdate, createHookSystem, defineHook };
@@ -1,6 +1,6 @@
1
- import { i as createIdempotencyResult, n as IdempotencyResult, r as IdempotencyStore, t as IdempotencyLock } from "../interface-DfLGcus7.mjs";
2
- import { n as MongoIdempotencyStoreOptions } from "../mongodb-CTcp0hQZ.mjs";
3
- import { i as RedisIdempotencyStoreOptions, n as RedisClient } from "../redis-BM00zaPB.mjs";
1
+ import { kt as RepositoryLike } from "../index-Cl0uoKd5.mjs";
2
+ import { i as createIdempotencyResult, n as IdempotencyResult, r as IdempotencyStore, t as IdempotencyLock } from "../interface-CSbZdv_3.mjs";
3
+ import { i as RedisIdempotencyStoreOptions, n as RedisClient } from "../redis-DqyeggCa.mjs";
4
4
  import { FastifyPluginAsync } from "fastify";
5
5
 
6
6
  //#region src/idempotency/idempotencyPlugin.d.ts
@@ -19,10 +19,34 @@ interface IdempotencyPluginOptions {
19
19
  include?: RegExp[];
20
20
  /** URL patterns to exclude (regex). Excluded patterns take precedence */
21
21
  exclude?: RegExp[];
22
- /** Custom store (default: MemoryIdempotencyStore) */
22
+ /**
23
+ * Repository managing the idempotency collection. Arc consumes it directly
24
+ * — no wrapper classes. Requires `getOne`, `deleteMany`, and
25
+ * `findOneAndUpdate` (mongokit ≥3.8 implements all three). Pass any
26
+ * `RepositoryLike` that matches.
27
+ *
28
+ * Use `store` (below) when your backend isn't a repository (Redis, memory
29
+ * for tests, custom). `repository` takes precedence when both are passed.
30
+ */
31
+ repository?: RepositoryLike;
32
+ /**
33
+ * Non-repository store. Use for Redis (the canonical multi-instance
34
+ * backend when you don't already have a DB repository), memory (tests),
35
+ * or custom implementations of `IdempotencyStore`.
36
+ *
37
+ * Default: `MemoryIdempotencyStore`.
38
+ */
23
39
  store?: IdempotencyStore;
24
40
  /** Retry-After header value in seconds when request is in-flight (default: 1) */
25
41
  retryAfterSeconds?: number;
42
+ /**
43
+ * Namespace key folded into the fingerprint — use when two deployments share
44
+ * a single store but should not replay each other's responses (e.g. `api`
45
+ * vs `jobs` with the same Redis, or prod vs canary sharing one cluster).
46
+ *
47
+ * Omit for the common case where the store is per-deployment.
48
+ */
49
+ namespace?: string;
26
50
  }
27
51
  declare module "fastify" {
28
52
  interface FastifyRequest {
@@ -58,6 +82,9 @@ declare module "fastify" {
58
82
  declare const idempotencyPlugin: FastifyPluginAsync<IdempotencyPluginOptions>;
59
83
  declare const _default: FastifyPluginAsync<IdempotencyPluginOptions>;
60
84
  //#endregion
85
+ //#region src/idempotency/repository-idempotency-adapter.d.ts
86
+ declare function repositoryAsIdempotencyStore(repository: RepositoryLike, defaultTtlMs: number): IdempotencyStore;
87
+ //#endregion
61
88
  //#region src/idempotency/stores/memory.d.ts
62
89
  interface MemoryIdempotencyStoreOptions {
63
90
  /** Default TTL in milliseconds (default: 86400000 = 24h) */
@@ -93,4 +120,4 @@ declare class MemoryIdempotencyStore implements IdempotencyStore {
93
120
  private evictOldest;
94
121
  }
95
122
  //#endregion
96
- export { type IdempotencyLock, type IdempotencyPluginOptions, type IdempotencyResult, type IdempotencyStore, MemoryIdempotencyStore, type MemoryIdempotencyStoreOptions, type MongoIdempotencyStoreOptions, type RedisClient, type RedisIdempotencyStoreOptions, createIdempotencyResult, _default as idempotencyPlugin, idempotencyPlugin as idempotencyPluginFn };
123
+ export { type IdempotencyLock, type IdempotencyPluginOptions, type IdempotencyResult, type IdempotencyStore, MemoryIdempotencyStore, type MemoryIdempotencyStoreOptions, type RedisClient, type RedisIdempotencyStoreOptions, createIdempotencyResult, _default as idempotencyPlugin, idempotencyPlugin as idempotencyPluginFn, repositoryAsIdempotencyStore };
@@ -1,5 +1,113 @@
1
+ import { n as createSafeGetOne, t as createIsDuplicateKeyError } from "../store-helpers-ZCSMJJAX.mjs";
1
2
  import { createHash } from "node:crypto";
2
3
  import fp from "fastify-plugin";
4
+ //#region src/idempotency/repository-idempotency-adapter.ts
5
+ function repositoryAsIdempotencyStore(repository, defaultTtlMs) {
6
+ const missing = [];
7
+ if (typeof repository.getOne !== "function") missing.push("getOne");
8
+ if (typeof repository.deleteMany !== "function") missing.push("deleteMany");
9
+ if (typeof repository.findOneAndUpdate !== "function") missing.push("findOneAndUpdate");
10
+ if (missing.length > 0) throw new Error(`idempotencyPlugin: repository is missing required methods: ${missing.join(", ")}. mongokit ≥3.8 satisfies these; other kits must implement them to back idempotency via a repository.`);
11
+ const r = repository;
12
+ const isDuplicateKeyError = createIsDuplicateKeyError(repository);
13
+ const safeGetOne = createSafeGetOne(repository);
14
+ const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15
+ return {
16
+ name: "repository",
17
+ async get(key) {
18
+ const doc = await safeGetOne({ _id: key });
19
+ if (!doc?.result) return void 0;
20
+ if (new Date(doc.expiresAt) < /* @__PURE__ */ new Date()) return void 0;
21
+ return {
22
+ key,
23
+ statusCode: doc.result.statusCode,
24
+ headers: doc.result.headers,
25
+ body: doc.result.body,
26
+ createdAt: new Date(doc.createdAt),
27
+ expiresAt: new Date(doc.expiresAt)
28
+ };
29
+ },
30
+ async set(key, result) {
31
+ await r.findOneAndUpdate({ _id: key }, {
32
+ $set: {
33
+ result: {
34
+ statusCode: result.statusCode,
35
+ headers: result.headers,
36
+ body: result.body
37
+ },
38
+ createdAt: result.createdAt,
39
+ expiresAt: result.expiresAt
40
+ },
41
+ $unset: { lock: "" }
42
+ }, {
43
+ upsert: true,
44
+ returnDocument: "after"
45
+ });
46
+ },
47
+ async tryLock(key, requestId, ttlMs) {
48
+ const now = /* @__PURE__ */ new Date();
49
+ const lockExpiresAt = new Date(now.getTime() + ttlMs);
50
+ const docExpiresAt = new Date(now.getTime() + defaultTtlMs);
51
+ try {
52
+ const doc = await r.findOneAndUpdate({
53
+ _id: key,
54
+ $or: [{ lock: { $exists: false } }, { "lock.expiresAt": { $lt: now } }]
55
+ }, {
56
+ $set: { lock: {
57
+ requestId,
58
+ expiresAt: lockExpiresAt
59
+ } },
60
+ $setOnInsert: {
61
+ createdAt: now,
62
+ expiresAt: docExpiresAt
63
+ }
64
+ }, {
65
+ upsert: true,
66
+ returnDocument: "after"
67
+ });
68
+ return doc !== null && doc !== void 0;
69
+ } catch (err) {
70
+ if (isDuplicateKeyError(err)) return false;
71
+ throw err;
72
+ }
73
+ },
74
+ async unlock(key, requestId) {
75
+ await r.findOneAndUpdate({
76
+ _id: key,
77
+ "lock.requestId": requestId
78
+ }, { $unset: { lock: "" } });
79
+ },
80
+ async isLocked(key) {
81
+ const doc = await safeGetOne({ _id: key });
82
+ if (!doc?.lock) return false;
83
+ return new Date(doc.lock.expiresAt) > /* @__PURE__ */ new Date();
84
+ },
85
+ async delete(key) {
86
+ await r.deleteMany({ _id: key });
87
+ },
88
+ async deleteByPrefix(prefix) {
89
+ return (await r.deleteMany({ _id: { $regex: `^${escapeRegex(prefix)}` } })).deletedCount ?? 0;
90
+ },
91
+ async findByPrefix(prefix) {
92
+ const doc = await safeGetOne({
93
+ _id: { $regex: `^${escapeRegex(prefix)}` },
94
+ result: { $exists: true },
95
+ expiresAt: { $gt: /* @__PURE__ */ new Date() }
96
+ });
97
+ if (!doc?.result) return void 0;
98
+ return {
99
+ key: doc._id,
100
+ statusCode: doc.result.statusCode,
101
+ headers: doc.result.headers,
102
+ body: doc.result.body,
103
+ createdAt: new Date(doc.createdAt),
104
+ expiresAt: new Date(doc.expiresAt)
105
+ };
106
+ },
107
+ async close() {}
108
+ };
109
+ }
110
+ //#endregion
3
111
  //#region src/idempotency/stores/interface.ts
4
112
  /**
5
113
  * Helper to create a result object
@@ -171,7 +279,8 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
171
279
  "POST",
172
280
  "PUT",
173
281
  "PATCH"
174
- ], include, exclude, store = new MemoryIdempotencyStore({ ttlMs }), retryAfterSeconds = 1 } = opts;
282
+ ], include, exclude, repository, store: explicitStore, retryAfterSeconds = 1, namespace } = opts;
283
+ const store = repository ? repositoryAsIdempotencyStore(repository, ttlMs) : explicitStore ?? new MemoryIdempotencyStore({ ttlMs });
175
284
  if (!enabled) {
176
285
  fastify.decorate("idempotency", {
177
286
  invalidate: async () => {},
@@ -225,7 +334,7 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
225
334
  }
226
335
  const user = request.user;
227
336
  const userId = user?.id ?? user?._id ?? "anon";
228
- return `${request.method}:${request.url}:${bodyHash}:u=${userId}`;
337
+ return `${namespace ? `n=${namespace}:` : ""}${request.method}:${request.url}:${bodyHash}:u=${userId}`;
229
338
  }
230
339
  const idempotencyMiddleware = async (request, reply) => {
231
340
  if (!shouldApplyIdempotency(request)) return;
@@ -252,6 +361,7 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
252
361
  return;
253
362
  }
254
363
  request._idempotencyFullKey = fullKey;
364
+ reply.header(HEADER_IDEMPOTENCY_KEY, idempotencyKey);
255
365
  };
256
366
  fastify.decorate("idempotency", {
257
367
  invalidate: async (key) => {
@@ -262,15 +372,12 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
262
372
  },
263
373
  middleware: idempotencyMiddleware
264
374
  });
265
- fastify.addHook("onSend", async (request, reply, payload) => {
375
+ fastify.addHook("preSerialization", async (request, reply, payload) => {
266
376
  if (request.idempotencyReplayed) return payload;
267
377
  const fullKey = request._idempotencyFullKey;
268
378
  if (!fullKey) return payload;
269
379
  const statusCode = reply.statusCode;
270
- if (statusCode < 200 || statusCode >= 300) {
271
- await store.unlock(fullKey, request.id);
272
- return payload;
273
- }
380
+ if (statusCode < 200 || statusCode >= 300) return payload;
274
381
  const headersToCache = {};
275
382
  const excludeHeaders = new Set([
276
383
  "content-length",
@@ -290,13 +397,13 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
290
397
  }
291
398
  const result = createIdempotencyResult(statusCode, body, headersToCache, ttlMs);
292
399
  await store.set(fullKey, result);
293
- await store.unlock(fullKey, request.id);
294
- reply.header(HEADER_IDEMPOTENCY_KEY, request.idempotencyKey);
295
400
  return payload;
296
401
  });
297
- fastify.addHook("onError", async (request) => {
402
+ fastify.addHook("onResponse", async (request) => {
403
+ if (request.idempotencyReplayed) return;
298
404
  const fullKey = request._idempotencyFullKey;
299
- if (fullKey) await store.unlock(fullKey, request.id);
405
+ if (!fullKey) return;
406
+ await store.unlock(fullKey, request.id);
300
407
  });
301
408
  fastify.addHook("onClose", async () => {
302
409
  await store.close?.();
@@ -312,4 +419,4 @@ var idempotencyPlugin_default = fp(idempotencyPlugin, {
312
419
  fastify: "5.x"
313
420
  });
314
421
  //#endregion
315
- export { MemoryIdempotencyStore, createIdempotencyResult, idempotencyPlugin_default as idempotencyPlugin, idempotencyPlugin as idempotencyPluginFn };
422
+ export { MemoryIdempotencyStore, createIdempotencyResult, idempotencyPlugin_default as idempotencyPlugin, idempotencyPlugin as idempotencyPluginFn, repositoryAsIdempotencyStore };
@@ -1,2 +1,2 @@
1
- import { a as UpstashRedisLike, i as RedisIdempotencyStoreOptions, n as RedisClient, o as ioredisAsIdempotencyClient, r as RedisIdempotencyStore, s as upstashAsIdempotencyClient, t as IoredisLike } from "../redis-BM00zaPB.mjs";
1
+ import { a as UpstashRedisLike, i as RedisIdempotencyStoreOptions, n as RedisClient, o as ioredisAsIdempotencyClient, r as RedisIdempotencyStore, s as upstashAsIdempotencyClient, t as IoredisLike } from "../redis-DqyeggCa.mjs";
2
2
  export { type IoredisLike, type RedisClient, RedisIdempotencyStore, type RedisIdempotencyStoreOptions, type UpstashRedisLike, ioredisAsIdempotencyClient, upstashAsIdempotencyClient };
@@ -1,7 +1,7 @@
1
1
  import { r as RequestScope } from "./types-BD85MlEK.mjs";
2
- import { D as CrudRouterOptions, Ht as IControllerResponse, N as FastifyWithDecorators, T as CrudController, Ut as IRequestContext, Vt as IController, ct as RequestWithExtras, m as AnyRecord, ot as RequestContext, qt as ResourceDefinition, ut as ResourceConfig } from "./interface-CMRutPfe.mjs";
3
- import { t as PermissionCheck } from "./types-DZi1aYhm.mjs";
4
- import { FastifyInstance, FastifyReply, FastifyRequest, RouteHandlerMethod } from "fastify";
2
+ import { c as PermissionCheck } from "./fields-Lo1VUDpt.mjs";
3
+ import { B as FastifyWithDecorators, G as ResourceDefinition, St as IRequestContext, Z as CrudController, bt as IController, dn as AnyRecord, lt as ResourceConfig, qt as RequestContext, w as CrudRouterOptions, xt as IControllerResponse } from "./index-Cl0uoKd5.mjs";
4
+ import { FastifyReply, FastifyRequest, RouteHandlerMethod } from "fastify";
5
5
 
6
6
  //#region src/constants.d.ts
7
7
  /**
@@ -52,137 +52,6 @@ declare const MAX_FILTER_DEPTH: 10;
52
52
  */
53
53
  declare const RESERVED_QUERY_PARAMS: Readonly<Set<string>>;
54
54
  //#endregion
55
- //#region src/core/createActionRouter.d.ts
56
- /**
57
- * Action handler function
58
- * @param id - Resource ID
59
- * @param data - Action-specific data from request body
60
- * @param req - Full Fastify request object
61
- * @returns Action result (will be wrapped in success response)
62
- */
63
- type ActionHandler<TData = Record<string, unknown>, TResult = unknown> = (id: string, data: TData, req: RequestWithExtras) => Promise<TResult>;
64
- /**
65
- * Action router configuration
66
- */
67
- interface ActionRouterConfig {
68
- /**
69
- * OpenAPI tag for grouping routes
70
- */
71
- readonly tag?: string;
72
- /**
73
- * Action handlers map
74
- * @example { approve: (id, data, req) => service.approve(id), ... }
75
- */
76
- readonly actions: Record<string, ActionHandler>;
77
- /**
78
- * Per-action permission checks (PermissionCheck functions)
79
- * @example { approve: requireRoles(['admin', 'manager']), cancel: requireRoles(['admin']) }
80
- */
81
- readonly actionPermissions?: Record<string, PermissionCheck>;
82
- /**
83
- * Per-action schema for body validation.
84
- *
85
- * Accepted shapes per action:
86
- *
87
- * 1. **Full JSON Schema object** with `type: 'object'`, `properties`, `required` —
88
- * used verbatim. Required fields ARE enforced by Fastify's AJV.
89
- *
90
- * 2. **Zod v4 schema** — auto-converted via `z.toJSONSchema()`. Required fields
91
- * ARE enforced.
92
- *
93
- * 3. **Legacy field map** `{ fieldName: { type: 'string' } }` — each key becomes
94
- * a property and is treated as REQUIRED unless the property schema has
95
- * `nullable: true` or Arc's sentinel `required: false`. Kept for back-compat.
96
- *
97
- * All shapes are compiled into a single `oneOf` discriminator body schema
98
- * so AJV can validate action-specific required fields at the HTTP layer.
99
- *
100
- * @example JSON Schema
101
- * ```ts
102
- * actionSchemas: {
103
- * dispatch: {
104
- * type: 'object',
105
- * properties: { carrier: { type: 'string' } },
106
- * required: ['carrier'],
107
- * },
108
- * }
109
- * ```
110
- *
111
- * @example Zod v4
112
- * ```ts
113
- * actionSchemas: {
114
- * dispatch: z.object({ carrier: z.string() }),
115
- * }
116
- * ```
117
- */
118
- readonly actionSchemas?: Record<string, Record<string, unknown>>;
119
- /**
120
- * Global permission check applied to all actions (if action-specific not defined)
121
- */
122
- readonly globalAuth?: PermissionCheck;
123
- /**
124
- * Optional idempotency service
125
- * If provided, will handle idempotency-key header
126
- */
127
- readonly idempotencyService?: IdempotencyService;
128
- /**
129
- * Custom error handler for action execution failures
130
- * @param error - The error thrown by action handler
131
- * @param action - The action that failed
132
- * @param id - The resource ID
133
- * @returns Status code and error response
134
- */
135
- readonly onError?: (error: Error, action: string, id: string) => {
136
- statusCode: number;
137
- error: string;
138
- code?: string;
139
- };
140
- }
141
- /**
142
- * Idempotency service interface
143
- * Apps can provide their own implementation
144
- */
145
- interface IdempotencyService {
146
- check(key: string, payload: unknown): Promise<{
147
- isNew: boolean;
148
- existingResult?: unknown;
149
- }>;
150
- complete(key: string | undefined, result: unknown): Promise<void>;
151
- fail(key: string | undefined, error: Error): Promise<void>;
152
- }
153
- /**
154
- * Create action-based state transition endpoint
155
- *
156
- * Registers: POST /:id/action
157
- * Body: { action: string, ...actionData }
158
- *
159
- * @param fastify - Fastify instance
160
- * @param config - Action router configuration
161
- */
162
- declare function createActionRouter(fastify: FastifyInstance, config: ActionRouterConfig): void;
163
- /**
164
- * Build a discriminated body schema for the unified action endpoint.
165
- *
166
- * Produces a schema of the form:
167
- * ```json
168
- * {
169
- * "type": "object",
170
- * "required": ["action"],
171
- * "oneOf": [
172
- * { "properties": { "action": { "const": "dispatch" }, "carrier": {...} }, "required": ["action", "carrier"] },
173
- * { "properties": { "action": { "const": "approve" } }, "required": ["action"] }
174
- * ]
175
- * }
176
- * ```
177
- *
178
- * AJV validates this natively, so an action call missing required fields is
179
- * rejected with HTTP 400 before the handler ever runs.
180
- *
181
- * Exported so OpenAPI generation and MCP tool generation can reuse the same
182
- * schema shape (single source of truth).
183
- */
184
- declare function buildActionBodySchema(actionEnum: readonly string[], actionSchemas?: Record<string, Record<string, unknown>>): Record<string, unknown>;
185
- //#endregion
186
55
  //#region src/core/createCrudRouter.d.ts
187
56
  /**
188
57
  * Create CRUD routes for a controller
@@ -309,4 +178,4 @@ declare function createCrudHandlers<TDoc>(controller: IController<TDoc>): {
309
178
  delete: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
310
179
  };
311
180
  //#endregion
312
- export { MUTATION_OPERATIONS as A, HOOK_OPERATIONS as C, MAX_FILTER_DEPTH as D, HookPhase as E, RESERVED_QUERY_PARAMS as M, SYSTEM_FIELDS as N, MAX_REGEX_LENGTH as O, DEFAULT_UPDATE_METHOD as S, HookOperation as T, DEFAULT_ID_FIELD as _, getControllerScope as a, DEFAULT_SORT as b, createCrudRouter as c, ActionRouterConfig as d, IdempotencyService as f, CrudOperation as g, CRUD_OPERATIONS as h, getControllerContext as i, MutationOperation as j, MAX_SEARCH_LENGTH as k, createPermissionMiddleware as l, createActionRouter as m, createFastifyHandler as n, sendControllerResponse as o, buildActionBodySchema as p, createRequestContext as r, defineResourceVariants as s, createCrudHandlers as t, ActionHandler as u, DEFAULT_LIMIT as v, HOOK_PHASES as w, DEFAULT_TENANT_FIELD as x, DEFAULT_MAX_LIMIT as y };
181
+ export { MAX_REGEX_LENGTH as C, RESERVED_QUERY_PARAMS as D, MutationOperation as E, SYSTEM_FIELDS as O, MAX_FILTER_DEPTH as S, MUTATION_OPERATIONS as T, DEFAULT_UPDATE_METHOD as _, getControllerScope as a, HookOperation as b, createCrudRouter as c, CrudOperation as d, DEFAULT_ID_FIELD as f, DEFAULT_TENANT_FIELD as g, DEFAULT_SORT as h, getControllerContext as i, createPermissionMiddleware as l, DEFAULT_MAX_LIMIT as m, createFastifyHandler as n, sendControllerResponse as o, DEFAULT_LIMIT as p, createRequestContext as r, defineResourceVariants as s, createCrudHandlers as t, CRUD_OPERATIONS as u, HOOK_OPERATIONS as v, MAX_SEARCH_LENGTH as w, HookPhase as x, HOOK_PHASES as y };