@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
@@ -1,20 +1,134 @@
1
- import { a as AuditContext, c as AuditStore, i as AuditAction, l as AuditStoreOptions, n as MongoAuditStoreOptions, o as AuditEntry, r as MongoConnection, s as AuditQueryOptions, u as createAuditEntry } from "../mongodb-BsP-WbhN.mjs";
1
+ import { d as UserBase } from "../fields-Lo1VUDpt.mjs";
2
+ import { kt as RepositoryLike } from "../index-Cl0uoKd5.mjs";
2
3
  import { FastifyPluginAsync } from "fastify";
3
4
 
5
+ //#region src/audit/stores/interface.d.ts
6
+ type AuditAction = "create" | "update" | "delete" | "restore" | "custom";
7
+ interface AuditEntry {
8
+ /** Unique audit log ID */
9
+ id: string;
10
+ /** Resource name (e.g., 'product', 'user') */
11
+ resource: string;
12
+ /** Document/entity ID */
13
+ documentId: string;
14
+ /** Action performed */
15
+ action: AuditAction;
16
+ /** User who performed the action */
17
+ userId?: string;
18
+ /** Organization context */
19
+ organizationId?: string;
20
+ /** Previous state (for updates) */
21
+ before?: Record<string, unknown>;
22
+ /** New state (for creates/updates) */
23
+ after?: Record<string, unknown>;
24
+ /** Changed fields (for updates) */
25
+ changes?: string[];
26
+ /** Request ID for tracing */
27
+ requestId?: string;
28
+ /** IP address */
29
+ ipAddress?: string;
30
+ /** User agent */
31
+ userAgent?: string;
32
+ /** Custom metadata */
33
+ metadata?: Record<string, unknown>;
34
+ /** When the action occurred */
35
+ timestamp: Date;
36
+ }
37
+ interface AuditContext {
38
+ user?: UserBase;
39
+ organizationId?: string;
40
+ requestId?: string;
41
+ ipAddress?: string;
42
+ userAgent?: string;
43
+ /** HTTP method + route pattern (e.g., 'PATCH /api/products/:id') */
44
+ endpoint?: string;
45
+ /** Request duration in milliseconds */
46
+ duration?: number;
47
+ }
48
+ interface AuditStoreOptions {
49
+ /** Store name for logging */
50
+ name: string;
51
+ }
52
+ /**
53
+ * Abstract audit store interface
54
+ */
55
+ interface AuditStore {
56
+ /** Store name */
57
+ readonly name: string;
58
+ /** Log an audit entry */
59
+ log(entry: AuditEntry): Promise<void>;
60
+ /** Query audit logs (optional - not all stores support querying) */
61
+ query?(options: AuditQueryOptions): Promise<AuditEntry[]>;
62
+ /**
63
+ * Purge entries older than `cutoff`, return count deleted. Optional —
64
+ * stores that don't support deletion (append-only emitters like Kafka,
65
+ * S3 archivers) simply omit this method and are skipped by
66
+ * `fastify.audit.purge(...)`. Mongo-backed repositories can also rely
67
+ * on a server-side TTL index instead of calling this; the method is
68
+ * the DB-agnostic escape hatch.
69
+ */
70
+ purgeOlderThan?(cutoff: Date): Promise<number>;
71
+ /** Close/cleanup (optional) */
72
+ close?(): Promise<void>;
73
+ }
74
+ interface AuditQueryOptions {
75
+ resource?: string;
76
+ documentId?: string;
77
+ userId?: string;
78
+ organizationId?: string;
79
+ action?: AuditAction | AuditAction[];
80
+ from?: Date;
81
+ to?: Date;
82
+ limit?: number;
83
+ offset?: number;
84
+ }
85
+ /**
86
+ * Create audit entry from context
87
+ */
88
+ declare function createAuditEntry(resource: string, documentId: string, action: AuditAction, context: AuditContext, data?: {
89
+ before?: Record<string, unknown>;
90
+ after?: Record<string, unknown>;
91
+ metadata?: Record<string, unknown>;
92
+ }): AuditEntry;
93
+ //#endregion
4
94
  //#region src/audit/auditPlugin.d.ts
