@classytic/arc 1.1.0 → 2.1.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 (200) hide show
  1. package/README.md +247 -794
  2. package/bin/arc.js +91 -52
  3. package/dist/EventTransport-BkUDYZEb.d.mts +99 -0
  4. package/dist/HookSystem-BsGV-j2l.mjs +404 -0
  5. package/dist/ResourceRegistry-7Ic20ZMw.mjs +249 -0
  6. package/dist/adapters/index.d.mts +5 -0
  7. package/dist/adapters/index.mjs +3 -0
  8. package/dist/audit/index.d.mts +81 -0
  9. package/dist/audit/index.mjs +275 -0
  10. package/dist/audit/mongodb.d.mts +5 -0
  11. package/dist/audit/mongodb.mjs +3 -0
  12. package/dist/audited-CGdLiSlE.mjs +140 -0
  13. package/dist/auth/index.d.mts +188 -0
  14. package/dist/auth/index.mjs +1096 -0
  15. package/dist/auth/redis-session.d.mts +43 -0
  16. package/dist/auth/redis-session.mjs +75 -0
  17. package/dist/betterAuthOpenApi-DjWDddNc.mjs +249 -0
  18. package/dist/cache/index.d.mts +145 -0
  19. package/dist/cache/index.mjs +91 -0
  20. package/dist/caching-GSDJcA6-.mjs +93 -0
  21. package/dist/chunk-C7Uep-_p.mjs +20 -0
  22. package/dist/circuitBreaker-DYhWBW_D.mjs +1096 -0
  23. package/dist/cli/commands/describe.d.mts +18 -0
  24. package/dist/cli/commands/describe.mjs +238 -0
  25. package/dist/cli/commands/docs.d.mts +13 -0
  26. package/dist/cli/commands/docs.mjs +52 -0
  27. package/dist/cli/commands/{generate.d.ts → generate.d.mts} +3 -2
  28. package/dist/cli/commands/generate.mjs +357 -0
  29. package/dist/cli/commands/{init.d.ts → init.d.mts} +11 -8
  30. package/dist/cli/commands/{init.js → init.mjs} +807 -617
  31. package/dist/cli/commands/introspect.d.mts +10 -0
  32. package/dist/cli/commands/introspect.mjs +75 -0
  33. package/dist/cli/index.d.mts +16 -0
  34. package/dist/cli/index.mjs +156 -0
  35. package/dist/constants-DdXFXQtN.mjs +84 -0
  36. package/dist/core/index.d.mts +5 -0
  37. package/dist/core/index.mjs +4 -0
  38. package/dist/createApp-D2D5XXaV.mjs +559 -0
  39. package/dist/defineResource-PXzSJ15_.mjs +2197 -0
  40. package/dist/discovery/index.d.mts +46 -0
  41. package/dist/discovery/index.mjs +109 -0
  42. package/dist/docs/index.d.mts +162 -0
  43. package/dist/docs/index.mjs +74 -0
  44. package/dist/elevation-DGo5shaX.d.mts +87 -0
  45. package/dist/elevation-DSTbVvYj.mjs +113 -0
  46. package/dist/errorHandler-C3GY3_ow.mjs +108 -0
  47. package/dist/errorHandler-CW3OOeYq.d.mts +72 -0
  48. package/dist/errors-DAWRdiYP.d.mts +124 -0
  49. package/dist/errors-DBANPbGr.mjs +211 -0
  50. package/dist/eventPlugin-BEOvaDqo.mjs +229 -0
  51. package/dist/eventPlugin-H6wDDjGO.d.mts +124 -0
  52. package/dist/events/index.d.mts +53 -0
  53. package/dist/events/index.mjs +51 -0
  54. package/dist/events/transports/redis-stream-entry.d.mts +2 -0
  55. package/dist/events/transports/redis-stream-entry.mjs +177 -0
  56. package/dist/events/transports/redis.d.mts +76 -0
  57. package/dist/events/transports/redis.mjs +124 -0
  58. package/dist/externalPaths-SyPF2tgK.d.mts +50 -0
  59. package/dist/factory/index.d.mts +63 -0
  60. package/dist/factory/index.mjs +3 -0
  61. package/dist/fastifyAdapter-C8DlE0YH.d.mts +216 -0
  62. package/dist/fields-Bi_AVKSo.d.mts +109 -0
  63. package/dist/fields-CTd_CrKr.mjs +114 -0
  64. package/dist/hooks/index.d.mts +4 -0
  65. package/dist/hooks/index.mjs +3 -0
  66. package/dist/idempotency/index.d.mts +96 -0
  67. package/dist/idempotency/index.mjs +319 -0
  68. package/dist/idempotency/mongodb.d.mts +2 -0
  69. package/dist/idempotency/mongodb.mjs +114 -0
  70. package/dist/idempotency/redis.d.mts +2 -0
  71. package/dist/idempotency/redis.mjs +103 -0
  72. package/dist/index.d.mts +260 -0
  73. package/dist/index.mjs +104 -0
  74. package/dist/integrations/event-gateway.d.mts +46 -0
  75. package/dist/integrations/event-gateway.mjs +43 -0
  76. package/dist/integrations/index.d.mts +5 -0
  77. package/dist/integrations/index.mjs +1 -0
  78. package/dist/integrations/jobs.d.mts +103 -0
  79. package/dist/integrations/jobs.mjs +123 -0
  80. package/dist/integrations/streamline.d.mts +60 -0
  81. package/dist/integrations/streamline.mjs +125 -0
  82. package/dist/integrations/websocket.d.mts +82 -0
  83. package/dist/integrations/websocket.mjs +288 -0
  84. package/dist/interface-CSNjltAc.d.mts +77 -0
  85. package/dist/interface-DTbsvIWe.d.mts +54 -0
  86. package/dist/interface-e9XfSsUV.d.mts +1097 -0
  87. package/dist/introspectionPlugin-B3JkrjwU.mjs +53 -0
  88. package/dist/keys-DhqDRxv3.mjs +42 -0
  89. package/dist/logger-ByrvQWZO.mjs +78 -0
  90. package/dist/memory-B2v7KrCB.mjs +143 -0
  91. package/dist/migrations/index.d.mts +156 -0
  92. package/dist/migrations/index.mjs +260 -0
  93. package/dist/mongodb-ClykrfGo.d.mts +118 -0
  94. package/dist/mongodb-DNKEExbf.mjs +93 -0
  95. package/dist/mongodb-Dg8O_gvd.d.mts +71 -0
  96. package/dist/openapi-9nB_kiuR.mjs +525 -0
  97. package/dist/org/index.d.mts +68 -0
  98. package/dist/org/index.mjs +513 -0
  99. package/dist/org/types.d.mts +82 -0
  100. package/dist/org/types.mjs +1 -0
  101. package/dist/permissions/index.d.mts +278 -0
  102. package/dist/permissions/index.mjs +579 -0
  103. package/dist/plugins/index.d.mts +172 -0
  104. package/dist/plugins/index.mjs +522 -0
  105. package/dist/plugins/response-cache.d.mts +87 -0
  106. package/dist/plugins/response-cache.mjs +283 -0
  107. package/dist/plugins/tracing-entry.d.mts +2 -0
  108. package/dist/plugins/tracing-entry.mjs +185 -0
  109. package/dist/pluralize-CM-jZg7p.mjs +86 -0
  110. package/dist/policies/{index.d.ts → index.d.mts} +204 -170
  111. package/dist/policies/index.mjs +321 -0
  112. package/dist/presets/{index.d.ts → index.d.mts} +62 -131
  113. package/dist/presets/index.mjs +143 -0
  114. package/dist/presets/multiTenant.d.mts +24 -0
  115. package/dist/presets/multiTenant.mjs +113 -0
  116. package/dist/presets-BTeYbw7h.d.mts +57 -0
  117. package/dist/presets-CeFtfDR8.mjs +119 -0
  118. package/dist/prisma-C3iornoK.d.mts +274 -0
  119. package/dist/prisma-DJbMt3yf.mjs +627 -0
  120. package/dist/queryCachePlugin-B6R0d4av.mjs +138 -0
  121. package/dist/queryCachePlugin-Q6SYuHZ6.d.mts +71 -0
  122. package/dist/redis-UwjEp8Ea.d.mts +49 -0
  123. package/dist/redis-stream-CBg0upHI.d.mts +103 -0
  124. package/dist/registry/index.d.mts +11 -0
  125. package/dist/registry/index.mjs +4 -0
  126. package/dist/requestContext-xi6OKBL-.mjs +55 -0
  127. package/dist/schemaConverter-Dtg0Kt9T.mjs +98 -0
  128. package/dist/schemas/index.d.mts +63 -0
  129. package/dist/schemas/index.mjs +82 -0
  130. package/dist/scope/index.d.mts +21 -0
  131. package/dist/scope/index.mjs +65 -0
  132. package/dist/sessionManager-D_iEHjQl.d.mts +186 -0
  133. package/dist/sse-DkqQ1uxb.mjs +123 -0
  134. package/dist/testing/index.d.mts +907 -0
  135. package/dist/testing/index.mjs +1976 -0
  136. package/dist/tracing-8CEbhF0w.d.mts +70 -0
  137. package/dist/typeGuards-DwxA1t_L.mjs +9 -0
  138. package/dist/types/index.d.mts +946 -0
  139. package/dist/types/index.mjs +14 -0
  140. package/dist/types-B0dhNrnd.d.mts +445 -0
  141. package/dist/types-Beqn1Un7.mjs +38 -0
  142. package/dist/types-DelU6kln.mjs +25 -0
  143. package/dist/types-RLkFVgaw.d.mts +101 -0
  144. package/dist/utils/index.d.mts +747 -0
  145. package/dist/utils/index.mjs +6 -0
  146. package/package.json +194 -68
  147. package/dist/BaseController-DVAiHxEQ.d.ts +0 -233
  148. package/dist/adapters/index.d.ts +0 -237
  149. package/dist/adapters/index.js +0 -668
  150. package/dist/arcCorePlugin-CsShQdyP.d.ts +0 -273
  151. package/dist/audit/index.d.ts +0 -195
  152. package/dist/audit/index.js +0 -319
  153. package/dist/auth/index.d.ts +0 -47
  154. package/dist/auth/index.js +0 -174
  155. package/dist/cli/commands/docs.d.ts +0 -11
  156. package/dist/cli/commands/docs.js +0 -474
  157. package/dist/cli/commands/generate.js +0 -334
  158. package/dist/cli/commands/introspect.d.ts +0 -8
  159. package/dist/cli/commands/introspect.js +0 -338
  160. package/dist/cli/index.d.ts +0 -4
  161. package/dist/cli/index.js +0 -3269
  162. package/dist/core/index.d.ts +0 -220
  163. package/dist/core/index.js +0 -2786
  164. package/dist/createApp-Ce9wl8W9.d.ts +0 -77
  165. package/dist/docs/index.d.ts +0 -166
  166. package/dist/docs/index.js +0 -658
  167. package/dist/errors-8WIxGS_6.d.ts +0 -122
  168. package/dist/events/index.d.ts +0 -117
  169. package/dist/events/index.js +0 -89
  170. package/dist/factory/index.d.ts +0 -38
  171. package/dist/factory/index.js +0 -1652
  172. package/dist/hooks/index.d.ts +0 -4
  173. package/dist/hooks/index.js +0 -199
  174. package/dist/idempotency/index.d.ts +0 -323
  175. package/dist/idempotency/index.js +0 -500
  176. package/dist/index-B4t03KQ0.d.ts +0 -1366
  177. package/dist/index.d.ts +0 -135
  178. package/dist/index.js +0 -4756
  179. package/dist/migrations/index.d.ts +0 -185
  180. package/dist/migrations/index.js +0 -274
  181. package/dist/org/index.d.ts +0 -129
  182. package/dist/org/index.js +0 -220
  183. package/dist/permissions/index.d.ts +0 -144
  184. package/dist/permissions/index.js +0 -103
  185. package/dist/plugins/index.d.ts +0 -46
  186. package/dist/plugins/index.js +0 -1069
  187. package/dist/policies/index.js +0 -196
  188. package/dist/presets/index.js +0 -384
  189. package/dist/presets/multiTenant.d.ts +0 -39
  190. package/dist/presets/multiTenant.js +0 -112
  191. package/dist/registry/index.d.ts +0 -16
  192. package/dist/registry/index.js +0 -253
  193. package/dist/testing/index.d.ts +0 -618
  194. package/dist/testing/index.js +0 -48020
  195. package/dist/types/index.d.ts +0 -4
  196. package/dist/types/index.js +0 -8
  197. package/dist/types-B99TBmFV.d.ts +0 -76
  198. package/dist/types-BvckRbs2.d.ts +0 -143
  199. package/dist/utils/index.d.ts +0 -679
  200. package/dist/utils/index.js +0 -931
