@usequark/quark-core 2.5.4
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/README.md +63 -0
- package/package.json +80 -0
- package/src/admin-auth.js +121 -0
- package/src/admin.js +352 -0
- package/src/admin.test.js +257 -0
- package/src/auth/index.js +160 -0
- package/src/auth/middleware.js +106 -0
- package/src/auth/middleware.test.js +116 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +235 -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/core.js +32 -0
- package/src/csrf.js +122 -0
- package/src/csrf.test.js +169 -0
- package/src/db-instrumentation.js +281 -0
- package/src/db-instrumentation.test.js +284 -0
- package/src/db-metrics.js +26 -0
- package/src/db.js +218 -0
- package/src/db.test.js +365 -0
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +314 -0
- package/src/email.test.js +498 -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/exports.test.js +154 -0
- package/src/file-validation.js +249 -0
- package/src/file-validation.test.js +207 -0
- package/src/index.js +58 -0
- package/src/locale.js +21 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mail.js +50 -0
- package/src/metrics.js +379 -0
- package/src/metrics.test.js +380 -0
- package/src/multipart.js +144 -0
- package/src/multipart.test.js +188 -0
- package/src/pagination.js +139 -0
- package/src/pagination.test.js +210 -0
- package/src/query-builder.js +183 -0
- package/src/query-builder.test.js +234 -0
- package/src/queue/index.js +446 -0
- package/src/queue/queue.test.js +365 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +154 -0
- package/src/redis.test.js +150 -0
- package/src/request-logger.js +211 -0
- package/src/request-logger.test.js +286 -0
- package/src/sms.js +208 -0
- package/src/storage-s3.js +236 -0
- package/src/storage.js +214 -0
- package/src/storage.test.js +457 -0
- package/src/stripe.js +89 -0
- package/src/testing/factories.js +89 -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/src/auth.test.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
createAuthConfig,
|
|
5
|
+
getCurrentSession,
|
|
6
|
+
getUserEmail,
|
|
7
|
+
getUserId,
|
|
8
|
+
isAuthenticated,
|
|
9
|
+
requireAuth,
|
|
10
|
+
} from "../src/auth/index.js";
|
|
11
|
+
|
|
12
|
+
test("Auth Module", async (t) => {
|
|
13
|
+
await t.test("createAuthConfig returns valid config", () => {
|
|
14
|
+
const config = createAuthConfig({ secret: "test-secret" });
|
|
15
|
+
assert(config.session);
|
|
16
|
+
assert(config.session.strategy === "jwt");
|
|
17
|
+
assert(config.session.maxAge === 30 * 24 * 60 * 60);
|
|
18
|
+
assert.deepStrictEqual(config.providers, []);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
await t.test("createAuthConfig falls back to AUTH_SECRET", () => {
|
|
22
|
+
const nextAuthSecret = process.env.NEXTAUTH_SECRET;
|
|
23
|
+
const authSecret = process.env.AUTH_SECRET;
|
|
24
|
+
|
|
25
|
+
delete process.env.NEXTAUTH_SECRET;
|
|
26
|
+
process.env.AUTH_SECRET = "test-secret";
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const config = createAuthConfig();
|
|
30
|
+
assert.strictEqual(config.secret, "test-secret");
|
|
31
|
+
} finally {
|
|
32
|
+
if (nextAuthSecret !== undefined) {
|
|
33
|
+
process.env.NEXTAUTH_SECRET = nextAuthSecret;
|
|
34
|
+
} else {
|
|
35
|
+
delete process.env.NEXTAUTH_SECRET;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (authSecret !== undefined) {
|
|
39
|
+
process.env.AUTH_SECRET = authSecret;
|
|
40
|
+
} else {
|
|
41
|
+
delete process.env.AUTH_SECRET;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await t.test("createAuthConfig throws when secret is missing", () => {
|
|
47
|
+
const orig = process.env.NEXTAUTH_SECRET;
|
|
48
|
+
const authOrig = process.env.AUTH_SECRET;
|
|
49
|
+
delete process.env.NEXTAUTH_SECRET;
|
|
50
|
+
delete process.env.AUTH_SECRET;
|
|
51
|
+
try {
|
|
52
|
+
assert.throws(
|
|
53
|
+
() => createAuthConfig(),
|
|
54
|
+
(err) =>
|
|
55
|
+
err instanceof Error &&
|
|
56
|
+
/NEXTAUTH_SECRET|AUTH_SECRET/.test(err.message),
|
|
57
|
+
);
|
|
58
|
+
} finally {
|
|
59
|
+
if (orig !== undefined) process.env.NEXTAUTH_SECRET = orig;
|
|
60
|
+
if (authOrig !== undefined) process.env.AUTH_SECRET = authOrig;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await t.test("createAuthConfig with custom options", () => {
|
|
65
|
+
const customProvider = { id: "custom" };
|
|
66
|
+
const config = createAuthConfig({
|
|
67
|
+
providers: [customProvider],
|
|
68
|
+
secret: "test-secret",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.deepStrictEqual(config.providers, [customProvider]);
|
|
72
|
+
assert(config.secret === "test-secret");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await t.test("createAuthConfig sets trustHost=true on Railway", () => {
|
|
76
|
+
const key = "RAILWAY_SERVICE_ID";
|
|
77
|
+
const orig = process.env[key];
|
|
78
|
+
process.env[key] = "service-abc";
|
|
79
|
+
try {
|
|
80
|
+
const config = createAuthConfig({ secret: "test-secret" });
|
|
81
|
+
assert.equal(config.trustHost, true);
|
|
82
|
+
} finally {
|
|
83
|
+
if (orig !== undefined) process.env[key] = orig;
|
|
84
|
+
else delete process.env[key];
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await t.test("createAuthConfig ignores AUTH_TRUST_HOST=false", () => {
|
|
89
|
+
const key = "AUTH_TRUST_HOST";
|
|
90
|
+
const nodeEnvKey = "NODE_ENV";
|
|
91
|
+
const orig = process.env[key];
|
|
92
|
+
const origNodeEnv = process.env[nodeEnvKey];
|
|
93
|
+
process.env[key] = "false";
|
|
94
|
+
process.env[nodeEnvKey] = "production";
|
|
95
|
+
try {
|
|
96
|
+
const config = createAuthConfig({ secret: "test-secret" });
|
|
97
|
+
assert.equal(config.trustHost, false);
|
|
98
|
+
} finally {
|
|
99
|
+
if (orig !== undefined) process.env[key] = orig;
|
|
100
|
+
else delete process.env[key];
|
|
101
|
+
if (origNodeEnv !== undefined) process.env[nodeEnvKey] = origNodeEnv;
|
|
102
|
+
else delete process.env[nodeEnvKey];
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await t.test("createAuthConfig honors AUTH_TRUST_HOST=true", () => {
|
|
107
|
+
const key = "AUTH_TRUST_HOST";
|
|
108
|
+
const orig = process.env[key];
|
|
109
|
+
process.env[key] = "true";
|
|
110
|
+
try {
|
|
111
|
+
const config = createAuthConfig({ secret: "test-secret" });
|
|
112
|
+
assert.equal(config.trustHost, true);
|
|
113
|
+
} finally {
|
|
114
|
+
if (orig !== undefined) process.env[key] = orig;
|
|
115
|
+
else delete process.env[key];
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await t.test(
|
|
120
|
+
"createAuthConfig sets trustHost=true for non-localhost NEXTAUTH_URL",
|
|
121
|
+
() => {
|
|
122
|
+
const key = "NEXTAUTH_URL";
|
|
123
|
+
const orig = process.env[key];
|
|
124
|
+
process.env[key] = "https://myapp.com";
|
|
125
|
+
try {
|
|
126
|
+
const config = createAuthConfig({ secret: "test-secret" });
|
|
127
|
+
assert.equal(config.trustHost, true);
|
|
128
|
+
} finally {
|
|
129
|
+
if (orig !== undefined) process.env[key] = orig;
|
|
130
|
+
else delete process.env[key];
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await t.test(
|
|
136
|
+
"createAuthConfig sets trustHost=false for localhost NEXTAUTH_URL in production",
|
|
137
|
+
() => {
|
|
138
|
+
const urlKey = "NEXTAUTH_URL";
|
|
139
|
+
const nodeEnvKey = "NODE_ENV";
|
|
140
|
+
const origUrl = process.env[urlKey];
|
|
141
|
+
const origNodeEnv = process.env[nodeEnvKey];
|
|
142
|
+
process.env[urlKey] = "http://localhost:3000";
|
|
143
|
+
process.env[nodeEnvKey] = "production";
|
|
144
|
+
try {
|
|
145
|
+
const config = createAuthConfig({ secret: "test-secret" });
|
|
146
|
+
assert.equal(config.trustHost, false);
|
|
147
|
+
} finally {
|
|
148
|
+
if (origUrl !== undefined) process.env[urlKey] = origUrl;
|
|
149
|
+
else delete process.env[urlKey];
|
|
150
|
+
if (origNodeEnv !== undefined) process.env[nodeEnvKey] = origNodeEnv;
|
|
151
|
+
else delete process.env[nodeEnvKey];
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
await t.test("createAuthConfig trusts user trustHost:true override", () => {
|
|
157
|
+
const config = createAuthConfig({
|
|
158
|
+
secret: "test-secret",
|
|
159
|
+
trustHost: true,
|
|
160
|
+
});
|
|
161
|
+
assert.equal(config.trustHost, true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await t.test("createAuthConfig trusts user trustHost:false override", () => {
|
|
165
|
+
const key = "RAILWAY_SERVICE_ID";
|
|
166
|
+
const orig = process.env[key];
|
|
167
|
+
process.env[key] = "service-abc";
|
|
168
|
+
try {
|
|
169
|
+
const config = createAuthConfig({
|
|
170
|
+
secret: "test-secret",
|
|
171
|
+
trustHost: false,
|
|
172
|
+
});
|
|
173
|
+
assert.equal(config.trustHost, false);
|
|
174
|
+
} finally {
|
|
175
|
+
if (orig !== undefined) process.env[key] = orig;
|
|
176
|
+
else delete process.env[key];
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await t.test("isAuthenticated returns true for valid session", () => {
|
|
181
|
+
const session = {
|
|
182
|
+
user: { email: "test@example.com", id: "123" },
|
|
183
|
+
};
|
|
184
|
+
assert(isAuthenticated(session));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await t.test("isAuthenticated returns false for invalid session", () => {
|
|
188
|
+
assert(!isAuthenticated(null));
|
|
189
|
+
assert(!isAuthenticated({ user: null }));
|
|
190
|
+
assert(!isAuthenticated({ user: { email: null } }));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await t.test("getUserId extracts user ID", () => {
|
|
194
|
+
const session = { user: { id: "user-123" } };
|
|
195
|
+
assert(getUserId(session) === "user-123");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await t.test("getUserId returns null when missing", () => {
|
|
199
|
+
assert(getUserId(null) === null);
|
|
200
|
+
assert(getUserId({ user: null }) === null);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await t.test("getUserEmail extracts user email", () => {
|
|
204
|
+
const session = { user: { email: "test@example.com" } };
|
|
205
|
+
assert(getUserEmail(session) === "test@example.com");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await t.test("getUserEmail returns null when missing", () => {
|
|
209
|
+
assert(getUserEmail(null) === null);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await t.test("requireAuth returns user ID for authenticated session", () => {
|
|
213
|
+
const session = { user: { id: "user-123", email: "test@example.com" } };
|
|
214
|
+
const userId = requireAuth(session);
|
|
215
|
+
assert(userId === "user-123");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await t.test("requireAuth throws error for unauthenticated session", () => {
|
|
219
|
+
assert.throws(
|
|
220
|
+
() => requireAuth(null),
|
|
221
|
+
(err) => err instanceof Error,
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await t.test(
|
|
226
|
+
"getCurrentSession returns null when session lookup throws",
|
|
227
|
+
async () => {
|
|
228
|
+
const session = await getCurrentSession(async () => {
|
|
229
|
+
throw new Error("session store unavailable");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
assert.strictEqual(session, null);
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
});
|
|
@@ -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
|
+
};
|