5
95
  interface AuditPluginOptions {
6
96
  /** Enable audit logging (default: false) */
7
97
  enabled?: boolean;
8
- /** Storage backends to use */
9
- stores?: ("memory" | "mongodb")[];
10
- /** MongoDB connection (required if using mongodb store) */
11
- mongoConnection?: MongoConnection;
12
- /** MongoDB collection name (default: 'audit_logs') */
13
- mongoCollection?: string;
14
- /** TTL in days for MongoDB (default: 90) */
15
- ttlDays?: number;
16
- /** Custom stores (advanced) */
98
+ /**
99
+ * Repository managing the audit collection. Arc consumes it **directly**
100
+ * no wrapping, no aliases, no proxy classes. Pass any object that
101
+ * implements arc's `RepositoryLike` (mongokit's `Repository`, prismakit's
102
+ * repo, a custom implementation). Arc calls `repository.create(entry)` to
103
+ * log and `repository.findAll(filter, options)` to query.
104
+ *
105
+ * If neither `repository` nor `customStores` is provided, falls back to
106
+ * `MemoryAuditStore` (intended for dev / tests only).
107
+ */
108
+ repository?: RepositoryLike;
109
+ /**
110
+ * Custom audit stores — for backends that aren't repositories (Kafka, S3,
111
+ * OpenTelemetry exporter, etc.). Each must implement the `AuditStore`
112
+ * interface. `repository` and `customStores` compose: entries get logged
113
+ * to every store.
114
+ */
17
115
  customStores?: AuditStore[];
116
+ /**
117
+ * Retention policy — optional. Entries older than `maxAgeMs` are purged
118
+ * on a timer (`purgeIntervalMs`, default 24h). Stores that implement
119
+ * `purgeOlderThan` participate; append-only stores are skipped.
120
+ *
121
+ * Apps on MongoDB can instead declare a TTL index on the audit
122
+ * collection's `timestamp` field — server-side TTL is cheaper than a
123
+ * periodic delete. Both approaches coexist: `fastify.audit.purge(...)`
124
+ * is always available for manual / cron-driven purges.
125
+ *
126
+ * Set `purgeIntervalMs: 0` to skip the timer (manual purge only).
127
+ */
128
+ retention?: {
129
+ /** Max entry age in ms. Entries with `timestamp < now - maxAgeMs` are purged. */maxAgeMs: number; /** Interval between purges in ms. Default 86_400_000 (24h). 0 disables the timer. */
130
+ purgeIntervalMs?: number;
131
+ };
18
132
  /**
19
133
  * Automatically audit CRUD operations via the hook system (default: true when enabled).
20
134
  * When enabled, create/update/delete operations are auto-logged without manual calls.
@@ -78,10 +192,19 @@ interface AuditLogger {
78
192
  custom: (resource: string, documentId: string, action: string, data?: Record<string, unknown>, context?: AuditContext) => Promise<void>;
79
193
  /** Query audit logs (if stores support it) */
80
194
  query: (options: AuditQueryOptions) => Promise<AuditEntry[]>;
195
+ /**
196
+ * Purge audit entries older than `cutoff` across every registered store.
197
+ * Returns the total number of entries deleted. Stores that don't support
198
+ * deletion (append-only emitters) are skipped silently.
199
+ */
200
+ purge: (cutoff: Date) => Promise<number>;
81
201
  }
82
202
  declare const auditPlugin: FastifyPluginAsync<AuditPluginOptions>;
83
203
  declare const _default: FastifyPluginAsync<AuditPluginOptions>;
84
204
  //#endregion
205
+ //#region src/audit/repository-audit-adapter.d.ts
206
+ declare function repositoryAsAuditStore(repository: RepositoryLike): AuditStore;
207
+ //#endregion
85
208
  //#region src/audit/stores/memory.d.ts
