@techstream/quark-core 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @techstream/quark-core - Authorization Module
3
+ * Policy-based, schema-agnostic RBAC engine
4
+ */
5
+
6
+ import { ForbiddenError } from "./errors.js";
7
+
8
+ /**
9
+ * @typedef {{ resource: string, action: string }} Permission
10
+ * @typedef {{ permissions: Permission[] }} RoleDefinition
11
+ * @typedef {{
12
+ * roles: Record<string, RoleDefinition>,
13
+ * defaultRole: string,
14
+ * ownershipField: string,
15
+ * ownerActions: string[]
16
+ * }} Policy
17
+ * @typedef {{ id: string, role?: string }} AuthUser
18
+ */
19
+
20
+ /**
21
+ * Default policy shipped with Quark — easily overridable by downstream projects.
22
+ * @type {Policy}
23
+ */
24
+ export const defaultPolicy = {
25
+ roles: {
26
+ admin: {
27
+ permissions: [{ resource: "*", action: "*" }],
28
+ },
29
+ editor: {
30
+ permissions: [
31
+ { resource: "post", action: "*" },
32
+ { resource: "user", action: "read" },
33
+ ],
34
+ },
35
+ viewer: {
36
+ permissions: [
37
+ { resource: "post", action: "read" },
38
+ { resource: "user", action: "read" },
39
+ ],
40
+ },
41
+ },
42
+ defaultRole: "viewer",
43
+ ownershipField: "ownerId",
44
+ ownerActions: ["update", "delete"],
45
+ };
46
+
47
+ /**
48
+ * Creates an authorization instance from a policy configuration.
49
+ * @param {Policy} [policy] - The policy to use (defaults to `defaultPolicy`)
50
+ * @returns {{
51
+ * can: (user: AuthUser, action: string, resource: string, context?: Record<string, unknown>) => boolean,
52
+ * authorize: (user: AuthUser, action: string, resource: string, context?: Record<string, unknown>) => void,
53
+ * hasPermission: (role: string, action: string, resource: string) => boolean,
54
+ * getRolePermissions: (role: string) => Permission[],
55
+ * addRole: (name: string, permissions: Permission[]) => void,
56
+ * removeRole: (name: string) => void,
57
+ * extendPolicy: (policyExtension: Partial<Policy>) => void
58
+ * }}
59
+ */
60
+ export const createAuthorization = (policy = defaultPolicy) => {
61
+ const _policy = structuredClone(policy);
62
+
63
+ /**
64
+ * Checks whether a role has a specific permission, respecting wildcards.
65
+ * @param {string} role - Role name
66
+ * @param {string} action - Action to check (e.g. "read", "update")
67
+ * @param {string} resource - Resource to check (e.g. "post", "user")
68
+ * @returns {boolean}
69
+ */
70
+ const hasPermission = (role, action, resource) => {
71
+ const roleDef = _policy.roles[role];
72
+ if (!roleDef) return false;
73
+
74
+ return roleDef.permissions.some((perm) => {
75
+ const resourceMatch = perm.resource === "*" || perm.resource === resource;
76
+ const actionMatch = perm.action === "*" || perm.action === action;
77
+ return resourceMatch && actionMatch;
78
+ });
79
+ };
80
+
81
+ /**
82
+ * Returns the permissions array for a given role.
83
+ * @param {string} role - Role name
84
+ * @returns {Permission[]} Array of permissions, or empty array if role not found
85
+ */
86
+ const getRolePermissions = (role) => {
87
+ const roleDef = _policy.roles[role];
88
+ return roleDef ? [...roleDef.permissions] : [];
89
+ };
90
+
91
+ /**
92
+ * Checks whether a user is allowed to perform an action on a resource.
93
+ * Supports role-based permissions and ownership checks.
94
+ * @param {AuthUser} user - User object with `id` and optional `role`
95
+ * @param {string} action - Action to perform
96
+ * @param {string} resource - Target resource
97
+ * @param {Record<string, unknown>} [context] - Optional context for ownership checks
98
+ * @returns {boolean}
99
+ */
100
+ const can = (user, action, resource, context) => {
101
+ const role = user.role || _policy.defaultRole;
102
+
103
+ if (hasPermission(role, action, resource)) {
104
+ return true;
105
+ }
106
+
107
+ // Ownership check
108
+ if (
109
+ context &&
110
+ _policy.ownerActions.includes(action) &&
111
+ context[_policy.ownershipField] != null &&
112
+ context[_policy.ownershipField] === user.id
113
+ ) {
114
+ return true;
115
+ }
116
+
117
+ return false;
118
+ };
119
+
120
+ /**
121
+ * Same as `can()` but throws `ForbiddenError` when access is denied.
122
+ * @param {AuthUser} user - User object with `id` and optional `role`
123
+ * @param {string} action - Action to perform
124
+ * @param {string} resource - Target resource
125
+ * @param {Record<string, unknown>} [context] - Optional context for ownership checks
126
+ * @throws {ForbiddenError}
127
+ */
128
+ const authorize = (user, action, resource, context) => {
129
+ if (!can(user, action, resource, context)) {
130
+ throw new ForbiddenError(
131
+ `User ${user.id} is not allowed to ${action} ${resource}`,
132
+ );
133
+ }
134
+ };
135
+
136
+ /**
137
+ * Dynamically adds a role to the current policy.
138
+ * @param {string} name - Role name
139
+ * @param {Permission[]} permissions - Permissions for the role
140
+ */
141
+ const addRole = (name, permissions) => {
142
+ _policy.roles[name] = { permissions: [...permissions] };
143
+ };
144
+
145
+ /**
146
+ * Removes a role from the current policy.
147
+ * @param {string} name - Role name to remove
148
+ */
149
+ const removeRole = (name) => {
150
+ delete _policy.roles[name];
151
+ };
152
+
153
+ /**
154
+ * Merges additional roles and settings into the current policy.
155
+ * @param {Partial<Policy>} policyExtension - Partial policy to merge
156
+ */
157
+ const extendPolicy = (policyExtension) => {
158
+ if (policyExtension.roles) {
159
+ for (const [name, roleDef] of Object.entries(policyExtension.roles)) {
160
+ _policy.roles[name] = structuredClone(roleDef);
161
+ }
162
+ }
163
+ if (policyExtension.defaultRole !== undefined) {
164
+ _policy.defaultRole = policyExtension.defaultRole;
165
+ }
166
+ if (policyExtension.ownershipField !== undefined) {
167
+ _policy.ownershipField = policyExtension.ownershipField;
168
+ }
169
+ if (policyExtension.ownerActions !== undefined) {
170
+ _policy.ownerActions = [...policyExtension.ownerActions];
171
+ }
172
+ };
173
+
174
+ return {
175
+ can,
176
+ authorize,
177
+ hasPermission,
178
+ getRolePermissions,
179
+ addRole,
180
+ removeRole,
181
+ extendPolicy,
182
+ };
183
+ };
184
+
185
+ /** Default authorization instance using the default policy. */
186
+ export const authorization = createAuthorization();
187
+
188
+ /**
189
+ * Higher-order function that returns a guard checking if the session user
190
+ * has one of the specified roles. Throws `ForbiddenError` if not.
191
+ * @param {...string} roles - Allowed role names
192
+ * @returns {(session: { user: AuthUser }) => void}
193
+ */
194
+ export const requireRole = (...roles) => {
195
+ /**
196
+ * @param {{ user: AuthUser }} session
197
+ * @throws {ForbiddenError}
198
+ */
199
+ return (session) => {
200
+ const userRole = session?.user?.role;
201
+ if (!userRole || !roles.includes(userRole)) {
202
+ throw new ForbiddenError(
203
+ `Role ${userRole || "none"} is not allowed. Required: ${roles.join(", ")}`,
204
+ );
205
+ }
206
+ };
207
+ };
208
+
209
+ /**
210
+ * Higher-order wrapper for API route handlers that enforces authorization
211
+ * before invoking the handler.
212
+ * @param {Function} handler - The route handler to wrap
213
+ * @param {{
214
+ * action: string,
215
+ * resource: string,
216
+ * getContext?: (req: unknown, session: { user: AuthUser }) => Record<string, unknown> | Promise<Record<string, unknown>>
217
+ * }} options - Authorization options
218
+ * @returns {Function} Wrapped handler
219
+ */
220
+ export const withAuthorization = (
221
+ handler,
222
+ { action, resource, getContext },
223
+ ) => {
224
+ return async (req, session) => {
225
+ const user = session?.user;
226
+ if (!user) {
227
+ throw new ForbiddenError("No user in session");
228
+ }
229
+
230
+ const context = getContext ? await getContext(req, session) : undefined;
231
+ authorization.authorize(user, action, resource, context);
232
+
233
+ return handler(req, session);
234
+ };
235
+ };
@@ -0,0 +1,314 @@
1
+ import assert from "node:assert/strict";
2
+ import { beforeEach, describe, it } from "node:test";
3
+ import {
4
+ authorization,
5
+ createAuthorization,
6
+ defaultPolicy,
7
+ requireRole,
8
+ withAuthorization,
9
+ } from "./authorization.js";
10
+ import { ForbiddenError } from "./errors.js";
11
+
12
+ describe("authorization", () => {
13
+ /** @type {ReturnType<typeof createAuthorization>} */
14
+ let auth;
15
+
16
+ beforeEach(() => {
17
+ auth = createAuthorization();
18
+ });
19
+
20
+ describe("can()", () => {
21
+ it("returns true for admin with wildcard permissions", () => {
22
+ const user = { id: "u1", role: "admin" };
23
+ assert.equal(auth.can(user, "create", "post"), true);
24
+ assert.equal(auth.can(user, "delete", "user"), true);
25
+ assert.equal(auth.can(user, "anything", "anywhere"), true);
26
+ });
27
+
28
+ it("returns true for editor with post actions", () => {
29
+ const user = { id: "u2", role: "editor" };
30
+ assert.equal(auth.can(user, "create", "post"), true);
31
+ assert.equal(auth.can(user, "update", "post"), true);
32
+ assert.equal(auth.can(user, "delete", "post"), true);
33
+ assert.equal(auth.can(user, "read", "post"), true);
34
+ });
35
+
36
+ it("returns true for editor reading users", () => {
37
+ const user = { id: "u2", role: "editor" };
38
+ assert.equal(auth.can(user, "read", "user"), true);
39
+ });
40
+
41
+ it("returns false for editor trying to delete users", () => {
42
+ const user = { id: "u2", role: "editor" };
43
+ assert.equal(auth.can(user, "delete", "user"), false);
44
+ });
45
+
46
+ it("returns false for viewer trying to write", () => {
47
+ const user = { id: "u3", role: "viewer" };
48
+ assert.equal(auth.can(user, "create", "post"), false);
49
+ assert.equal(auth.can(user, "update", "post"), false);
50
+ assert.equal(auth.can(user, "delete", "post"), false);
51
+ });
52
+
53
+ it("returns true for viewer reading posts and users", () => {
54
+ const user = { id: "u3", role: "viewer" };
55
+ assert.equal(auth.can(user, "read", "post"), true);
56
+ assert.equal(auth.can(user, "read", "user"), true);
57
+ });
58
+
59
+ it("owner can update their own resource", () => {
60
+ const user = { id: "u3", role: "viewer" };
61
+ const context = { ownerId: "u3" };
62
+ assert.equal(auth.can(user, "update", "post", context), true);
63
+ });
64
+
65
+ it("owner can delete their own resource", () => {
66
+ const user = { id: "u3", role: "viewer" };
67
+ const context = { ownerId: "u3" };
68
+ assert.equal(auth.can(user, "delete", "post", context), true);
69
+ });
70
+
71
+ it("non-owner cannot update another user's resource", () => {
72
+ const user = { id: "u3", role: "viewer" };
73
+ const context = { ownerId: "u999" };
74
+ assert.equal(auth.can(user, "update", "post", context), false);
75
+ });
76
+
77
+ it("ownership does not apply for non-owner actions", () => {
78
+ const user = { id: "u3", role: "viewer" };
79
+ const context = { ownerId: "u3" };
80
+ // "create" is not in ownerActions by default
81
+ assert.equal(auth.can(user, "create", "post", context), false);
82
+ });
83
+
84
+ it("applies defaultRole when user has no role", () => {
85
+ const user = { id: "u4" };
86
+ // defaultRole is "viewer" — can read but not create
87
+ assert.equal(auth.can(user, "read", "post"), true);
88
+ assert.equal(auth.can(user, "create", "post"), false);
89
+ });
90
+ });
91
+
92
+ describe("authorize()", () => {
93
+ it("throws ForbiddenError when denied", () => {
94
+ const user = { id: "u3", role: "viewer" };
95
+ assert.throws(
96
+ () => auth.authorize(user, "create", "post"),
97
+ (err) => err instanceof ForbiddenError,
98
+ );
99
+ });
100
+
101
+ it("does not throw when allowed", () => {
102
+ const user = { id: "u1", role: "admin" };
103
+ assert.doesNotThrow(() => auth.authorize(user, "create", "post"));
104
+ });
105
+
106
+ it("includes user and action information in error message", () => {
107
+ const user = { id: "u3", role: "viewer" };
108
+ assert.throws(() => auth.authorize(user, "create", "post"), {
109
+ message: "User u3 is not allowed to create post",
110
+ });
111
+ });
112
+ });
113
+
114
+ describe("hasPermission()", () => {
115
+ it("returns true for exact match", () => {
116
+ assert.equal(auth.hasPermission("viewer", "read", "post"), true);
117
+ assert.equal(auth.hasPermission("viewer", "read", "user"), true);
118
+ });
119
+
120
+ it("returns false for no match", () => {
121
+ assert.equal(auth.hasPermission("viewer", "create", "post"), false);
122
+ });
123
+
124
+ it("returns true with resource wildcard", () => {
125
+ assert.equal(auth.hasPermission("admin", "create", "anything"), true);
126
+ });
127
+
128
+ it("returns true with action wildcard", () => {
129
+ assert.equal(auth.hasPermission("editor", "delete", "post"), true);
130
+ });
131
+
132
+ it("returns false for unknown role", () => {
133
+ assert.equal(auth.hasPermission("unknown", "read", "post"), false);
134
+ });
135
+ });
136
+
137
+ describe("getRolePermissions()", () => {
138
+ it("returns permissions for a valid role", () => {
139
+ const perms = auth.getRolePermissions("viewer");
140
+ assert.deepStrictEqual(perms, [
141
+ { resource: "post", action: "read" },
142
+ { resource: "user", action: "read" },
143
+ ]);
144
+ });
145
+
146
+ it("returns empty array for unknown role", () => {
147
+ const perms = auth.getRolePermissions("nonexistent");
148
+ assert.deepStrictEqual(perms, []);
149
+ });
150
+ });
151
+
152
+ describe("addRole() and removeRole()", () => {
153
+ it("addRole() makes new role available", () => {
154
+ auth.addRole("moderator", [
155
+ { resource: "post", action: "update" },
156
+ { resource: "post", action: "delete" },
157
+ ]);
158
+ const user = { id: "u5", role: "moderator" };
159
+ assert.equal(auth.can(user, "update", "post"), true);
160
+ assert.equal(auth.can(user, "delete", "post"), true);
161
+ assert.equal(auth.can(user, "create", "post"), false);
162
+ });
163
+
164
+ it("removeRole() removes a role", () => {
165
+ auth.removeRole("editor");
166
+ assert.deepStrictEqual(auth.getRolePermissions("editor"), []);
167
+ assert.equal(auth.hasPermission("editor", "read", "post"), false);
168
+ });
169
+ });
170
+
171
+ describe("extendPolicy()", () => {
172
+ it("merges new roles into existing policy", () => {
173
+ auth.extendPolicy({
174
+ roles: {
175
+ supermod: {
176
+ permissions: [{ resource: "*", action: "delete" }],
177
+ },
178
+ },
179
+ });
180
+
181
+ const user = { id: "u6", role: "supermod" };
182
+ assert.equal(auth.can(user, "delete", "post"), true);
183
+ assert.equal(auth.can(user, "delete", "user"), true);
184
+ assert.equal(auth.can(user, "create", "post"), false);
185
+ });
186
+
187
+ it("preserves existing roles when extending", () => {
188
+ auth.extendPolicy({
189
+ roles: {
190
+ custom: {
191
+ permissions: [{ resource: "report", action: "read" }],
192
+ },
193
+ },
194
+ });
195
+ // Original roles still work
196
+ assert.equal(auth.hasPermission("admin", "create", "post"), true);
197
+ assert.equal(auth.hasPermission("viewer", "read", "post"), true);
198
+ });
199
+
200
+ it("can override defaultRole", () => {
201
+ auth.extendPolicy({ defaultRole: "editor" });
202
+ const user = { id: "u7" };
203
+ assert.equal(auth.can(user, "create", "post"), true);
204
+ });
205
+ });
206
+
207
+ describe("requireRole()", () => {
208
+ it("does not throw when session user has an allowed role", () => {
209
+ const guard = requireRole("admin", "editor");
210
+ const session = { user: { id: "u1", role: "admin" } };
211
+ assert.doesNotThrow(() => guard(session));
212
+ });
213
+
214
+ it("throws ForbiddenError when session user role is not allowed", () => {
215
+ const guard = requireRole("admin");
216
+ const session = { user: { id: "u2", role: "viewer" } };
217
+ assert.throws(
218
+ () => guard(session),
219
+ (err) => err instanceof ForbiddenError,
220
+ );
221
+ });
222
+
223
+ it("throws ForbiddenError when session has no role", () => {
224
+ const guard = requireRole("admin");
225
+ const session = { user: { id: "u2" } };
226
+ assert.throws(
227
+ () => guard(session),
228
+ (err) => err instanceof ForbiddenError,
229
+ );
230
+ });
231
+
232
+ it("throws ForbiddenError when session has no user", () => {
233
+ const guard = requireRole("admin");
234
+ assert.throws(
235
+ () => guard({}),
236
+ (err) => err instanceof ForbiddenError,
237
+ );
238
+ });
239
+ });
240
+
241
+ describe("withAuthorization()", () => {
242
+ it("calls handler when authorized", async () => {
243
+ let called = false;
244
+ const handler = (_req, _session) => {
245
+ called = true;
246
+ return "ok";
247
+ };
248
+ const wrapped = withAuthorization(handler, {
249
+ action: "read",
250
+ resource: "post",
251
+ });
252
+ const session = { user: { id: "u1", role: "admin" } };
253
+ const result = await wrapped({}, session);
254
+ assert.equal(called, true);
255
+ assert.equal(result, "ok");
256
+ });
257
+
258
+ it("throws ForbiddenError when not authorized", async () => {
259
+ const handler = () => "ok";
260
+ const wrapped = withAuthorization(handler, {
261
+ action: "create",
262
+ resource: "post",
263
+ });
264
+ const session = { user: { id: "u3", role: "viewer" } };
265
+ await assert.rejects(() => wrapped({}, session), ForbiddenError);
266
+ });
267
+
268
+ it("throws ForbiddenError when no user in session", async () => {
269
+ const handler = () => "ok";
270
+ const wrapped = withAuthorization(handler, {
271
+ action: "read",
272
+ resource: "post",
273
+ });
274
+ await assert.rejects(() => wrapped({}, {}), ForbiddenError);
275
+ });
276
+
277
+ it("passes context from getContext to authorization", async () => {
278
+ const handler = () => "ok";
279
+ const wrapped = withAuthorization(handler, {
280
+ action: "update",
281
+ resource: "post",
282
+ getContext: () => ({ ownerId: "owner1" }),
283
+ });
284
+ // viewer can update own resource via ownership
285
+ const session = { user: { id: "owner1", role: "viewer" } };
286
+ const result = await wrapped({}, session);
287
+ assert.equal(result, "ok");
288
+ });
289
+ });
290
+
291
+ describe("default instance", () => {
292
+ it("exports a working default authorization instance", () => {
293
+ assert.equal(
294
+ authorization.can({ id: "u1", role: "admin" }, "create", "post"),
295
+ true,
296
+ );
297
+ assert.equal(
298
+ authorization.can({ id: "u1", role: "viewer" }, "create", "post"),
299
+ false,
300
+ );
301
+ });
302
+ });
303
+
304
+ describe("defaultPolicy", () => {
305
+ it("is exported and has expected structure", () => {
306
+ assert.ok(defaultPolicy.roles.admin);
307
+ assert.ok(defaultPolicy.roles.editor);
308
+ assert.ok(defaultPolicy.roles.viewer);
309
+ assert.equal(defaultPolicy.defaultRole, "viewer");
310
+ assert.equal(defaultPolicy.ownershipField, "ownerId");
311
+ assert.deepStrictEqual(defaultPolicy.ownerActions, ["update", "delete"]);
312
+ });
313
+ });
314
+ });
package/src/cache.js ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Cache utility for Redis-based caching with TTL and invalidation.
3
+ * Designed to be used with any Redis client (ioredis, redis, etc.)
4
+ *
5
+ * @example
6
+ * ```js
7
+ * import Redis from "ioredis";
8
+ * import { createCache } from "@quark/core";
9
+ *
10
+ * const redis = new Redis();
11
+ * const cache = createCache(redis, { prefix: "app:", defaultTTL: 600 });
12
+ *
13
+ * await cache.set("user:1", { name: "Alice" });
14
+ * const user = await cache.get("user:1");
15
+ * ```
16
+ */
17
+
18
+ /**
19
+ * Creates a cache instance backed by a Redis client.
20
+ *
21
+ * @param {import("ioredis").Redis} redisClient — any Redis client with get/set/del/keys/expire methods
22
+ * @param {Object} [options]
23
+ * @param {string} [options.prefix="cache:"] — key prefix for namespacing
24
+ * @param {number} [options.defaultTTL=300] — default TTL in seconds (5 minutes)
25
+ * @returns {ReturnType<typeof createCache>}
26
+ */
27
+ export function createCache(redisClient, options = {}) {
28
+ const { prefix = "cache:", defaultTTL = 300 } = options;
29
+
30
+ return {
31
+ /**
32
+ * Get a cached value.
33
+ *
34
+ * @param {string} key
35
+ * @returns {Promise<any|null>} Parsed value or null if not found
36
+ */
37
+ async get(key) {
38
+ const raw = await redisClient.get(`${prefix}${key}`);
39
+ if (raw === null || raw === undefined) return null;
40
+ try {
41
+ return JSON.parse(raw);
42
+ } catch {
43
+ return null;
44
+ }
45
+ },
46
+
47
+ /**
48
+ * Set a cached value with optional TTL (atomic SET + EX).
49
+ *
50
+ * @param {string} key
51
+ * @param {any} value — will be JSON.stringified
52
+ * @param {number} [ttl] — TTL in seconds, defaults to defaultTTL
53
+ */
54
+ async set(key, value, ttl) {
55
+ const prefixedKey = `${prefix}${key}`;
56
+ const serialized = JSON.stringify(value);
57
+ await redisClient.set(prefixedKey, serialized, "EX", ttl ?? defaultTTL);
58
+ },
59
+
60
+ /**
61
+ * Delete a cached key.
62
+ *
63
+ * @param {string} key
64
+ */
65
+ async del(key) {
66
+ await redisClient.del(`${prefix}${key}`);
67
+ },
68
+
69
+ /**
70
+ * Delete all keys matching a pattern using SCAN (non-blocking, production-safe).
71
+ *
72
+ * @param {string} pattern — e.g., "user:*"
73
+ */
74
+ async invalidate(pattern) {
75
+ const matchPattern = `${prefix}${pattern}`;
76
+ let cursor = "0";
77
+ do {
78
+ const [nextCursor, keys] = await redisClient.scan(
79
+ cursor,
80
+ "MATCH",
81
+ matchPattern,
82
+ "COUNT",
83
+ 100,
84
+ );
85
+ cursor = nextCursor;
86
+ if (keys.length > 0) {
87
+ await Promise.all(keys.map((k) => redisClient.del(k)));
88
+ }
89
+ } while (cursor !== "0");
90
+ },
91
+
92
+ /**
93
+ * Get-or-set pattern: returns cached value if it exists,
94
+ * otherwise calls factory, caches the result, and returns it.
95
+ *
96
+ * @param {string} key
97
+ * @param {Function} factory — async function that produces the value
98
+ * @param {number} [ttl] — TTL in seconds, defaults to defaultTTL
99
+ * @returns {Promise<any>}
100
+ */
101
+ async getOrSet(key, factory, ttl) {
102
+ const cached = await this.get(key);
103
+ if (cached !== null) return cached;
104
+
105
+ const value = await factory();
106
+ await this.set(key, value, ttl);
107
+ return value;
108
+ },
109
+
110
+ /**
111
+ * Wrap a function with caching. Returns a new function that
112
+ * caches results based on argument serialisation.
113
+ *
114
+ * @param {Function} fn — the function to wrap
115
+ * @param {Object} [wrapOptions]
116
+ * @param {string} [wrapOptions.keyPrefix] — prefix for generated cache keys
117
+ * @param {number} [wrapOptions.ttl] — TTL in seconds
118
+ * @param {Function} [wrapOptions.keyGenerator] — custom key generator `(...args) => string`
119
+ * @returns {Function}
120
+ */
121
+ wrap(fn, wrapOptions = {}) {
122
+ const {
123
+ keyPrefix = fn.name || "wrapped",
124
+ ttl,
125
+ keyGenerator,
126
+ } = wrapOptions;
127
+
128
+ return async (...args) => {
129
+ const cacheKey = keyGenerator
130
+ ? `${keyPrefix}:${keyGenerator(...args)}`
131
+ : `${keyPrefix}:${JSON.stringify(args)}`;
132
+
133
+ return this.getOrSet(cacheKey, () => fn(...args), ttl);
134
+ };
135
+ },
136
+ };
137
+ }