@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.
- package/.turbo/turbo-lint.log +7 -0
- package/.turbo/turbo-test.log +1376 -0
- package/README.md +419 -0
- package/package.json +29 -0
- package/src/auth/index.js +127 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +90 -0
- package/src/authorization.js +235 -0
- package/src/authorization.test.js +314 -0
- package/src/cache.js +137 -0
- package/src/cache.test.js +217 -0
- package/src/csrf.js +118 -0
- package/src/csrf.test.js +157 -0
- package/src/email.js +140 -0
- package/src/email.test.js +259 -0
- package/src/error-reporter.js +266 -0
- package/src/error-reporter.test.js +236 -0
- package/src/errors.js +192 -0
- package/src/errors.test.js +128 -0
- package/src/index.js +32 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mailhog.js +43 -0
- package/src/queue/index.js +214 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +96 -0
- package/src/testing/factories.js +93 -0
- package/src/testing/helpers.js +266 -0
- package/src/testing/index.js +46 -0
- package/src/testing/mocks.js +480 -0
- package/src/testing/testing.test.js +543 -0
- package/src/types.js +74 -0
- package/src/utils.js +219 -0
- package/src/utils.test.js +193 -0
- package/src/validation.js +26 -0
- package/test-imports.mjs +21 -0
|
@@ -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
|
+
}
|