@@ -0,0 +1,114 @@
1
+ //#region src/permissions/fields.ts
2
+ /**
3
+ * Field-Level Permissions
4
+ *
5
+ * Control field visibility and writability per role.
6
+ * Integrated into the response path (read) and sanitization path (write).
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { fields, defineResource } from '@classytic/arc';
11
+ *
12
+ * const userResource = defineResource({
13
+ * name: 'user',
14
+ * adapter: userAdapter,
15
+ * fields: {
16
+ * salary: fields.visibleTo(['admin', 'hr']),
17
+ * internalNotes: fields.writableBy(['admin']),
18
+ * email: fields.redactFor(['viewer']),
19
+ * password: fields.hidden(),
20
+ * },
21
+ * });
22
+ * ```
23
+ */
24
+ /** Type guard for Mongoose-like documents with toObject() */
25
+ function isMongooseDoc(obj) {
26
+ return !!obj && typeof obj === "object" && "toObject" in obj && typeof obj.toObject === "function";
27
+ }
28
+ const fields = {
29
+ hidden() {
30
+ return { _type: "hidden" };
31
+ },
32
+ visibleTo(roles) {
33
+ return {
34
+ _type: "visibleTo",
35
+ roles
36
+ };
37
+ },
38
+ writableBy(roles) {
39
+ return {
40
+ _type: "writableBy",
41
+ roles
42
+ };
43
+ },
44
+ redactFor(roles, redactValue = "***") {
45
+ return {
46
+ _type: "redactFor",
47
+ roles,
48
+ redactValue
49
+ };
50
+ }
51
+ };
52
+ /**
53
+ * Apply field-level READ permissions to a response object.
54
+ * Strips hidden fields, enforces visibility, and applies redaction.
55
+ *
56
+ * @param data - The response object (mutated in place for performance)
57
+ * @param fieldPermissions - Field permission map from resource config
58
+ * @param userRoles - Current user's roles (empty array for unauthenticated)
59
+ * @returns The filtered object
60
+ */
61
+ function applyFieldReadPermissions(data, fieldPermissions, userRoles) {
62
+ if (!data || typeof data !== "object") return data;
63
+ const result = { ...isMongooseDoc(data) ? data.toObject() : data };
64
+ for (const [field, perm] of Object.entries(fieldPermissions)) switch (perm._type) {
65
+ case "hidden":
66
+ delete result[field];
67
+ break;
68
+ case "visibleTo":
69
+ if (!perm.roles?.some((r) => userRoles.includes(r))) delete result[field];
70
+ break;
71
+ case "redactFor":
72
+ if (perm.roles?.some((r) => userRoles.includes(r))) result[field] = perm.redactValue ?? "***";
73
+ break;
74
+ case "writableBy": break;
75
+ }
76
+ return result;
77
+ }
78
+ /**
79
+ * Apply field-level WRITE permissions to request body.
80
+ * Strips fields that the user doesn't have permission to write.
81
+ *
82
+ * @param body - The request body (returns a new filtered copy)
83
+ * @param fieldPermissions - Field permission map from resource config
84
+ * @param userRoles - Current user's roles
85
+ * @returns Filtered body
86
+ */
87
+ function applyFieldWritePermissions(body, fieldPermissions, userRoles) {
88
+ const result = { ...body };
89
+ for (const [field, perm] of Object.entries(fieldPermissions)) switch (perm._type) {
90
+ case "hidden":
91
+ delete result[field];
92
+ break;
93
+ case "writableBy":
94
+ if (field in result && !perm.roles?.some((r) => userRoles.includes(r))) delete result[field];
95
+ break;
96
+ }
97
+ return result;
98
+ }
99
+ /**
100
+ * Resolve effective roles by merging global user roles with org-level roles.
101
+ *
102
+ * Global roles come from `req.user.role` (normalized via getUserRoles()).
103
+ * Org roles come from `req.context.orgRoles` (set by BA adapter's org bridge).
104
+ *
105
+ * When no org context exists, returns global roles only — backward compatible.
106
+ */
107
+ function resolveEffectiveRoles(userRoles, orgRoles) {
108
+ if (orgRoles.length === 0) return [...userRoles];
109
+ if (userRoles.length === 0) return [...orgRoles];
110
+ return [...new Set([...userRoles, ...orgRoles])];
111
+ }
112
+
113
+ //#endregion
114
+ export { resolveEffectiveRoles as i, applyFieldWritePermissions as n, fields as r, applyFieldReadPermissions as t };
@@ -0,0 +1,4 @@
1
+ import "../elevation-DGo5shaX.mjs";
2
+ import { $ as beforeUpdate, B as DefineHookOptions, G as HookRegistration, H as HookHandler, J as afterCreate, K as HookSystem, Q as beforeDelete, U as HookOperation, V as HookContext, W as HookPhase, X as afterUpdate, Y as afterDelete, Z as beforeCreate, et as createHookSystem, q as HookSystemOptions, tt as defineHook } from "../interface-e9XfSsUV.mjs";
3
+ import "../types-RLkFVgaw.mjs";
4
+ export { type DefineHookOptions, type HookContext, type HookHandler, type HookOperation, type HookPhase, type HookRegistration, HookSystem, type HookSystemOptions, afterCreate, afterDelete, afterUpdate, beforeCreate, beforeDelete, beforeUpdate, createHookSystem, defineHook };
@@ -0,0 +1,3 @@
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-BsGV-j2l.mjs";
2
+
3
+ export { HookSystem, afterCreate, afterDelete, afterUpdate, beforeCreate, beforeDelete, beforeUpdate, createHookSystem, defineHook };
@@ -0,0 +1,96 @@
1
+ import { i as createIdempotencyResult, n as IdempotencyResult, r as IdempotencyStore, t as IdempotencyLock } from "../interface-CSNjltAc.mjs";
2
+ import { r as RedisIdempotencyStoreOptions, t as RedisClient } from "../redis-UwjEp8Ea.mjs";
3
+ import { n as MongoIdempotencyStoreOptions } from "../mongodb-Dg8O_gvd.mjs";
4
+ import { FastifyPluginAsync } from "fastify";
5
+
6
+ //#region src/idempotency/idempotencyPlugin.d.ts
7
+ interface IdempotencyPluginOptions {
8
+ /** Enable idempotency (default: false) */
9
+ enabled?: boolean;
10
+ /** Header name for idempotency key (default: 'idempotency-key') */
11
+ headerName?: string;
12
+ /** TTL for cached responses in ms (default: 86400000 = 24h) */
13
+ ttlMs?: number;
14
+ /** Lock timeout in ms (default: 30000 = 30s) */
15
+ lockTimeoutMs?: number;
16
+ /** HTTP methods to apply idempotency to (default: ['POST', 'PUT', 'PATCH']) */
17
+ methods?: string[];
18
+ /** URL patterns to include (regex). If set, only matching URLs use idempotency */
19
+ include?: RegExp[];
20
+ /** URL patterns to exclude (regex). Excluded patterns take precedence */
21
+ exclude?: RegExp[];
22
+ /** Custom store (default: MemoryIdempotencyStore) */
23
+ store?: IdempotencyStore;
24
+ /** Retry-After header value in seconds when request is in-flight (default: 1) */
25
+ retryAfterSeconds?: number;
26
+ }
27
+ declare module 'fastify' {
28
+ interface FastifyRequest {
29
+ /** The idempotency key for this request (if present) */
30
+ idempotencyKey?: string;
31
+ /** Whether this response was replayed from cache */
32
+ idempotencyReplayed?: boolean;
33
+ /** @internal Full key with fingerprint for store lookups */
34
+ _idempotencyFullKey?: string;
35
+ }
36
+ interface FastifyInstance {
37
+ /** Idempotency utilities */
38
+ idempotency: {
39
+ /** Manually invalidate an idempotency key */invalidate: (key: string) => Promise<void>; /** Check if a key has a cached response */
40
+ has: (key: string) => Promise<boolean>;
41
+ /**
42
+ * Route-level preHandler for idempotency check + lock.
43
+ * Wire AFTER authenticate in the preHandler chain so that
44
+ * `request.user` is populated before the fingerprint is computed.
45
+ *
46
+ * `createCrudRouter` injects this automatically for mutation routes.
47
+ * For custom routes, add it manually:
48
+ * ```typescript
49
+ * fastify.post('/orders', {
50
+ * preHandler: [fastify.authenticate, fastify.idempotency.middleware],
51
+ * }, handler);
52
+ * ```
53
+ */
54
+ middleware: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
55
+ };
56
+ }
57
+ }
58
+ declare const idempotencyPlugin: FastifyPluginAsync<IdempotencyPluginOptions>;
59
+ declare const _default: FastifyPluginAsync<IdempotencyPluginOptions>;
60
+ //#endregion
61
+ //#region src/idempotency/stores/memory.d.ts
62
+ interface MemoryIdempotencyStoreOptions {
63
+ /** Default TTL in milliseconds (default: 86400000 = 24h) */
64
+ ttlMs?: number;
65
+ /** Cleanup interval in milliseconds (default: 60000 = 1 min) */
66
+ cleanupIntervalMs?: number;
67
+ /** Maximum entries before oldest are evicted (default: 10000) */
68
+ maxEntries?: number;
69
+ }
70
+ declare class MemoryIdempotencyStore implements IdempotencyStore {
71
+ readonly name = "memory";
72
+ private results;
73
+ private locks;
74
+ private ttlMs;
75
+ private maxEntries;
76
+ private cleanupInterval;
77
+ constructor(options?: MemoryIdempotencyStoreOptions);
78
+ get(key: string): Promise<IdempotencyResult | undefined>;
79
+ set(key: string, result: Omit<IdempotencyResult, 'key'>): Promise<void>;
80
+ tryLock(key: string, requestId: string, ttlMs: number): Promise<boolean>;
81
+ unlock(key: string, requestId: string): Promise<void>;
82
+ isLocked(key: string): Promise<boolean>;
83
+ delete(key: string): Promise<void>;
84
+ deleteByPrefix(prefix: string): Promise<number>;
85
+ findByPrefix(prefix: string): Promise<IdempotencyResult | undefined>;
86
+ close(): Promise<void>;
87
+ /** Get current stats (for debugging/monitoring) */
88
+ getStats(): {
89
+ results: number;
90
+ locks: number;
91
+ };
92
+ private cleanup;
93
+ private evictOldest;
94
+ }
95
+ //#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 };
@@ -0,0 +1,319 @@
1
+ import fp from "fastify-plugin";
2
+ import { createHash } from "crypto";
3
+
4
+ //#region src/idempotency/stores/interface.ts
5
+ /**
6
+ * Helper to create a result object
7
+ */
8
+ function createIdempotencyResult(statusCode, body, headers, ttlMs) {
9
+ const now = /* @__PURE__ */ new Date();
10
+ return {
11
+ statusCode,
12
+ headers,
13
+ body,
14
+ createdAt: now,
15
+ expiresAt: new Date(now.getTime() + ttlMs)
16
+ };
17
+ }
18
+
19
+ //#endregion
20
+ //#region src/idempotency/stores/memory.ts
21
+ var MemoryIdempotencyStore = class {
22
+ name = "memory";
23
+ results = /* @__PURE__ */ new Map();
24
+ locks = /* @__PURE__ */ new Map();
25
+ ttlMs;
26
+ maxEntries;
27
+ cleanupInterval = null;
28
+ constructor(options = {}) {
29
+ this.ttlMs = options.ttlMs ?? 864e5;
30
+ this.maxEntries = options.maxEntries ?? 1e4;
31
+ const cleanupIntervalMs = options.cleanupIntervalMs ?? 6e4;
32
+ this.cleanupInterval = setInterval(() => {
33
+ this.cleanup();
34
+ }, cleanupIntervalMs);
35
+ if (this.cleanupInterval.unref) this.cleanupInterval.unref();
36
+ }
37
+ async get(key) {
38
+ const result = this.results.get(key);
39
+ if (!result) return void 0;
40
+ if (/* @__PURE__ */ new Date() > result.expiresAt) {
41
+ this.results.delete(key);
42
+ return;
43
+ }
44
+ return result;
45
+ }
46
+ async set(key, result) {
47
+ if (this.results.size >= this.maxEntries) this.evictOldest();
48
+ this.results.set(key, {
49
+ ...result,
50
+ key
51
+ });
52
+ }
53
+ async tryLock(key, requestId, ttlMs) {
54
+ const existing = this.locks.get(key);
55
+ if (existing) if (/* @__PURE__ */ new Date() > existing.expiresAt) this.locks.delete(key);
56
+ else return false;
57
+ this.locks.set(key, {
58
+ key,
59
+ requestId,
60
+ lockedAt: /* @__PURE__ */ new Date(),
61
+ expiresAt: new Date(Date.now() + ttlMs)
62
+ });
63
+ return true;
64
+ }
65
+ async unlock(key, requestId) {
66
+ const lock = this.locks.get(key);
67
+ if (lock && lock.requestId === requestId) this.locks.delete(key);
68
+ }
69
+ async isLocked(key) {
70
+ const lock = this.locks.get(key);
71
+ if (!lock) return false;
72
+ if (/* @__PURE__ */ new Date() > lock.expiresAt) {
73
+ this.locks.delete(key);
74
+ return false;
75
+ }
76
+ return true;
77
+ }
78
+ async delete(key) {
79
+ this.results.delete(key);
80
+ this.locks.delete(key);
81
+ }
82
+ async deleteByPrefix(prefix) {
83
+ let count = 0;
84
+ for (const key of this.results.keys()) if (key.startsWith(prefix)) {
85
+ this.results.delete(key);
86
+ count++;
87
+ }
88
+ for (const key of this.locks.keys()) if (key.startsWith(prefix)) this.locks.delete(key);
89
+ return count;
90
+ }
91
+ async findByPrefix(prefix) {
92
+ const now = /* @__PURE__ */ new Date();
93
+ for (const [key, result] of this.results) if (key.startsWith(prefix)) {
94
+ if (now > result.expiresAt) {
95
+ this.results.delete(key);
96
+ continue;
97
+ }
98
+ return result;
99
+ }
100
+ }
101
+ async close() {
102
+ if (this.cleanupInterval) {
103
+ clearInterval(this.cleanupInterval);
104
+ this.cleanupInterval = null;
105
+ }
106
+ this.results.clear();
107
+ this.locks.clear();
108
+ }
109
+ /** Get current stats (for debugging/monitoring) */
110
+ getStats() {
111
+ return {
112
+ results: this.results.size,
113
+ locks: this.locks.size
114
+ };
115
+ }
116
+ cleanup() {
117
+ const now = /* @__PURE__ */ new Date();
118
+ for (const [key, result] of this.results) if (now > result.expiresAt) this.results.delete(key);
119
+ for (const [key, lock] of this.locks) if (now > lock.expiresAt) this.locks.delete(key);
120
+ }
121
+ evictOldest() {
122
+ const entries = Array.from(this.results.entries()).sort((a, b) => a[1].createdAt.getTime() - b[1].createdAt.getTime());
123
+ const toRemove = Math.max(1, Math.floor(entries.length * .1));
124
+ for (let i = 0; i < toRemove; i++) {
125
+ const entry = entries[i];
126
+ if (entry) this.results.delete(entry[0]);
127
+ }
128
+ }
129
+ };
130
+
131
+ //#endregion
132
+ //#region src/idempotency/idempotencyPlugin.ts
133
+ /**
134
+ * Idempotency Plugin
135
+ *
136
+ * Duplicate request protection for mutating operations.
137
+ * Uses idempotency keys to ensure safe retries.
138
+ *
139
+ * ## Auth Safety
140
+ *
141
+ * The idempotency check runs as a **route-level middleware**
142
+ * (`idempotency.middleware`) that must be wired AFTER authentication in the
143
+ * preHandler chain. This ensures the fingerprint includes the real caller
144
+ * identity, preventing cross-user replay attacks.
145
+ *
146
+ * Arc's `createCrudRouter` does this automatically for mutation routes.
147
+ * For custom routes, wire it manually:
148
+ *
149
+ * ```typescript
150
+ * fastify.post('/orders', {
151
+ * preHandler: [fastify.authenticate, fastify.idempotency.middleware],
152
+ * }, handler);
153
+ * ```
154
+ *
155
+ * @example
156
+ * import { idempotencyPlugin } from '@classytic/arc/idempotency';
157
+ *
158
+ * await fastify.register(idempotencyPlugin, {
159
+ * enabled: true,
160
+ * headerName: 'idempotency-key',
161
+ * ttlMs: 86400000, // 24 hours
162
+ * });
163
+ *
164
+ * // Client sends:
165
+ * // POST /api/orders
166
+ * // Idempotency-Key: order-123-abc
167
+ *
168
+ * // If same key sent again within TTL, returns cached response
169
+ */
170
+ const HEADER_IDEMPOTENCY_REPLAYED = "x-idempotency-replayed";
171
+ const HEADER_IDEMPOTENCY_KEY = "x-idempotency-key";
172
+ const idempotencyPlugin = async (fastify, opts = {}) => {
173
+ const { enabled = false, headerName = "idempotency-key", ttlMs = 864e5, lockTimeoutMs = 3e4, methods = [
174
+ "POST",
175
+ "PUT",
176
+ "PATCH"
177
+ ], include, exclude, store = new MemoryIdempotencyStore({ ttlMs }), retryAfterSeconds = 1 } = opts;
178
+ if (!enabled) {
179
+ fastify.decorate("idempotency", {
180
+ invalidate: async () => {},
181
+ has: async () => false,
182
+ middleware: async () => {}
183
+ });
184
+ fastify.decorateRequest("idempotencyKey", void 0);
185
+ fastify.decorateRequest("idempotencyReplayed", false);
186
+ fastify.log?.debug?.("Idempotency plugin disabled");
187
+ return;
188
+ }
189
+ const methodSet = new Set(methods.map((m) => m.toUpperCase()));
190
+ fastify.decorateRequest("idempotencyKey", void 0);
191
+ fastify.decorateRequest("idempotencyReplayed", false);
192
+ /**
193
+ * Check if this request should use idempotency
194
+ */
195
+ function shouldApplyIdempotency(request) {
196
+ if (!methodSet.has(request.method)) return false;
197
+ const url = request.url;
198
+ if (exclude?.some((pattern) => pattern.test(url))) return false;
199
+ if (include && !include.some((pattern) => pattern.test(url))) return false;
200
+ return true;
201
+ }
202
+ /**
203
+ * Normalize body for consistent hashing (sort keys recursively)
204
+ */
205
+ function normalizeBody(obj) {
206
+ if (obj === null || typeof obj !== "object") return obj;
207
+ if (Array.isArray(obj)) return obj.map(normalizeBody);
208
+ const sorted = {};
209
+ const keys = Object.keys(obj).sort();
210
+ for (const key of keys) sorted[key] = normalizeBody(obj[key]);
211
+ return sorted;
212
+ }
213
+ /**
214
+ * Generate a fingerprint for the request (for key generation).
215
+ * Includes caller identity so the same idempotency key from different
216
+ * users doesn't replay one user's response to another.
217
+ *
218
+ * IMPORTANT: This must be called AFTER auth has populated request.user,
219
+ * otherwise userId falls back to 'anon' and cross-user replay is possible.
220
+ */
221
+ function getRequestFingerprint(request) {
222
+ let bodyHash = "nobody";
223
+ if (request.body && typeof request.body === "object") {
224
+ const normalized = normalizeBody(request.body);
225
+ const bodyString = JSON.stringify(normalized);
226
+ bodyHash = createHash("sha256").update(bodyString).digest("hex").substring(0, 16);
227
+ if (request.log && request.log.debug) request.log.debug({ bodyHash }, "Generated body hash");
228
+ }
229
+ const user = request.user;
230
+ const userId = user?.id ?? user?._id ?? "anon";
231
+ return `${request.method}:${request.url}:${bodyHash}:u=${userId}`;
232
+ }
233
+ const idempotencyMiddleware = async (request, reply) => {
234
+ if (!shouldApplyIdempotency(request)) return;
235
+ const keyHeader = request.headers[headerName.toLowerCase()];
236
+ const idempotencyKey = typeof keyHeader === "string" ? keyHeader.trim() : void 0;
237
+ if (!idempotencyKey) return;
238
+ request.idempotencyKey = idempotencyKey;
239
+ const fullKey = `${idempotencyKey}:${getRequestFingerprint(request)}`;
240
+ const cached = await store.get(fullKey);
241
+ if (cached) {
242
+ request.idempotencyReplayed = true;
243
+ reply.header(HEADER_IDEMPOTENCY_REPLAYED, "true");
244
+ reply.header(HEADER_IDEMPOTENCY_KEY, idempotencyKey);
245
+ for (const [key, value] of Object.entries(cached.headers)) if (!key.startsWith("x-idempotency")) reply.header(key, value);
246
+ reply.code(cached.statusCode).send(cached.body);
247
+ return;
248
+ }
249
+ if (!await store.tryLock(fullKey, request.id, lockTimeoutMs)) {
250
+ reply.code(409).header("Retry-After", retryAfterSeconds.toString()).send({
251
+ error: "Request with this idempotency key is already in progress",
252
+ code: "IDEMPOTENCY_CONFLICT",
253
+ retryAfter: retryAfterSeconds
254
+ });
255
+ return;
256
+ }
257
+ request._idempotencyFullKey = fullKey;
258
+ };
259
+ fastify.decorate("idempotency", {
260
+ invalidate: async (key) => {
261
+ await store.deleteByPrefix(`${key}:`);
262
+ },
263
+ has: async (key) => {
264
+ return !!await store.findByPrefix(`${key}:`);
265
+ },
266
+ middleware: idempotencyMiddleware
267
+ });
268
+ fastify.addHook("onSend", async (request, reply, payload) => {
269
+ if (request.idempotencyReplayed) return payload;
270
+ const fullKey = request._idempotencyFullKey;
271
+ if (!fullKey) return payload;
272
+ const statusCode = reply.statusCode;
273
+ if (statusCode < 200 || statusCode >= 300) {
274
+ await store.unlock(fullKey, request.id);
275
+ return payload;
276
+ }
277
+ const headersToCache = {};
278
+ const excludeHeaders = new Set([
279
+ "content-length",
280
+ "transfer-encoding",
281
+ "connection",
282
+ "keep-alive",
283
+ "date",
284
+ "set-cookie"
285
+ ]);
286
+ const rawHeaders = reply.getHeaders();
287
+ for (const [key, value] of Object.entries(rawHeaders)) if (!excludeHeaders.has(key.toLowerCase()) && typeof value === "string") headersToCache[key] = value;
288
+ let body;
289
+ try {
290
+ body = typeof payload === "string" ? JSON.parse(payload) : payload;
291
+ } catch {
292
+ body = payload;
293
+ }
294
+ const result = createIdempotencyResult(statusCode, body, headersToCache, ttlMs);
295
+ await store.set(fullKey, result);
296
+ await store.unlock(fullKey, request.id);
297
+ reply.header(HEADER_IDEMPOTENCY_KEY, request.idempotencyKey);
298
+ return payload;
299
+ });
300
+ fastify.addHook("onError", async (request) => {
301
+ const fullKey = request._idempotencyFullKey;
302
+ if (fullKey) await store.unlock(fullKey, request.id);
303
+ });
304
+ fastify.addHook("onClose", async () => {
305
+ await store.close?.();
306
+ });
307
+ fastify.log?.debug?.({
308
+ headerName,
309
+ ttlMs,
310
+ methods
311
+ }, "Idempotency plugin enabled");
312
+ };
313
+ var idempotencyPlugin_default = fp(idempotencyPlugin, {
314
+ name: "arc-idempotency",
315
+ fastify: "5.x"
316
+ });
317
+
318
+ //#endregion
319
+ export { MemoryIdempotencyStore, createIdempotencyResult, idempotencyPlugin_default as idempotencyPlugin, idempotencyPlugin as idempotencyPluginFn };
@@ -0,0 +1,2 @@
1
+ import { n as MongoIdempotencyStoreOptions, t as MongoIdempotencyStore } from "../mongodb-Dg8O_gvd.mjs";
2
+ export { MongoIdempotencyStore, type MongoIdempotencyStoreOptions };
@@ -0,0 +1,114 @@
1
+ //#region src/idempotency/stores/mongodb.ts
2
+ var MongoIdempotencyStore = class {
3
+ name = "mongodb";
4
+ connection;
5
+ collectionName;
6
+ ttlMs;
7
+ indexCreated = false;
8
+ constructor(options) {
9
+ this.connection = options.connection;
10
+ this.collectionName = options.collection ?? "arc_idempotency";
11
+ this.ttlMs = options.ttlMs ?? 864e5;
12
+ if (options.createIndex !== false) this.ensureIndex().catch((err) => {
13
+ console.warn("[MongoIdempotencyStore] Failed to create index:", err);
14
+ });
15
+ }
16
+ get collection() {
17
+ return this.connection.db.collection(this.collectionName);
18
+ }
19
+ async ensureIndex() {
20
+ if (this.indexCreated) return;
21
+ try {
22
+ await this.collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
23
+ this.indexCreated = true;
24
+ } catch {
25
+ this.indexCreated = true;
26
+ }
27
+ }
28
+ async get(key) {
29
+ const doc = await this.collection.findOne({ _id: key });
30
+ if (!doc || !doc.result) return void 0;
31
+ if (new Date(doc.expiresAt) < /* @__PURE__ */ new Date()) return;
32
+ return {
33
+ key,
34
+ statusCode: doc.result.statusCode,
35
+ headers: doc.result.headers,
36
+ body: doc.result.body,
37
+ createdAt: new Date(doc.createdAt),
38
+ expiresAt: new Date(doc.expiresAt)
39
+ };
40
+ }
41
+ async set(key, result) {
42
+ await this.collection.updateOne({ _id: key }, {
43
+ $set: {
44
+ result: {
45
+ statusCode: result.statusCode,
46
+ headers: result.headers,
47
+ body: result.body
48
+ },
49
+ createdAt: result.createdAt,
50
+ expiresAt: result.expiresAt
51
+ },
52
+ $unset: { lock: "" }
53
+ }, { upsert: true });
54
+ }
55
+ async tryLock(key, requestId, ttlMs) {
56
+ const now = /* @__PURE__ */ new Date();
57
+ const expiresAt = new Date(now.getTime() + ttlMs);
58
+ try {
59
+ const result = await this.collection.updateOne({
60
+ _id: key,
61
+ $or: [{ lock: { $exists: false } }, { "lock.expiresAt": { $lt: now } }]
62
+ }, {
63
+ $set: { lock: {
64
+ requestId,
65
+ expiresAt
66
+ } },
67
+ $setOnInsert: {
68
+ createdAt: now,
69
+ expiresAt: new Date(now.getTime() + this.ttlMs)
70
+ }
71
+ }, { upsert: true });
72
+ return result.matchedCount === 1 || result.modifiedCount === 1;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+ async unlock(key, requestId) {
78
+ await this.collection.updateOne({
79
+ _id: key,
80
+ "lock.requestId": requestId
81
+ }, { $unset: { lock: "" } });
82
+ }
83
+ async isLocked(key) {
84
+ const doc = await this.collection.findOne({ _id: key });
85
+ if (!doc || !doc.lock) return false;
86
+ return new Date(doc.lock.expiresAt) > /* @__PURE__ */ new Date();
87
+ }
88
+ async delete(key) {
89
+ await this.collection.deleteOne({ _id: key });
90
+ }
91
+ async deleteByPrefix(prefix) {
92
+ return (await this.collection.deleteMany({ _id: { $regex: `^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}` } })).deletedCount;
93
+ }
94
+ async findByPrefix(prefix) {
95
+ const doc = await this.collection.findOne({
96
+ _id: { $regex: `^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}` },
97
+ result: { $exists: true },
98
+ expiresAt: { $gt: /* @__PURE__ */ new Date() }
99
+ });
100
+ if (!doc || !doc.result) return void 0;
101
+ return {
102
+ key: doc._id,
103
+ statusCode: doc.result.statusCode,
104
+ headers: doc.result.headers,
105
+ body: doc.result.body,
106
+ createdAt: new Date(doc.createdAt),
107
+ expiresAt: new Date(doc.expiresAt)
108
+ };
109
+ }
110
+ async close() {}
111
+ };
112
+
113
+ //#endregion
114
+ export { MongoIdempotencyStore };
@@ -0,0 +1,2 @@
1
+ import { n as RedisIdempotencyStore, r as RedisIdempotencyStoreOptions, t as RedisClient } from "../redis-UwjEp8Ea.mjs";
2
+ export { type RedisClient, RedisIdempotencyStore, type RedisIdempotencyStoreOptions };