@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.
Files changed (68) hide show
  1. package/README.md +63 -0
  2. package/package.json +80 -0
  3. package/src/admin-auth.js +121 -0
  4. package/src/admin.js +352 -0
  5. package/src/admin.test.js +257 -0
  6. package/src/auth/index.js +160 -0
  7. package/src/auth/middleware.js +106 -0
  8. package/src/auth/middleware.test.js +116 -0
  9. package/src/auth/password.js +9 -0
  10. package/src/auth.test.js +235 -0
  11. package/src/authorization.js +235 -0
  12. package/src/authorization.test.js +314 -0
  13. package/src/cache.js +137 -0
  14. package/src/cache.test.js +217 -0
  15. package/src/core.js +32 -0
  16. package/src/csrf.js +122 -0
  17. package/src/csrf.test.js +169 -0
  18. package/src/db-instrumentation.js +281 -0
  19. package/src/db-instrumentation.test.js +284 -0
  20. package/src/db-metrics.js +26 -0
  21. package/src/db.js +218 -0
  22. package/src/db.test.js +365 -0
  23. package/src/email-templates.js +134 -0
  24. package/src/email-templates.test.js +124 -0
  25. package/src/email.js +314 -0
  26. package/src/email.test.js +498 -0
  27. package/src/error-reporter.js +266 -0
  28. package/src/error-reporter.test.js +236 -0
  29. package/src/errors.js +192 -0
  30. package/src/errors.test.js +128 -0
  31. package/src/exports.test.js +154 -0
  32. package/src/file-validation.js +249 -0
  33. package/src/file-validation.test.js +207 -0
  34. package/src/index.js +58 -0
  35. package/src/locale.js +21 -0
  36. package/src/logger.js +182 -0
  37. package/src/logger.test.js +287 -0
  38. package/src/mail.js +50 -0
  39. package/src/metrics.js +379 -0
  40. package/src/metrics.test.js +380 -0
  41. package/src/multipart.js +144 -0
  42. package/src/multipart.test.js +188 -0
  43. package/src/pagination.js +139 -0
  44. package/src/pagination.test.js +210 -0
  45. package/src/query-builder.js +183 -0
  46. package/src/query-builder.test.js +234 -0
  47. package/src/queue/index.js +446 -0
  48. package/src/queue/queue.test.js +365 -0
  49. package/src/rate-limiter.js +253 -0
  50. package/src/rate-limiter.test.js +130 -0
  51. package/src/redis.js +154 -0
  52. package/src/redis.test.js +150 -0
  53. package/src/request-logger.js +211 -0
  54. package/src/request-logger.test.js +286 -0
  55. package/src/sms.js +208 -0
  56. package/src/storage-s3.js +236 -0
  57. package/src/storage.js +214 -0
  58. package/src/storage.test.js +457 -0
  59. package/src/stripe.js +89 -0
  60. package/src/testing/factories.js +89 -0
  61. package/src/testing/helpers.js +266 -0
  62. package/src/testing/index.js +46 -0
  63. package/src/testing/mocks.js +480 -0
  64. package/src/testing/testing.test.js +543 -0
  65. package/src/types.js +74 -0
  66. package/src/utils.js +219 -0
  67. package/src/utils.test.js +193 -0
  68. package/src/validation.js +26 -0
@@ -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
+ };