86
209
  interface MemoryAuditStoreOptions {
87
210
  /** Maximum entries to keep (default: 1000) */
@@ -94,6 +217,7 @@ declare class MemoryAuditStore implements AuditStore {
94
217
  constructor(options?: MemoryAuditStoreOptions);
95
218
  log(entry: AuditEntry): Promise<void>;
96
219
  query(options?: AuditQueryOptions): Promise<AuditEntry[]>;
220
+ purgeOlderThan(cutoff: Date): Promise<number>;
97
221
  close(): Promise<void>;
98
222
  /** Get all entries (for testing) */
99
223
  getAll(): AuditEntry[];
@@ -101,4 +225,4 @@ declare class MemoryAuditStore implements AuditStore {
101
225
  clear(): void;
102
226
  }
103
227
  //#endregion
104
- export { type AuditAction, type AuditContext, type AuditEntry, type AuditLogger, type AuditPluginOptions, type AuditQueryOptions, type AuditStore, type AuditStoreOptions, MemoryAuditStore, type MemoryAuditStoreOptions, type MongoAuditStoreOptions, _default as auditPlugin, auditPlugin as auditPluginFn, createAuditEntry };
228
+ export { type AuditAction, type AuditContext, type AuditEntry, type AuditLogger, type AuditPluginOptions, type AuditQueryOptions, type AuditStore, type AuditStoreOptions, MemoryAuditStore, type MemoryAuditStoreOptions, _default as auditPlugin, auditPlugin as auditPluginFn, createAuditEntry, repositoryAsAuditStore };
@@ -1,5 +1,77 @@
1
- import { t as MongoAuditStore } from "../mongodb-Utc5k_-0.mjs";
2
1
  import fp from "fastify-plugin";
2
+ //#region src/audit/repository-audit-adapter.ts
3
+ function repositoryAsAuditStore(repository) {
4
+ return {
5
+ name: "repository",
6
+ async log(entry) {
7
+ const doc = {
8
+ _id: entry.id,
9
+ id: entry.id,
10
+ resource: entry.resource,
11
+ documentId: entry.documentId,
12
+ action: entry.action,
13
+ userId: entry.userId,
14
+ organizationId: entry.organizationId,
15
+ before: entry.before,
16
+ after: entry.after,
17
+ changes: entry.changes,
18
+ requestId: entry.requestId,
19
+ ipAddress: entry.ipAddress,
20
+ userAgent: entry.userAgent,
21
+ metadata: entry.metadata,
22
+ timestamp: entry.timestamp
23
+ };
24
+ await repository.create(doc);
25
+ },
26
+ async purgeOlderThan(cutoff) {
27
+ if (!repository.deleteMany) return 0;
28
+ return (await repository.deleteMany({ timestamp: { $lt: cutoff } })).deletedCount ?? 0;
29
+ },
30
+ async query(opts = {}) {
31
+ if (!repository.getAll) throw new Error("auditPlugin: repository.getAll is required for query(). It's on repo-core's MinimalRepo floor — every kit (mongokit, sqlitekit, custom) implements it.");
32
+ const filter = {};
33
+ if (opts.resource) filter.resource = opts.resource;
34
+ if (opts.documentId) filter.documentId = opts.documentId;
35
+ if (opts.userId) filter.userId = opts.userId;
36
+ if (opts.organizationId) filter.organizationId = opts.organizationId;
37
+ if (opts.action) {
38
+ const actions = Array.isArray(opts.action) ? opts.action : [opts.action];
39
+ filter.action = actions.length === 1 ? actions[0] : { $in: actions };
40
+ }
41
+ if (opts.from || opts.to) {
42
+ const range = {};
43
+ if (opts.from) range.$gte = opts.from;
44
+ if (opts.to) range.$lte = opts.to;
45
+ filter.timestamp = range;
46
+ }
47
+ const limit = opts.limit ?? 100;
48
+ const page = Math.floor((opts.offset ?? 0) / limit) + 1;
49
+ const result = await repository.getAll({
50
+ filters: filter,
51
+ sort: { timestamp: -1 },
52
+ page,
53
+ limit
54
+ });
55
+ return (Array.isArray(result) ? result : result.docs ?? []).map((d) => ({
56
+ id: String(d._id ?? d.id ?? ""),
57
+ resource: d.resource ?? "",
58
+ documentId: d.documentId ?? "",
59
+ action: d.action ?? "create",
60
+ userId: d.userId,
61
+ organizationId: d.organizationId,
62
+ before: d.before,
63
+ after: d.after,
64
+ changes: d.changes,
65
+ requestId: d.requestId,
66
+ ipAddress: d.ipAddress,
67
+ userAgent: d.userAgent,
68
+ metadata: d.metadata,
69
+ timestamp: d.timestamp ?? /* @__PURE__ */ new Date()
70
+ }));
71
+ }
72
+ };
73
+ }
74
+ //#endregion
3
75
  //#region src/audit/stores/interface.ts
4
76
  /**
5
77
  * Create audit entry from context
@@ -81,6 +153,11 @@ var MemoryAuditStore = class {
81
153
  results = results.slice(offset, offset + limit);
82
154
  return results;
83
155
  }
156
+ async purgeOlderThan(cutoff) {
157
+ const before = this.entries.length;
158
+ this.entries = this.entries.filter((e) => e.timestamp >= cutoff);
159
+ return before - this.entries.length;
160
+ }
84
161
  async close() {
85
162
  this.entries = [];
86
163
  }
@@ -96,28 +173,17 @@ var MemoryAuditStore = class {
96
173
  //#endregion
97
174
  //#region src/audit/auditPlugin.ts
98
175
  const auditPlugin = async (fastify, opts = {}) => {
99
- const { enabled = false, stores: storeTypes = ["memory"], mongoConnection, mongoCollection = "audit_logs", ttlDays = 90, customStores = [] } = opts;
176
+ const { enabled = false, repository, customStores = [] } = opts;
100
177
  if (!enabled) {
101
178
  fastify.decorate("audit", createNoopLogger());
102
179
  fastify.decorateRequest("auditContext", void 0);
103
180
  fastify.log?.debug?.("Audit plugin disabled");
104
181
  return;
105
182
  }
106
- const stores = [...customStores];
107
- for (const type of storeTypes) switch (type) {
108
- case "memory":
109
- stores.push(new MemoryAuditStore());
110
- break;
111
- case "mongodb":
112
- if (!mongoConnection) throw new Error("Audit: mongoConnection required for mongodb store");
113
- stores.push(new MongoAuditStore({
114
- connection: mongoConnection,
115
- collection: mongoCollection,
116
- ttlDays
117
- }));
118
- break;
119
- }
120
- if (stores.length === 0) throw new Error("Audit: at least one store must be configured");
183
+ const stores = [];
184
+ if (repository) stores.push(repositoryAsAuditStore(repository));
185
+ stores.push(...customStores);
186
+ if (stores.length === 0) stores.push(new MemoryAuditStore());
121
187
  async function logToStores(entry) {
122
188
  await Promise.all(stores.map((store) => store.log(entry)));
123
189
  }
@@ -146,6 +212,11 @@ const auditPlugin = async (fastify, opts = {}) => {
146
212
  async query(options) {
147
213
  for (const store of stores) if (store.query) return store.query(options);
148
214
  return [];
215
+ },
216
+ async purge(cutoff) {
217
+ let total = 0;
218
+ for (const store of stores) if (store.purgeOlderThan) total += await store.purgeOlderThan(cutoff);
219
+ return total;
149
220
  }
150
221
  };
151
222
  fastify.decorate("audit", audit);
@@ -170,7 +241,22 @@ const auditPlugin = async (fastify, opts = {}) => {
170
241
  request.auditContext.duration = Math.round(reply.elapsedTime);
171
242
  }
172
243
  });
244
+ const retention = opts.retention;
245
+ let retentionTimer = null;
246
+ if (retention) {
247
+ const interval = retention.purgeIntervalMs ?? 864e5;
248
+ if (interval > 0) {
249
+ retentionTimer = setInterval(() => {
250
+ const cutoff = new Date(Date.now() - retention.maxAgeMs);
251
+ audit.purge(cutoff).catch((err) => {
252
+ fastify.log?.warn?.({ err }, "audit retention purge failed");
253
+ });
254
+ }, interval);
255
+ retentionTimer.unref?.();
256
+ }
257
+ }
173
258
  fastify.addHook("onClose", async () => {
259
+ if (retentionTimer) clearInterval(retentionTimer);
174
260
  await Promise.all(stores.map((store) => store.close?.()));
175
261
  });
176
262
  const autoAuditConfig = opts.autoAudit ?? true;
@@ -232,7 +318,7 @@ const auditPlugin = async (fastify, opts = {}) => {
232
318
  }, "Auto-audit hooks registered");
233
319
  });
234
320
  }
235
- fastify.log?.debug?.({ stores: storeTypes }, "Audit plugin enabled");
321
+ fastify.log?.debug?.({ stores: stores.map((s) => s.name) }, "Audit plugin enabled");
236
322
  };
237
323
  /** Extract document ID from a result */
238
324
  function autoAuditExtractId(doc) {
@@ -264,7 +350,8 @@ function createNoopLogger() {
264
350
  delete: noop,
265
351
  restore: noop,
266
352
  custom: noop,
267
- query: async () => []
353
+ query: async () => [],
354
+ purge: async () => 0
268
355
  };
269
356
  }
270
357
  var auditPlugin_default = fp(auditPlugin, {
@@ -273,4 +360,4 @@ var auditPlugin_default = fp(auditPlugin, {
273
360
  dependencies: ["arc-core"]
274
361
  });
275
362
  //#endregion
276
- export { MemoryAuditStore, auditPlugin_default as auditPlugin, auditPlugin as auditPluginFn, createAuditEntry };
363
+ export { MemoryAuditStore, auditPlugin_default as auditPlugin, auditPlugin as auditPluginFn, createAuditEntry, repositoryAsAuditStore };
@@ -1,8 +1,8 @@
1
- import { b as AuthPluginOptions, y as AuthHelpers } from "../interface-CMRutPfe.mjs";
2
- import { t as PermissionCheck } from "../types-DZi1aYhm.mjs";
3
- import { t as ExternalOpenApiPaths } from "../externalPaths-BnkYrNzp.mjs";
4
- import { a as SessionManagerOptions, c as createSessionManager, i as SessionData, n as MemorySessionStoreOptions, o as SessionManagerResult, r as SessionCookieOptions, s as SessionStore, t as MemorySessionStore } from "../sessionManager-DDCmiNIo.mjs";
5
- import { FastifyPluginAsync, FastifyReply as FastifyReply$1, FastifyRequest as FastifyRequest$1 } from "fastify";
1
+ import { c as PermissionCheck } from "../fields-Lo1VUDpt.mjs";
2
+ import { A as AuthHelpers, j as AuthPluginOptions } from "../index-Cl0uoKd5.mjs";
3
+ import { t as ExternalOpenApiPaths } from "../externalPaths-BQ8QijNH.mjs";
4
+ import { a as SessionManagerOptions, c as createSessionManager, i as SessionData, n as MemorySessionStoreOptions, o as SessionManagerResult, r as SessionCookieOptions, s as SessionStore, t as MemorySessionStore } from "../sessionManager-BkzVU8h2.mjs";
5
+ import { FastifyPluginAsync, FastifyReply as FastifyReply$1, FastifyRequest } from "fastify";
6
6
 
7
7
  //#region src/auth/authPlugin.d.ts
8
8
  declare module "fastify" {
@@ -17,6 +17,14 @@ declare module "fastify" {
17
17
  auth: AuthHelpers;
18
18
  }
19
19
  }
20
+ /**
21
+ * Extract Bearer token from Authorization header.
22
+ *
23
+ * Exported for property-based test coverage — the contract is:
24
+ * - header must start with exactly `"Bearer "` (case-sensitive, one space)
25
+ * - everything after that prefix is returned verbatim (no trim, no parse)
26
+ * - missing header → `null`; any other shape → `null`
27
+ */
20
28
  declare const authPlugin: FastifyPluginAsync<AuthPluginOptions>;
21
29
  declare const _default: FastifyPluginAsync<AuthPluginOptions>;
22
30
  //#endregion
@@ -90,9 +98,9 @@ interface BetterAuthAdapterResult {
90
98
  /** Fastify plugin that registers catch-all auth routes */
91
99
  plugin: FastifyPluginAsync;
92
100
  /** Authenticate preHandler -- validates session via Better Auth */
93
- authenticate: (request: FastifyRequest$1, reply: FastifyReply$1) => Promise<void>;
101
+ authenticate: (request: FastifyRequest, reply: FastifyReply$1) => Promise<void>;
94
102
  /** Optional authenticate -- resolves session silently, continues as unauthenticated on failure */
95
- optionalAuthenticate: (request: FastifyRequest$1, reply: FastifyReply$1) => Promise<void>;
103
+ optionalAuthenticate: (request: FastifyRequest, reply: FastifyReply$1) => Promise<void>;
96
104
  /** Permission helpers bound to this auth adapter (available when orgContext is enabled) */
97
105
  permissions: {
98
106
  requireOrgRole: (...roles: string[]) => PermissionCheck;
@@ -109,7 +117,7 @@ declare module "fastify" {
109
117
  * Validates session by calling Better Auth's session endpoint internally.
110
118
  * Set by the Better Auth adapter plugin.
111
119
  */
112
- authenticate: (request: FastifyRequest$1, reply: FastifyReply$1) => Promise<void>;
120
+ authenticate: (request: FastifyRequest, reply: FastifyReply$1) => Promise<void>;
113
121
  /**
114
122
  * Optional authenticate middleware (Better Auth variant).
115
123
  * Tries to resolve session silently — populates request.user if valid,
@@ -117,7 +125,7 @@ declare module "fastify" {
117
125
  * Used on allowPublic() routes so downstream middleware can apply
118
126
  * org-scoped queries when a user IS authenticated.
119
127
  */
120
- optionalAuthenticate: (request: FastifyRequest$1, reply: FastifyReply$1) => Promise<void>;
128
+ optionalAuthenticate: (request: FastifyRequest, reply: FastifyReply$1) => Promise<void>;
121
129
  }
122
130
  }
123
131
  /**
@@ -1,7 +1,7 @@
1
- import { n as normalizeRoles, t as getUserRoles } from "../types-ZUu_h0jp.mjs";
2
- import { t as ArcError } from "../errors-BF2bIOIS.mjs";
3
- import { h as requireTeamMembership, l as requireOrgMembership, u as requireOrgRole } from "../permissions-CH4cNwJi.mjs";
4
- import { n as extractBetterAuthOpenApi } from "../betterAuthOpenApi-BuUcUEJq.mjs";
1
+ import { n as normalizeRoles, t as getUserRoles } from "../types-DV9WDfeg.mjs";
2
+ import { t as ArcError } from "../errors-D5c-5BJL.mjs";
3
+ import { _ as requireTeamMembership, m as requireOrgRole, p as requireOrgMembership } from "../permissions-Dk6mshja.mjs";
4
+ import { n as extractBetterAuthOpenApi } from "../betterAuthOpenApi-BBRVhjQN.mjs";
5
5
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
6
6
  import fp from "fastify-plugin";
7
7
  //#region src/auth/authPlugin.ts
@@ -21,7 +21,12 @@ function parseExpiresIn(input, defaultValue) {
21
21
  }[match[2]?.toLowerCase() ?? "s"] ?? 1);
22
22
  }
23
23
  /**
24
- * Extract Bearer token from Authorization header
24
+ * Extract Bearer token from Authorization header.
25
+ *
26
+ * Exported for property-based test coverage — the contract is:
27
+ * - header must start with exactly `"Bearer "` (case-sensitive, one space)
28
+ * - everything after that prefix is returned verbatim (no trim, no parse)
29
+ * - missing header → `null`; any other shape → `null`
25
30
  */
26
31
  function extractBearerToken(request) {
27
32
  const auth = request.headers.authorization;
@@ -29,7 +34,7 @@ function extractBearerToken(request) {
29
34
  return auth.slice(7);
30
35
  }
31
36
  const authPlugin = async (fastify, opts = {}) => {
32
- const { jwt: jwtConfig, authenticate: appAuthenticator, onFailure, userProperty = "user", exposeAuthErrors = false, tokenExtractor, isRevoked } = opts;
37
+ const { jwt: jwtConfig, authenticate: appAuthenticator, onFailure, userProperty = "user", exposeAuthErrors = false, tokenExtractor, isRevoked, strictTokenType = true } = opts;
33
38
  /** Extract token from request — uses custom extractor if provided, else Bearer header */
34
39
  const resolveToken = (request) => {
35
40
  if (tokenExtractor) return tokenExtractor(request);
@@ -84,6 +89,7 @@ const authPlugin = async (fastify, opts = {}) => {
84
89
  if (token) {
85
90
  const decoded = jwtContext.verify(token);
86
91
  if (decoded.type === "refresh") throw new Error("Refresh tokens cannot be used for authentication");
92
+ if (strictTokenType && decoded.type !== "access") throw new Error("Invalid token type: expected access token");
87
93
  user = decoded;
88
94
  }
89
95
  } else throw new Error("No authenticator configured. Provide auth.authenticate function or auth.jwt.secret.");
@@ -146,6 +152,7 @@ const authPlugin = async (fastify, opts = {}) => {
146
152
  if (token) {
147
153
  const decoded = jwtContext.verify(token);
148
154
  if (decoded.type === "refresh") return;
155
+ if (strictTokenType && decoded.type !== "access") return;
149
156
  user = decoded;
150
157
  }
151
158
  }
@@ -677,7 +684,7 @@ function createBetterAuthAdapter(options) {
677
684
  if (!fastify.hasDecorator("authenticate")) fastify.decorate("authenticate", authenticate);
678
685
  if (!fastify.hasDecorator("optionalAuthenticate")) fastify.decorate("optionalAuthenticate", optionalAuthenticate);
679
686
  if (!extractedOpenApi && openapiOpt !== false && auth.api && typeof auth.api === "object") {
680
- const { extractBetterAuthOpenApi } = await import("../betterAuthOpenApi-BuUcUEJq.mjs").then((n) => n.t);
687
+ const { extractBetterAuthOpenApi } = await import("../betterAuthOpenApi-BBRVhjQN.mjs").then((n) => n.t);
681
688
  extractedOpenApi = extractBetterAuthOpenApi(auth.api, {
682
689
  basePath,
683
690
  userFields
@@ -1,4 +1,4 @@
1
- import { i as SessionData, s as SessionStore } from "../sessionManager-DDCmiNIo.mjs";
1
+ import { i as SessionData, s as SessionStore } from "../sessionManager-BkzVU8h2.mjs";
2
2
 
3
3
  //#region src/auth/redis-session.d.ts
4
4
  /** Minimal Redis client interface — compatible with ioredis */
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./chunk-BpYLSNr0.mjs";
2
- import { a as toJsonSchema } from "./schemaConverter-Y7nCYaLJ.mjs";
2
+ import { a as toJsonSchema } from "./schemaConverter-BxFDdtXu.mjs";
3
3
  //#region src/auth/betterAuthOpenApi.ts
4
4
  var betterAuthOpenApi_exports = /* @__PURE__ */ __exportAll({ extractBetterAuthOpenApi: () => extractBetterAuthOpenApi });
5
5
  /**
@@ -1,5 +1,5 @@
1
- import { i as CacheStore, n as CacheSetOptions, r as CacheStats, t as CacheLogger } from "../interface-4y979v99.mjs";
2
- import { a as CacheEnvelope, c as QueryCache, i as queryCachePlugin, l as QueryCacheConfig, n as QueryCacheDefaults, o as CacheResult, r as QueryCachePluginOptions, s as CacheStatus, t as CrossResourceRule } from "../queryCachePlugin-BJJGBTlu.mjs";
1
+ import { n as CacheStats, r as CacheStore, t as CacheLogger } from "../interface-D218ikEo.mjs";
2
+ import { a as CacheEnvelope, c as QueryCache, i as queryCachePlugin, l as QueryCacheConfig, n as QueryCacheDefaults, o as CacheResult, r as QueryCachePluginOptions, s as CacheStatus, t as CrossResourceRule } from "../queryCachePlugin-BKbWjgDG.mjs";
3
3
 
4
4
  //#region src/cache/keys.d.ts
5
5
  /**
@@ -24,8 +24,8 @@ declare function hashParams(params: Record<string, unknown>): string;
24
24
  //#endregion
25
25
  //#region src/cache/memory.d.ts
26
26
  interface MemoryCacheStoreOptions {
27
- /** Default TTL in milliseconds (default: 60_000) */
28
- defaultTtlMs?: number;
27
+ /** Default TTL in seconds (default: 60) */
28
+ defaultTtlSeconds?: number;
29
29
  /** Hard upper bound for entries (default: 1000) */
30
30
  maxEntries?: number;
31
31
  /** Background cleanup interval in milliseconds (default: 30_000) */
@@ -59,7 +59,7 @@ interface MemoryCacheStoreOptions {
59
59
  declare class MemoryCacheStore<TValue = unknown> implements CacheStore<TValue> {
60
60
  readonly name = "memory-cache";
61
61
  private readonly cache;
62
- private readonly defaultTtlMs;
62
+ private readonly defaultTtlSeconds;
63
63
  private readonly maxEntries;
64
64
  private readonly maxEntryBytes;
65
65
  private readonly maxMemoryBytes;
@@ -72,9 +72,9 @@ declare class MemoryCacheStore<TValue = unknown> implements CacheStore<TValue> {
72
72
  private _evictions;
73
73
  constructor(options?: MemoryCacheStoreOptions);
74
74
  get(key: string): Promise<TValue | undefined>;
75
- set(key: string, value: TValue, options?: CacheSetOptions): Promise<void>;
75
+ set(key: string, value: TValue, ttlSeconds?: number): Promise<void>;
76
76
  delete(key: string): Promise<void>;
77
- clear(): Promise<void>;
77
+ clear(pattern?: string): Promise<void>;
78
78
  close(): Promise<void>;
79
79
  stats(): CacheStats;
80
80
  private removeEntry;
@@ -112,8 +112,8 @@ interface RedisCacheStoreOptions {
112
112
  client: RedisCacheClient;
113
113
  /** Key prefix for namespacing (default: 'arc:cache:') */
114
114
  prefix?: string;
115
- /** Default TTL in milliseconds (default: 60_000) */
116
- defaultTtlMs?: number;
115
+ /** Default TTL in seconds (default: 60) */
116
+ defaultTtlSeconds?: number;
117
117
  /** Maximum serialized entry size in bytes. Oversized entries are skipped. */
118
118
  maxEntryBytes?: number;
119
119
  }
@@ -126,17 +126,19 @@ declare class RedisCacheStore<TValue = unknown> implements CacheStore<TValue> {
126
126
  readonly name = "redis-cache";
127
127
  private readonly client;
128
128
  private readonly prefix;
129
- private readonly defaultTtlMs;
129
+ private readonly defaultTtlSeconds;
130
130
  private readonly maxEntryBytes;
131
131
  private _hits;
132
132
  private _misses;
133
133
  constructor(options: RedisCacheStoreOptions);
134
134
  get(key: string): Promise<TValue | undefined>;
135
- set(key: string, value: TValue, options?: CacheSetOptions): Promise<void>;
135
+ set(key: string, value: TValue, ttlSeconds?: number): Promise<void>;
136
136
  delete(key: string): Promise<void>;
137
- clear(): Promise<void>;
138
- /** Delete all keys matching `this.prefix + prefix + *`. Returns count deleted. */
139
- deleteByPrefix(prefix: string): Promise<number>;
137
+ /**
138
+ * Invalidate keys. Pass a glob pattern to delete a subset (`user:*:v2`);
139
+ * omit to clear every key under this store's prefix.
140
+ */
141
+ clear(pattern?: string): Promise<void>;
140
142
  stats(): CacheStats;
141
143
  private scanAndDelete;
142
144
  private withPrefix;
@@ -212,4 +214,4 @@ interface UpstashRedisLike {
212
214
  */
213
215
  declare function upstashAsCacheClient(client: UpstashRedisLike): RedisCacheClient;
214
216
  //#endregion
215
- export { type CacheEnvelope, type CacheLogger, type CacheResult, type CacheSetOptions, type CacheStats, type CacheStatus, type CacheStore, type CrossResourceRule, type IoredisLike, MemoryCacheStore, type MemoryCacheStoreOptions, QueryCache, type QueryCacheConfig, type QueryCacheDefaults, type QueryCachePluginOptions, type RedisCacheClient, RedisCacheStore, type RedisCacheStoreOptions, type RedisPipeline, type UpstashRedisLike, buildQueryKey, hashParams, ioredisAsCacheClient, queryCachePlugin, tagVersionKey, upstashAsCacheClient, versionKey };
217
+ export { type CacheEnvelope, type CacheLogger, type CacheResult, type CacheStats, type CacheStatus, type CacheStore, type CrossResourceRule, type IoredisLike, MemoryCacheStore, type MemoryCacheStoreOptions, QueryCache, type QueryCacheConfig, type QueryCacheDefaults, type QueryCachePluginOptions, type RedisCacheClient, RedisCacheStore, type RedisCacheStoreOptions, type RedisPipeline, type UpstashRedisLike, buildQueryKey, hashParams, ioredisAsCacheClient, queryCachePlugin, tagVersionKey, upstashAsCacheClient, versionKey };
@@ -1,6 +1,6 @@
1
1
  import { i as versionKey, n as hashParams, r as tagVersionKey, t as buildQueryKey } from "../keys-qcD-TVJl.mjs";
2
- import { t as MemoryCacheStore } from "../memory-Cp7_cAko.mjs";
3
- import { r as QueryCache, t as queryCachePlugin } from "../queryCachePlugin-BH-fidlv.mjs";
2
+ import { t as MemoryCacheStore } from "../memory-B5Amv9A1.mjs";
3
+ import { r as QueryCache, t as queryCachePlugin } from "../queryCachePlugin-DQCEfJis.mjs";
4
4
  //#region src/cache/redis.ts
5
5
  /**
6
6
  * Redis-backed cache store.
@@ -11,14 +11,14 @@ var RedisCacheStore = class {
11
11
  name = "redis-cache";
12
12
  client;
13
13
  prefix;
14
- defaultTtlMs;
14
+ defaultTtlSeconds;
15
15
  maxEntryBytes;
16
16
  _hits = 0;
17
17
  _misses = 0;
18
18
  constructor(options) {
19
19
  this.client = options.client;
20
20
  this.prefix = options.prefix ?? "arc:cache:";
21
- this.defaultTtlMs = options.defaultTtlMs ?? 6e4;
21
+ this.defaultTtlSeconds = options.defaultTtlSeconds ?? 60;
22
22
  this.maxEntryBytes = options.maxEntryBytes ?? 0;
23
23
  }
24
24
  async get(key) {
@@ -36,22 +36,23 @@ var RedisCacheStore = class {
36
36
  return;
37
37
  }
38
38
  }
39
- async set(key, value, options = {}) {
40
- const ttlMs = options.ttlMs ?? this.defaultTtlMs;
41
- if (!Number.isFinite(ttlMs) || ttlMs <= 0) return;
39
+ async set(key, value, ttlSeconds) {
40
+ const effectiveTtlSeconds = ttlSeconds ?? this.defaultTtlSeconds;
41
+ if (!Number.isFinite(effectiveTtlSeconds) || effectiveTtlSeconds <= 0) return;
42
42
  const payload = JSON.stringify(value);
43
43
  if (this.maxEntryBytes > 0 && Buffer.byteLength(payload, "utf8") > this.maxEntryBytes) return;
44
- await this.client.set(this.withPrefix(key), payload, { PX: Math.ceil(ttlMs) });
44
+ await this.client.set(this.withPrefix(key), payload, { EX: Math.ceil(effectiveTtlSeconds) });
45
45
  }
46
46
  async delete(key) {
47
47
  await this.client.del(this.withPrefix(key));
48
48
  }
49
- async clear() {
50
- await this.scanAndDelete(`${this.prefix}*`);
51
- }
52
- /** Delete all keys matching `this.prefix + prefix + *`. Returns count deleted. */
53
- async deleteByPrefix(prefix) {
54
- return this.scanAndDelete(`${this.prefix}${prefix}*`);
49
+ /**
50
+ * Invalidate keys. Pass a glob pattern to delete a subset (`user:*:v2`);
51
+ * omit to clear every key under this store's prefix.
52
+ */
53
+ async clear(pattern) {
54
+ const scanPattern = pattern ? `${this.prefix}${pattern.includes("*") ? pattern : `${pattern}*`}` : `${this.prefix}*`;
55
+ await this.scanAndDelete(scanPattern);
55
56
  }
56
57
  stats() {
57
58
  return {
@@ -34,7 +34,7 @@ const cachingPlugin = async (fastify, opts = {}) => {
34
34
  if (rule?.staleWhileRevalidate) parts.push(`stale-while-revalidate=${rule.staleWhileRevalidate}`);
35
35
  return parts.join(", ");
36
36
  }
37
- fastify.addHook("onSend", async (request, reply, payload) => {
37
+ fastify.addHook("preSerialization", async (request, reply, payload) => {
38
38
  const url = request.url;
39
39
  if (exclude.some((p) => url.startsWith(p))) return payload;
40
40
  const method = request.method.toUpperCase();
@@ -48,13 +48,18 @@ const cachingPlugin = async (fastify, opts = {}) => {
48
48
  const rule = findRule(url);
49
49
  reply.header("cache-control", buildCacheControl(rule));
50
50
  }
51
- if (etag && payload) {
52
- const tag = `"${fnv1a(typeof payload === "string" ? payload : String(payload))}"`;
51
+ if (etag && payload != null) {
52
+ let body;
53
+ if (typeof payload === "string") body = payload;
54
+ else if (Buffer.isBuffer(payload)) body = payload.toString("utf-8");
55
+ else body = JSON.stringify(payload);
56
+ const tag = `"${fnv1a(body)}"`;
53
57
  reply.header("etag", tag);
54
58
  if (conditional) {
55
59
  const ifNoneMatch = request.headers["if-none-match"];
56
60
  if (ifNoneMatch && ifNoneMatch === tag) {
57
61
  reply.code(304);
62
+ reply.serializer((p) => typeof p === "string" ? p : "");
58
63
  return "";
59
64
  }
60
65
  }