@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
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
coerceId,
|
|
9
|
+
detectIdField,
|
|
10
|
+
getEnums,
|
|
11
|
+
getModelByName,
|
|
12
|
+
getModels,
|
|
13
|
+
parseSchema,
|
|
14
|
+
resetSchemaCache,
|
|
15
|
+
} from "./admin.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Fixture: a minimal Prisma schema
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const SCHEMA = `
|
|
22
|
+
generator client {
|
|
23
|
+
provider = "prisma-client-js"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
datasource db {
|
|
27
|
+
provider = "postgresql"
|
|
28
|
+
url = env("DATABASE_URL")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
enum Role {
|
|
32
|
+
ADMIN
|
|
33
|
+
EDITOR
|
|
34
|
+
VIEWER
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
model User {
|
|
38
|
+
id Int @id @default(autoincrement())
|
|
39
|
+
email String @unique
|
|
40
|
+
name String?
|
|
41
|
+
role Role @default(VIEWER)
|
|
42
|
+
createdAt DateTime @default(now())
|
|
43
|
+
updatedAt DateTime @updatedAt
|
|
44
|
+
|
|
45
|
+
posts Post[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
model Post {
|
|
49
|
+
id Int @id @default(autoincrement())
|
|
50
|
+
title String
|
|
51
|
+
body String?
|
|
52
|
+
published Boolean @default(false)
|
|
53
|
+
author User @relation(fields: [authorId], references: [id])
|
|
54
|
+
authorId Int
|
|
55
|
+
createdAt DateTime @default(now())
|
|
56
|
+
updatedAt DateTime @updatedAt
|
|
57
|
+
}
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Helpers
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
let schemaDir;
|
|
65
|
+
|
|
66
|
+
async function setup() {
|
|
67
|
+
schemaDir = await mkdir(join(tmpdir(), "admin-test"), { recursive: true });
|
|
68
|
+
await writeFile(join(schemaDir, "schema.prisma"), SCHEMA);
|
|
69
|
+
resetSchemaCache();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function teardown() {
|
|
73
|
+
await rm(schemaDir, { recursive: true, force: true });
|
|
74
|
+
resetSchemaCache();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Tests
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
test("admin - parseSchema returns models and enums", async () => {
|
|
82
|
+
await setup();
|
|
83
|
+
try {
|
|
84
|
+
const result = parseSchema(join(schemaDir, "schema.prisma"));
|
|
85
|
+
assert.ok(result.models.length > 0, "should have models");
|
|
86
|
+
assert.ok(result.enums.length > 0, "should have enums");
|
|
87
|
+
} finally {
|
|
88
|
+
await teardown();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("admin - getModels returns User and Post", async () => {
|
|
93
|
+
await setup();
|
|
94
|
+
try {
|
|
95
|
+
const models = getModels(join(schemaDir, "schema.prisma"));
|
|
96
|
+
const names = models.map((m) => m.name);
|
|
97
|
+
assert.ok(names.includes("User"));
|
|
98
|
+
assert.ok(names.includes("Post"));
|
|
99
|
+
} finally {
|
|
100
|
+
await teardown();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("admin - getEnums returns Role", async () => {
|
|
105
|
+
await setup();
|
|
106
|
+
try {
|
|
107
|
+
const enums = getEnums(join(schemaDir, "schema.prisma"));
|
|
108
|
+
const roleEnum = enums.find((e) => e.name === "Role");
|
|
109
|
+
assert.ok(roleEnum, "should find Role enum");
|
|
110
|
+
assert.deepEqual(roleEnum.values, ["ADMIN", "EDITOR", "VIEWER"]);
|
|
111
|
+
} finally {
|
|
112
|
+
await teardown();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("admin - getModelByName is case-insensitive", async () => {
|
|
117
|
+
await setup();
|
|
118
|
+
try {
|
|
119
|
+
const user = getModelByName("user", join(schemaDir, "schema.prisma"));
|
|
120
|
+
assert.ok(user);
|
|
121
|
+
assert.equal(user.name, "User");
|
|
122
|
+
} finally {
|
|
123
|
+
await teardown();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("admin - getModelByName returns undefined for unknown model", async () => {
|
|
128
|
+
await setup();
|
|
129
|
+
try {
|
|
130
|
+
const result = getModelByName("Comment", join(schemaDir, "schema.prisma"));
|
|
131
|
+
assert.equal(result, undefined);
|
|
132
|
+
} finally {
|
|
133
|
+
await teardown();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("admin - detectIdField returns the @id field", async () => {
|
|
138
|
+
await setup();
|
|
139
|
+
try {
|
|
140
|
+
const user = getModelByName("User", join(schemaDir, "schema.prisma"));
|
|
141
|
+
const idField = detectIdField(user);
|
|
142
|
+
assert.ok(idField);
|
|
143
|
+
assert.equal(idField.name, "id");
|
|
144
|
+
assert.equal(idField.type, "Int");
|
|
145
|
+
assert.equal(idField.isId, true);
|
|
146
|
+
} finally {
|
|
147
|
+
await teardown();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("admin - detectIdField returns null for model without @id", async () => {
|
|
152
|
+
const fakeModel = {
|
|
153
|
+
name: "NoId",
|
|
154
|
+
fields: [{ name: "name", type: "String", isId: false }],
|
|
155
|
+
};
|
|
156
|
+
assert.equal(detectIdField(fakeModel), null);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("admin - coerceId converts string to number for Int @id", async () => {
|
|
160
|
+
await setup();
|
|
161
|
+
try {
|
|
162
|
+
const user = getModelByName("User", join(schemaDir, "schema.prisma"));
|
|
163
|
+
assert.equal(coerceId(user, "42"), 42);
|
|
164
|
+
} finally {
|
|
165
|
+
await teardown();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("admin - coerceId returns string for String @id", async () => {
|
|
170
|
+
const stringIdModel = {
|
|
171
|
+
name: "Thing",
|
|
172
|
+
fields: [{ name: "id", type: "String", isId: true }],
|
|
173
|
+
};
|
|
174
|
+
assert.equal(coerceId(stringIdModel, "abc-123"), "abc-123");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("admin - User model has expected fields", async () => {
|
|
178
|
+
await setup();
|
|
179
|
+
try {
|
|
180
|
+
const user = getModelByName("User", join(schemaDir, "schema.prisma"));
|
|
181
|
+
const fieldNames = user.fields.map((f) => f.name);
|
|
182
|
+
assert.ok(fieldNames.includes("id"));
|
|
183
|
+
assert.ok(fieldNames.includes("email"));
|
|
184
|
+
assert.ok(fieldNames.includes("name"));
|
|
185
|
+
assert.ok(fieldNames.includes("role"));
|
|
186
|
+
assert.ok(fieldNames.includes("createdAt"));
|
|
187
|
+
assert.ok(fieldNames.includes("updatedAt"));
|
|
188
|
+
} finally {
|
|
189
|
+
await teardown();
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("admin - User.email is marked @unique", async () => {
|
|
194
|
+
await setup();
|
|
195
|
+
try {
|
|
196
|
+
const user = getModelByName("User", join(schemaDir, "schema.prisma"));
|
|
197
|
+
const email = user.fields.find((f) => f.name === "email");
|
|
198
|
+
assert.ok(email.isUnique);
|
|
199
|
+
} finally {
|
|
200
|
+
await teardown();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("admin - Post.author is an object relation", async () => {
|
|
205
|
+
await setup();
|
|
206
|
+
try {
|
|
207
|
+
const post = getModelByName("Post", join(schemaDir, "schema.prisma"));
|
|
208
|
+
const author = post.fields.find((f) => f.name === "author");
|
|
209
|
+
assert.equal(author.kind, "object");
|
|
210
|
+
assert.equal(author.type, "User");
|
|
211
|
+
} finally {
|
|
212
|
+
await teardown();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("admin - Post.role field resolves enum kind", async () => {
|
|
217
|
+
await setup();
|
|
218
|
+
try {
|
|
219
|
+
const user = getModelByName("User", join(schemaDir, "schema.prisma"));
|
|
220
|
+
const role = user.fields.find((f) => f.name === "role");
|
|
221
|
+
assert.equal(role.kind, "enum");
|
|
222
|
+
assert.equal(role.type, "Role");
|
|
223
|
+
assert.deepEqual(role.enumValues, ["ADMIN", "EDITOR", "VIEWER"]);
|
|
224
|
+
} finally {
|
|
225
|
+
await teardown();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("admin - resetSchemaCache clears the cache", async () => {
|
|
230
|
+
await setup();
|
|
231
|
+
try {
|
|
232
|
+
parseSchema(join(schemaDir, "schema.prisma"));
|
|
233
|
+
resetSchemaCache();
|
|
234
|
+
// After reset, next call re-parses from disk — no error should occur
|
|
235
|
+
const result = parseSchema(join(schemaDir, "schema.prisma"));
|
|
236
|
+
assert.ok(result.models.length > 0);
|
|
237
|
+
} finally {
|
|
238
|
+
await teardown();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("admin - parseSchema returns empty models/enums when no schema found at default paths", () => {
|
|
243
|
+
// Override cwd to a path with no schema.prisma and no env var
|
|
244
|
+
const origCwd = process.cwd;
|
|
245
|
+
const origEnv = process.env.PRISMA_SCHEMA_PATH;
|
|
246
|
+
try {
|
|
247
|
+
process.cwd = () => "/nonexistent";
|
|
248
|
+
delete process.env.PRISMA_SCHEMA_PATH;
|
|
249
|
+
resetSchemaCache();
|
|
250
|
+
const result = parseSchema();
|
|
251
|
+
assert.ok(Array.isArray(result.models));
|
|
252
|
+
assert.ok(Array.isArray(result.enums));
|
|
253
|
+
} finally {
|
|
254
|
+
process.cwd = origCwd;
|
|
255
|
+
if (origEnv !== undefined) process.env.PRISMA_SCHEMA_PATH = origEnv;
|
|
256
|
+
}
|
|
257
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-core - Authentication Module
|
|
3
|
+
* Provides next-auth initialization and session management helpers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { UnauthorizedError } from "../errors.js";
|
|
7
|
+
import { createLogger } from "../logger.js";
|
|
8
|
+
|
|
9
|
+
export * from "./middleware.js";
|
|
10
|
+
export * from "./password.js";
|
|
11
|
+
|
|
12
|
+
const logger = createLogger({ name: "auth" });
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the auth secret from the supported environment variables.
|
|
16
|
+
* Prefers NEXTAUTH_SECRET for compatibility, but also accepts AUTH_SECRET.
|
|
17
|
+
* @returns {string|null}
|
|
18
|
+
*/
|
|
19
|
+
export const getAuthSecret = () => {
|
|
20
|
+
return process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET || null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Detects whether the app is running on a deployed (non-local) environment.
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
function isDeployed() {
|
|
28
|
+
const url = process.env.AUTH_URL || process.env.NEXTAUTH_URL;
|
|
29
|
+
if (url && !/localhost|127\.0\.0\.1|0\.0\.0\.0/.test(url)) return true;
|
|
30
|
+
return !!(
|
|
31
|
+
process.env.VERCEL ||
|
|
32
|
+
process.env.CF_PAGES ||
|
|
33
|
+
process.env.RAILWAY_SERVICE_ID ||
|
|
34
|
+
process.env.AUTH_TRUST_HOST === "true" ||
|
|
35
|
+
process.env.NODE_ENV !== "production"
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Creates a next-auth configuration object with sensible defaults.
|
|
41
|
+
* Designed to be extended by applications.
|
|
42
|
+
* @param {Object} options - Configuration options
|
|
43
|
+
* @param {Array} options.providers - Next-auth providers (GitHub, Google, etc.)
|
|
44
|
+
* @param {Object} options.callbacks - next-auth callbacks (jwt, session, etc.)
|
|
45
|
+
* @param {Object} options.session - Session configuration
|
|
46
|
+
* @param {string} options.secret - NEXTAUTH_SECRET (defaults to env var)
|
|
47
|
+
* @param {string} options.trustHost - When false, next-auth rejects requests from unknown hosts
|
|
48
|
+
* @returns {Object} Complete next-auth configuration
|
|
49
|
+
*/
|
|
50
|
+
export const createAuthConfig = (options = {}) => {
|
|
51
|
+
const {
|
|
52
|
+
providers = [],
|
|
53
|
+
callbacks = {},
|
|
54
|
+
session = {},
|
|
55
|
+
secret = getAuthSecret(),
|
|
56
|
+
...rest
|
|
57
|
+
} = options;
|
|
58
|
+
|
|
59
|
+
if (!secret) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"NEXTAUTH_SECRET or AUTH_SECRET must be set (env var or options.secret)",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const trustHost = rest.trustHost ?? isDeployed();
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
secret,
|
|
69
|
+
providers,
|
|
70
|
+
session: {
|
|
71
|
+
strategy: "jwt",
|
|
72
|
+
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
73
|
+
updateAge: 24 * 60 * 60, // 24 hours
|
|
74
|
+
...session,
|
|
75
|
+
},
|
|
76
|
+
callbacks: {
|
|
77
|
+
async jwt({ token, user }) {
|
|
78
|
+
if (user) {
|
|
79
|
+
token.id = user.id;
|
|
80
|
+
token.email = user.email;
|
|
81
|
+
token.name = user.name;
|
|
82
|
+
token.role = user.role || "viewer";
|
|
83
|
+
}
|
|
84
|
+
return token;
|
|
85
|
+
},
|
|
86
|
+
async session({ session, token }) {
|
|
87
|
+
if (session.user) {
|
|
88
|
+
session.user.id = token.id;
|
|
89
|
+
session.user.role = token.role;
|
|
90
|
+
}
|
|
91
|
+
return session;
|
|
92
|
+
},
|
|
93
|
+
...callbacks,
|
|
94
|
+
},
|
|
95
|
+
pages: {
|
|
96
|
+
signIn: "/auth/signin",
|
|
97
|
+
error: "/auth/error",
|
|
98
|
+
},
|
|
99
|
+
trustHost,
|
|
100
|
+
...rest,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Utility to safely get the current session (works in both server & edge)
|
|
106
|
+
* @param {Function} getSession - The next-auth getSession function
|
|
107
|
+
* @returns {Promise<Object|null>} Session object or null if not authenticated
|
|
108
|
+
*/
|
|
109
|
+
export const getCurrentSession = async (getSession) => {
|
|
110
|
+
try {
|
|
111
|
+
return await getSession();
|
|
112
|
+
} catch (error) {
|
|
113
|
+
logger.error("Failed to get session", {
|
|
114
|
+
message: error?.message ?? String(error),
|
|
115
|
+
});
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Checks if a user is authenticated
|
|
122
|
+
* @param {Object} session - Session object from next-auth
|
|
123
|
+
* @returns {boolean} True if user is authenticated
|
|
124
|
+
*/
|
|
125
|
+
export const isAuthenticated = (session) => {
|
|
126
|
+
return session?.user?.email;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Gets user ID from session
|
|
131
|
+
* @param {Object} session - Session object from next-auth
|
|
132
|
+
* @returns {string|null} User ID or null
|
|
133
|
+
*/
|
|
134
|
+
export const getUserId = (session) => {
|
|
135
|
+
return session?.user?.id || null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Gets user email from session
|
|
140
|
+
* @param {Object} session - Session object from next-auth
|
|
141
|
+
* @returns {string|null} User email or null
|
|
142
|
+
*/
|
|
143
|
+
export const getUserEmail = (session) => {
|
|
144
|
+
return session?.user?.email || null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Validates that a request has a valid session
|
|
149
|
+
* Throws UnauthorizedError if not authenticated
|
|
150
|
+
* @param {Object} session - Session object from next-auth
|
|
151
|
+
* @throws {UnauthorizedError} If session is invalid
|
|
152
|
+
* @returns {string} User ID
|
|
153
|
+
*/
|
|
154
|
+
export const requireAuth = (session) => {
|
|
155
|
+
const userId = getUserId(session);
|
|
156
|
+
if (!userId) {
|
|
157
|
+
throw new UnauthorizedError("Authentication required");
|
|
158
|
+
}
|
|
159
|
+
return userId;
|
|
160
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-core - Auth Middleware
|
|
3
|
+
*
|
|
4
|
+
* Lightweight, framework-agnostic authentication middleware for Server
|
|
5
|
+
* Actions and API routes. The caller supplies a `getSession` function
|
|
6
|
+
* (e.g. NextAuth's `auth()`) — this module adds the guard logic.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { requireSession, requireSessionRole, createAuthMiddleware } from "@techstream/quark-core/auth/middleware";
|
|
10
|
+
*
|
|
11
|
+
* // In a Server Action:
|
|
12
|
+
* const session = await requireSession(() => auth());
|
|
13
|
+
* // session is guaranteed non-null; UnauthorizedError thrown otherwise.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ForbiddenError, UnauthorizedError } from "../errors.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Requires a valid session. Throws `UnauthorizedError` if the session
|
|
20
|
+
* is missing or has no user.
|
|
21
|
+
*
|
|
22
|
+
* @template T
|
|
23
|
+
* @param {() => Promise<T | null>} getSession - Async function that returns the session (or null)
|
|
24
|
+
* @returns {Promise<T>} The session, guaranteed non-null
|
|
25
|
+
* @throws {UnauthorizedError}
|
|
26
|
+
*/
|
|
27
|
+
export async function requireSession(getSession) {
|
|
28
|
+
const session = await getSession();
|
|
29
|
+
if (!session) {
|
|
30
|
+
throw new UnauthorizedError("Authentication required");
|
|
31
|
+
}
|
|
32
|
+
return session;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Requires a valid session **and** that the user has the specified role.
|
|
37
|
+
*
|
|
38
|
+
* @template {{ user?: { role?: string } | null }} S
|
|
39
|
+
* @param {string} role - Required role (case-insensitive comparison)
|
|
40
|
+
* @param {() => Promise<S | null>} getSession - Async function that returns the session (or null)
|
|
41
|
+
* @returns {Promise<S>} The session, guaranteed non-null with the correct role
|
|
42
|
+
* @throws {UnauthorizedError} When the session is missing
|
|
43
|
+
* @throws {ForbiddenError} When the user's role does not match
|
|
44
|
+
*/
|
|
45
|
+
export async function requireSessionRole(role, getSession) {
|
|
46
|
+
const session = await requireSession(getSession);
|
|
47
|
+
const userRole = session?.user?.role;
|
|
48
|
+
|
|
49
|
+
if (!userRole || userRole.toLowerCase() !== role.toLowerCase()) {
|
|
50
|
+
throw new ForbiddenError(
|
|
51
|
+
`Role "${role}" required — you have "${userRole || "none"}"`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return session;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Creates a reusable auth middleware from a configuration object.
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} options
|
|
62
|
+
* @param {() => Promise<any | null>} options.getSession - Async function returning the session
|
|
63
|
+
* @param {string} [options.roleField="role"] - Field on `session.user` that holds the role
|
|
64
|
+
* @returns {{
|
|
65
|
+
* requireSession: () => Promise<any>,
|
|
66
|
+
* requireSessionRole: (role: string) => Promise<any>
|
|
67
|
+
* }}
|
|
68
|
+
*/
|
|
69
|
+
export function createAuthMiddleware({ getSession, roleField = "role" }) {
|
|
70
|
+
/**
|
|
71
|
+
* @returns {Promise<any>} The session
|
|
72
|
+
*/
|
|
73
|
+
async function resolveSession() {
|
|
74
|
+
const session = await getSession();
|
|
75
|
+
if (!session) {
|
|
76
|
+
throw new UnauthorizedError("Authentication required");
|
|
77
|
+
}
|
|
78
|
+
return session;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
/**
|
|
83
|
+
* @returns {Promise<any>} The session
|
|
84
|
+
*/
|
|
85
|
+
async requireSession() {
|
|
86
|
+
return resolveSession();
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} role - Required role
|
|
91
|
+
* @returns {Promise<any>} The session
|
|
92
|
+
*/
|
|
93
|
+
async requireSessionRole(role) {
|
|
94
|
+
const session = await resolveSession();
|
|
95
|
+
const userRole = session?.user?.[roleField];
|
|
96
|
+
|
|
97
|
+
if (!userRole || userRole.toLowerCase() !== role.toLowerCase()) {
|
|
98
|
+
throw new ForbiddenError(
|
|
99
|
+
`Role "${role}" required — you have "${userRole || "none"}"`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return session;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { ForbiddenError, UnauthorizedError } from "../errors.js";
|
|
4
|
+
import {
|
|
5
|
+
createAuthMiddleware,
|
|
6
|
+
requireSession,
|
|
7
|
+
requireSessionRole,
|
|
8
|
+
} from "./middleware.js";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// requireSession
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
test("auth/middleware - requireSession returns session when valid", async () => {
|
|
15
|
+
const session = { user: { id: "1", email: "a@b.com" } };
|
|
16
|
+
const result = await requireSession(async () => session);
|
|
17
|
+
assert.deepEqual(result, session);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("auth/middleware - requireSession throws UnauthorizedError when session is null", async () => {
|
|
21
|
+
await assert.rejects(
|
|
22
|
+
() => requireSession(async () => null),
|
|
23
|
+
(err) => err instanceof UnauthorizedError,
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("auth/middleware - requireSession throws UnauthorizedError when session is undefined", async () => {
|
|
28
|
+
await assert.rejects(
|
|
29
|
+
() => requireSession(async () => undefined),
|
|
30
|
+
(err) => err instanceof UnauthorizedError,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// requireSessionRole
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
test("auth/middleware - requireSessionRole returns session when role matches", async () => {
|
|
39
|
+
const session = { user: { id: "1", role: "admin" } };
|
|
40
|
+
const result = await requireSessionRole("admin", async () => session);
|
|
41
|
+
assert.deepEqual(result, session);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("auth/middleware - requireSessionRole is case-insensitive", async () => {
|
|
45
|
+
const session = { user: { id: "1", role: "Admin" } };
|
|
46
|
+
const result = await requireSessionRole("admin", async () => session);
|
|
47
|
+
assert.deepEqual(result, session);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("auth/middleware - requireSessionRole throws ForbiddenError when role does not match", async () => {
|
|
51
|
+
const session = { user: { id: "1", role: "viewer" } };
|
|
52
|
+
await assert.rejects(
|
|
53
|
+
() => requireSessionRole("admin", async () => session),
|
|
54
|
+
(err) => err instanceof ForbiddenError,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("auth/middleware - requireSessionRole throws UnauthorizedError when session is null", async () => {
|
|
59
|
+
await assert.rejects(
|
|
60
|
+
() => requireSessionRole("admin", async () => null),
|
|
61
|
+
(err) => err instanceof UnauthorizedError,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("auth/middleware - requireSessionRole throws ForbiddenError when user has no role", async () => {
|
|
66
|
+
const session = { user: { id: "1" } };
|
|
67
|
+
await assert.rejects(
|
|
68
|
+
() => requireSessionRole("admin", async () => session),
|
|
69
|
+
(err) => err instanceof ForbiddenError,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// createAuthMiddleware
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
test("auth/middleware - createAuthMiddleware requireSession works", async () => {
|
|
78
|
+
const session = { user: { id: "42", role: "editor" } };
|
|
79
|
+
const mw = createAuthMiddleware({ getSession: async () => session });
|
|
80
|
+
const result = await mw.requireSession();
|
|
81
|
+
assert.deepEqual(result, session);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("auth/middleware - createAuthMiddleware requireSession throws on null session", async () => {
|
|
85
|
+
const mw = createAuthMiddleware({ getSession: async () => null });
|
|
86
|
+
await assert.rejects(
|
|
87
|
+
() => mw.requireSession(),
|
|
88
|
+
(err) => err instanceof UnauthorizedError,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("auth/middleware - createAuthMiddleware requireSessionRole works", async () => {
|
|
93
|
+
const session = { user: { id: "42", role: "admin" } };
|
|
94
|
+
const mw = createAuthMiddleware({ getSession: async () => session });
|
|
95
|
+
const result = await mw.requireSessionRole("admin");
|
|
96
|
+
assert.deepEqual(result, session);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("auth/middleware - createAuthMiddleware requireSessionRole throws ForbiddenError", async () => {
|
|
100
|
+
const session = { user: { id: "42", role: "viewer" } };
|
|
101
|
+
const mw = createAuthMiddleware({ getSession: async () => session });
|
|
102
|
+
await assert.rejects(
|
|
103
|
+
() => mw.requireSessionRole("admin"),
|
|
104
|
+
(err) => err instanceof ForbiddenError,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("auth/middleware - createAuthMiddleware uses custom roleField", async () => {
|
|
109
|
+
const flatSession = { user: { id: "42", accessLevel: "admin" } };
|
|
110
|
+
const mw = createAuthMiddleware({
|
|
111
|
+
getSession: async () => flatSession,
|
|
112
|
+
roleField: "accessLevel",
|
|
113
|
+
});
|
|
114
|
+
const result = await mw.requireSessionRole("admin");
|
|
115
|
+
assert.deepEqual(result, flatSession);
|
|
116
|
+
});
|