@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
package/README.md
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# @techstream/quark-core - The Quark Platform Core
|
|
2
|
+
|
|
3
|
+
The central "plumbing" package for the Quark platform. Every Quark application inherits this core infrastructure, providing database access, authentication, job queuing, and error handling out of the box.
|
|
4
|
+
|
|
5
|
+
## Philosophy
|
|
6
|
+
|
|
7
|
+
`@techstream/quark-core` provides **opinionated, zero-configuration defaults** for common infrastructure concerns:
|
|
8
|
+
|
|
9
|
+
- **Database**: Prisma ORM with singleton pattern
|
|
10
|
+
- **Authentication**: Next-auth helpers with sensible defaults
|
|
11
|
+
- **Job Queue**: BullMQ integration for background jobs
|
|
12
|
+
- **Error Handling**: Standardized, serializable error types
|
|
13
|
+
- **Utilities**: Common functions for retries, validation, etc.
|
|
14
|
+
|
|
15
|
+
Applications **inherit these tools** but can **extend and override** them as needed.
|
|
16
|
+
|
|
17
|
+
## Core Principles
|
|
18
|
+
|
|
19
|
+
### What Goes in Core
|
|
20
|
+
|
|
21
|
+
✅ **Belongs in Core:**
|
|
22
|
+
- Infrastructure-level utilities (DB, Auth, Queue, Errors)
|
|
23
|
+
- Patterns that every app needs
|
|
24
|
+
- Zero-configuration defaults
|
|
25
|
+
- Type definitions
|
|
26
|
+
- Provider-agnostic helpers
|
|
27
|
+
|
|
28
|
+
❌ **Does NOT belong in Core:**
|
|
29
|
+
- Application-specific business logic
|
|
30
|
+
- Domain models (use Prisma schema instead)
|
|
31
|
+
- UI components (use @techstream/quark-ui)
|
|
32
|
+
- Config-specific settings (use environment variables)
|
|
33
|
+
|
|
34
|
+
### What Gets "Ejected"
|
|
35
|
+
|
|
36
|
+
The "ejection" pattern allows apps to customize Core defaults:
|
|
37
|
+
|
|
38
|
+
```javascript
|
|
39
|
+
// Import core helpers
|
|
40
|
+
import { createAuthConfig, createQueue } from "@techstream/quark-core";
|
|
41
|
+
|
|
42
|
+
// Override/extend with app-specific config
|
|
43
|
+
export const authConfig = createAuthConfig({
|
|
44
|
+
providers: [GitHubProvider(...)],
|
|
45
|
+
callbacks: {
|
|
46
|
+
async jwt({ token, user }) {
|
|
47
|
+
// Custom JWT logic
|
|
48
|
+
return token;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
### Database Client
|
|
57
|
+
|
|
58
|
+
Access the Prisma singleton client:
|
|
59
|
+
|
|
60
|
+
```javascript
|
|
61
|
+
import { createDbClient } from "@techstream/quark-core";
|
|
62
|
+
|
|
63
|
+
const db = createDbClient();
|
|
64
|
+
|
|
65
|
+
const user = await db.user.findUnique({
|
|
66
|
+
where: { id: "123" }
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
In development, the client automatically attaches to `globalThis` to prevent hot-reload issues.
|
|
71
|
+
|
|
72
|
+
### Authentication
|
|
73
|
+
|
|
74
|
+
Create a next-auth configuration with defaults:
|
|
75
|
+
|
|
76
|
+
```javascript
|
|
77
|
+
import { createAuthConfig, getCurrentSession, getUserId } from "@techstream/quark-core";
|
|
78
|
+
|
|
79
|
+
export const authConfig = createAuthConfig({
|
|
80
|
+
providers: [GitHubProvider({ ... })],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// In a server action/API route:
|
|
84
|
+
import { getSession } from "next-auth/react";
|
|
85
|
+
|
|
86
|
+
const session = await getCurrentSession(getSession);
|
|
87
|
+
if (!session) {
|
|
88
|
+
throw new UnauthorizedError();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const userId = getUserId(session);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
#### Available Auth Functions
|
|
95
|
+
|
|
96
|
+
- `createAuthConfig(options)` - Creates next-auth config
|
|
97
|
+
- `getCurrentSession(getSession)` - Safely retrieves session
|
|
98
|
+
- `isAuthenticated(session)` - Checks if session is valid
|
|
99
|
+
- `getUserId(session)` - Extracts user ID
|
|
100
|
+
- `getUserEmail(session)` - Extracts user email
|
|
101
|
+
- `requireAuth(session)` - Throws error if not authenticated
|
|
102
|
+
|
|
103
|
+
### Job Queue
|
|
104
|
+
|
|
105
|
+
Initialize background job processing:
|
|
106
|
+
|
|
107
|
+
```javascript
|
|
108
|
+
import { createQueue, createWorker, addJob } from "@techstream/quark-core";
|
|
109
|
+
|
|
110
|
+
// Create a queue
|
|
111
|
+
const emailQueue = createQueue("emails", {
|
|
112
|
+
redis: {
|
|
113
|
+
host: process.env.REDIS_HOST,
|
|
114
|
+
port: process.env.REDIS_PORT,
|
|
115
|
+
},
|
|
116
|
+
defaultJobOptions: {
|
|
117
|
+
attempts: 3,
|
|
118
|
+
backoff: {
|
|
119
|
+
type: "exponential",
|
|
120
|
+
delay: 2000,
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Process jobs
|
|
126
|
+
createWorker("emails", async (job) => {
|
|
127
|
+
await sendEmail(job.data);
|
|
128
|
+
return { success: true };
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Add a job
|
|
132
|
+
await addJob(emailQueue, "send-email", {
|
|
133
|
+
to: "user@example.com",
|
|
134
|
+
subject: "Welcome!",
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
#### Available Queue Functions
|
|
139
|
+
|
|
140
|
+
- `createQueue(name, options)` - Creates a BullMQ queue
|
|
141
|
+
- `createWorker(queueName, handler, options)` - Creates a job processor
|
|
142
|
+
- `addJob(queue, jobName, data, jobOptions)` - Adds a job to queue
|
|
143
|
+
- `getJobStatus(job)` - Gets job status/progress
|
|
144
|
+
- `clearQueue(queue)` - Clears all jobs from queue
|
|
145
|
+
- `closeAllQueues()` - Gracefully closes all queues
|
|
146
|
+
- `checkQueueHealth()` - Checks Redis connectivity
|
|
147
|
+
|
|
148
|
+
### Error Handling
|
|
149
|
+
|
|
150
|
+
Use standardized error types:
|
|
151
|
+
|
|
152
|
+
```javascript
|
|
153
|
+
import {
|
|
154
|
+
ValidationError,
|
|
155
|
+
UnauthorizedError,
|
|
156
|
+
NotFoundError,
|
|
157
|
+
AppError,
|
|
158
|
+
logError,
|
|
159
|
+
normalizeError,
|
|
160
|
+
} from "@techstream/quark-core";
|
|
161
|
+
|
|
162
|
+
// Throw specific errors
|
|
163
|
+
if (!email) {
|
|
164
|
+
throw new ValidationError("Email is required");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!session) {
|
|
168
|
+
throw new UnauthorizedError("Please log in");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!user) {
|
|
172
|
+
throw new NotFoundError("User not found");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Error types automatically serialize to JSON:
|
|
176
|
+
// {
|
|
177
|
+
// "name": "ValidationError",
|
|
178
|
+
// "message": "Email is required",
|
|
179
|
+
// "code": "VALIDATION_ERROR",
|
|
180
|
+
// "statusCode": 400,
|
|
181
|
+
// "timestamp": "2026-02-04T10:00:00.000Z"
|
|
182
|
+
// }
|
|
183
|
+
|
|
184
|
+
// Handle errors with context
|
|
185
|
+
try {
|
|
186
|
+
await someOperation();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
logError(error, { userId: "123", context: "email_signup" });
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
#### Available Error Types
|
|
193
|
+
|
|
194
|
+
- `AppError` - Base class (500)
|
|
195
|
+
- `ValidationError` - Bad input (400)
|
|
196
|
+
- `UnauthorizedError` - Not authenticated (401)
|
|
197
|
+
- `ForbiddenError` - Not authorized (403)
|
|
198
|
+
- `NotFoundError` - Resource missing (404)
|
|
199
|
+
- `ConflictError` - Resource conflict (409)
|
|
200
|
+
- `RateLimitError` - Too many requests (429)
|
|
201
|
+
- `DatabaseError` - Database issue (500)
|
|
202
|
+
- `ServiceError` - External service failure (502)
|
|
203
|
+
|
|
204
|
+
### Utilities
|
|
205
|
+
|
|
206
|
+
Common helper functions:
|
|
207
|
+
|
|
208
|
+
```javascript
|
|
209
|
+
import {
|
|
210
|
+
retryAsync,
|
|
211
|
+
validateEnv,
|
|
212
|
+
deepMerge,
|
|
213
|
+
randomString,
|
|
214
|
+
sanitizeId,
|
|
215
|
+
measureTime,
|
|
216
|
+
memoize,
|
|
217
|
+
} from "@techstream/quark-core";
|
|
218
|
+
|
|
219
|
+
// Retry with exponential backoff
|
|
220
|
+
const result = await retryAsync(
|
|
221
|
+
() => fetchFromExternalAPI(),
|
|
222
|
+
{
|
|
223
|
+
maxAttempts: 5,
|
|
224
|
+
initialDelay: 1000,
|
|
225
|
+
onRetry: ({ attempt, delay }) => {
|
|
226
|
+
console.log(`Retry ${attempt} after ${delay}ms`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Validate environment
|
|
232
|
+
validateEnv(["DATABASE_URL", "REDIS_HOST"]);
|
|
233
|
+
|
|
234
|
+
// Merge configurations
|
|
235
|
+
const config = deepMerge(defaults, userConfig);
|
|
236
|
+
|
|
237
|
+
// Generate IDs
|
|
238
|
+
const id = randomString(12);
|
|
239
|
+
|
|
240
|
+
// Sanitize for URLs
|
|
241
|
+
const slug = sanitizeId("Hello World"); // "hello-world"
|
|
242
|
+
|
|
243
|
+
// Measure performance
|
|
244
|
+
const { result, duration } = await measureTime(async () => {
|
|
245
|
+
return await expensiveOperation();
|
|
246
|
+
});
|
|
247
|
+
console.log(`Completed in ${duration}ms`);
|
|
248
|
+
|
|
249
|
+
// Cache results
|
|
250
|
+
const getCachedUser = memoize(
|
|
251
|
+
(id) => db.user.findUnique({ where: { id } }),
|
|
252
|
+
60000 // 1 minute TTL
|
|
253
|
+
);
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Environment Configuration
|
|
257
|
+
|
|
258
|
+
Core respects these environment variables:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
# Database
|
|
262
|
+
DATABASE_URL=postgresql://...
|
|
263
|
+
|
|
264
|
+
# Redis/Queue
|
|
265
|
+
REDIS_HOST=localhost
|
|
266
|
+
REDIS_PORT=6379
|
|
267
|
+
REDIS_DB=0
|
|
268
|
+
|
|
269
|
+
# Application URL
|
|
270
|
+
APP_URL=http://localhost:3000
|
|
271
|
+
|
|
272
|
+
# Authentication
|
|
273
|
+
NEXTAUTH_SECRET=your-secret-key
|
|
274
|
+
|
|
275
|
+
# Node
|
|
276
|
+
NODE_ENV=development
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Testing Core
|
|
280
|
+
|
|
281
|
+
Core includes unit tests verifying all functionality:
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
cd packages/core
|
|
285
|
+
pnpm test
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
See [Core Tests](./test/) for examples.
|
|
289
|
+
|
|
290
|
+
## Extending Core
|
|
291
|
+
|
|
292
|
+
### Custom Error Types
|
|
293
|
+
|
|
294
|
+
```javascript
|
|
295
|
+
import { AppError } from "@techstream/quark-core";
|
|
296
|
+
|
|
297
|
+
export class PaymentError extends AppError {
|
|
298
|
+
constructor(message, code = "PAYMENT_FAILED") {
|
|
299
|
+
super(message, 402, code);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Custom Queue Handlers
|
|
305
|
+
|
|
306
|
+
```javascript
|
|
307
|
+
import { createWorker } from "@techstream/quark-core";
|
|
308
|
+
|
|
309
|
+
createWorker("analytics", async (job) => {
|
|
310
|
+
await trackEvent(job.data);
|
|
311
|
+
}, {
|
|
312
|
+
concurrency: 10,
|
|
313
|
+
redis: {
|
|
314
|
+
host: "redis-prod.internal",
|
|
315
|
+
port: 6379,
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Middleware & Hooks
|
|
321
|
+
|
|
322
|
+
```javascript
|
|
323
|
+
import { createDbClient } from "@techstream/quark-core";
|
|
324
|
+
|
|
325
|
+
const db = createDbClient({
|
|
326
|
+
middleware: [
|
|
327
|
+
{
|
|
328
|
+
$use: async (params, next) => {
|
|
329
|
+
const before = Date.now();
|
|
330
|
+
const result = await next(params);
|
|
331
|
+
const after = Date.now();
|
|
332
|
+
console.log(`${params.model}.${params.action} took ${after - before}ms`);
|
|
333
|
+
return result;
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
});
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## Architecture
|
|
341
|
+
|
|
342
|
+
```
|
|
343
|
+
@techstream/quark-core/
|
|
344
|
+
├── src/
|
|
345
|
+
│ ├── index.js # Main exports
|
|
346
|
+
│ ├── auth/
|
|
347
|
+
│ │ ├── index.js # Next-auth helpers
|
|
348
|
+
│ │ └── password.js # Password hashing (bcryptjs)
|
|
349
|
+
│ ├── queue/
|
|
350
|
+
│ │ └── index.js # BullMQ integration
|
|
351
|
+
│ ├── errors.js # Error types & utilities
|
|
352
|
+
│ ├── redis.js # Redis URL & config helpers
|
|
353
|
+
│ ├── mailhog.js # Mailhog SMTP/UI config helpers
|
|
354
|
+
│ ├── validation.js # Zod request body validation
|
|
355
|
+
│ ├── utils.js # Common helpers
|
|
356
|
+
│ ├── types.js # JSDoc type definitions
|
|
357
|
+
│ ├── auth.test.js # Auth tests
|
|
358
|
+
│ ├── errors.test.js # Error tests
|
|
359
|
+
│ └── utils.test.js # Utils tests
|
|
360
|
+
└── package.json
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
## Migration Path from Existing Apps
|
|
364
|
+
|
|
365
|
+
If you're migrating from an existing app:
|
|
366
|
+
|
|
367
|
+
1. **Keep your current setup** - Core is not required
|
|
368
|
+
2. **Extract shared patterns** - Move common code to Core
|
|
369
|
+
3. **Test independently** - Ensure Core works standalone
|
|
370
|
+
4. **Adopt gradually** - Start using Core utilities piece by piece
|
|
371
|
+
5. **Eject where needed** - Override defaults in your app
|
|
372
|
+
|
|
373
|
+
Example migration:
|
|
374
|
+
|
|
375
|
+
```javascript
|
|
376
|
+
// Before: app-specific auth.js
|
|
377
|
+
export const authConfig = { ... };
|
|
378
|
+
|
|
379
|
+
// After: inherit and extend from Core
|
|
380
|
+
import { createAuthConfig } from "@techstream/quark-core";
|
|
381
|
+
|
|
382
|
+
export const authConfig = createAuthConfig({
|
|
383
|
+
providers: [...], // add app-specific providers
|
|
384
|
+
});
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
## Troubleshooting
|
|
388
|
+
|
|
389
|
+
### Prisma Client Issues
|
|
390
|
+
|
|
391
|
+
**"Cannot find Prisma Client"**
|
|
392
|
+
- Ensure `@prisma/client` is installed: `pnpm install`
|
|
393
|
+
- Run `pnpm db:generate` to build Prisma client
|
|
394
|
+
|
|
395
|
+
### Queue Connection Issues
|
|
396
|
+
|
|
397
|
+
**"Redis connection refused"**
|
|
398
|
+
- Check REDIS_HOST and REDIS_PORT
|
|
399
|
+
- Ensure Redis is running: `docker-compose up redis`
|
|
400
|
+
|
|
401
|
+
### Auth Issues
|
|
402
|
+
|
|
403
|
+
**"NEXTAUTH_SECRET is not set"**
|
|
404
|
+
- Set in production: `export NEXTAUTH_SECRET=<random-string>`
|
|
405
|
+
- Or pass via `createAuthConfig({ secret: '...' })`
|
|
406
|
+
|
|
407
|
+
## Contributing
|
|
408
|
+
|
|
409
|
+
When adding features to Core:
|
|
410
|
+
|
|
411
|
+
1. ✅ **Add comprehensive JSDoc comments**
|
|
412
|
+
2. ✅ **Create unit tests** in `*.test.js`
|
|
413
|
+
3. ✅ **Update this README**
|
|
414
|
+
4. ✅ **Keep it framework-agnostic** where possible
|
|
415
|
+
5. ✅ **Document configuration options**
|
|
416
|
+
|
|
417
|
+
## License
|
|
418
|
+
|
|
419
|
+
ISC - Part of the Quark Platform
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@techstream/quark-core",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js",
|
|
8
|
+
"./testing": "./src/testing/index.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"bcryptjs": "^3.0.3",
|
|
12
|
+
"bullmq": "^5.67.3",
|
|
13
|
+
"ioredis": "^5.9.3",
|
|
14
|
+
"next-auth": "5.0.0-beta.30",
|
|
15
|
+
"nodemailer": "^6.10.1",
|
|
16
|
+
"zod": "^4.3.6"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^24.10.12"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test $(find src -name '*.test.js')",
|
|
23
|
+
"lint": "biome format --write && biome check --write"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"registry": "https://registry.npmjs.org",
|
|
27
|
+
"access": "public"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
|
|
8
|
+
export * from "./password.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates a next-auth configuration object with sensible defaults
|
|
12
|
+
* Designed to be extended by applications
|
|
13
|
+
* @param {Object} options - Configuration options
|
|
14
|
+
* @param {Array} options.providers - Next-auth providers (GitHub, Google, etc.)
|
|
15
|
+
* @param {Object} options.callbacks - next-auth callbacks (jwt, session, etc.)
|
|
16
|
+
* @param {Object} options.session - Session configuration
|
|
17
|
+
* @param {string} options.secret - NEXTAUTH_SECRET (defaults to env var)
|
|
18
|
+
* @returns {Object} Complete next-auth configuration
|
|
19
|
+
*/
|
|
20
|
+
export const createAuthConfig = (options = {}) => {
|
|
21
|
+
const {
|
|
22
|
+
providers = [],
|
|
23
|
+
callbacks = {},
|
|
24
|
+
session = {},
|
|
25
|
+
secret = process.env.NEXTAUTH_SECRET,
|
|
26
|
+
...rest
|
|
27
|
+
} = options;
|
|
28
|
+
|
|
29
|
+
if (!secret) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"NEXTAUTH_SECRET must be set (env var or options.secret)",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
secret,
|
|
37
|
+
providers,
|
|
38
|
+
session: {
|
|
39
|
+
strategy: "jwt",
|
|
40
|
+
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
41
|
+
updateAge: 24 * 60 * 60, // 24 hours
|
|
42
|
+
...session,
|
|
43
|
+
},
|
|
44
|
+
callbacks: {
|
|
45
|
+
async jwt({ token, user }) {
|
|
46
|
+
if (user) {
|
|
47
|
+
token.id = user.id;
|
|
48
|
+
token.email = user.email;
|
|
49
|
+
token.name = user.name;
|
|
50
|
+
token.role = user.role || "viewer";
|
|
51
|
+
}
|
|
52
|
+
return token;
|
|
53
|
+
},
|
|
54
|
+
async session({ session, token }) {
|
|
55
|
+
if (session.user) {
|
|
56
|
+
session.user.id = token.id;
|
|
57
|
+
session.user.role = token.role;
|
|
58
|
+
}
|
|
59
|
+
// Note: NextAuth v5+ includes CSRF token in session automatically
|
|
60
|
+
// For older versions, you can add: session.csrfToken = token.csrfToken;
|
|
61
|
+
return session;
|
|
62
|
+
},
|
|
63
|
+
...callbacks,
|
|
64
|
+
},
|
|
65
|
+
pages: {
|
|
66
|
+
signIn: "/auth/signin",
|
|
67
|
+
error: "/auth/error",
|
|
68
|
+
},
|
|
69
|
+
...rest,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Utility to safely get the current session (works in both server & edge)
|
|
75
|
+
* @param {Function} getSession - The next-auth getSession function
|
|
76
|
+
* @returns {Promise<Object|null>} Session object or null if not authenticated
|
|
77
|
+
*/
|
|
78
|
+
export const getCurrentSession = async (getSession) => {
|
|
79
|
+
try {
|
|
80
|
+
return await getSession();
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.error("Failed to get session:", error);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Checks if a user is authenticated
|
|
89
|
+
* @param {Object} session - Session object from next-auth
|
|
90
|
+
* @returns {boolean} True if user is authenticated
|
|
91
|
+
*/
|
|
92
|
+
export const isAuthenticated = (session) => {
|
|
93
|
+
return session?.user?.email;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Gets user ID from session
|
|
98
|
+
* @param {Object} session - Session object from next-auth
|
|
99
|
+
* @returns {string|null} User ID or null
|
|
100
|
+
*/
|
|
101
|
+
export const getUserId = (session) => {
|
|
102
|
+
return session?.user?.id || null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Gets user email from session
|
|
107
|
+
* @param {Object} session - Session object from next-auth
|
|
108
|
+
* @returns {string|null} User email or null
|
|
109
|
+
*/
|
|
110
|
+
export const getUserEmail = (session) => {
|
|
111
|
+
return session?.user?.email || null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validates that a request has a valid session
|
|
116
|
+
* Throws UnauthorizedError if not authenticated
|
|
117
|
+
* @param {Object} session - Session object from next-auth
|
|
118
|
+
* @throws {UnauthorizedError} If session is invalid
|
|
119
|
+
* @returns {string} User ID
|
|
120
|
+
*/
|
|
121
|
+
export const requireAuth = (session) => {
|
|
122
|
+
const userId = getUserId(session);
|
|
123
|
+
if (!userId) {
|
|
124
|
+
throw new UnauthorizedError("Authentication required");
|
|
125
|
+
}
|
|
126
|
+
return userId;
|
|
127
|
+
};
|
package/src/auth.test.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
createAuthConfig,
|
|
5
|
+
getUserEmail,
|
|
6
|
+
getUserId,
|
|
7
|
+
isAuthenticated,
|
|
8
|
+
requireAuth,
|
|
9
|
+
} from "../src/auth/index.js";
|
|
10
|
+
|
|
11
|
+
test("Auth Module", async (t) => {
|
|
12
|
+
await t.test("createAuthConfig returns valid config", () => {
|
|
13
|
+
const config = createAuthConfig({ secret: "test-secret" });
|
|
14
|
+
assert(config.session);
|
|
15
|
+
assert(config.session.strategy === "jwt");
|
|
16
|
+
assert(config.session.maxAge === 30 * 24 * 60 * 60);
|
|
17
|
+
assert.deepStrictEqual(config.providers, []);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
await t.test("createAuthConfig throws when secret is missing", () => {
|
|
21
|
+
const orig = process.env.NEXTAUTH_SECRET;
|
|
22
|
+
delete process.env.NEXTAUTH_SECRET;
|
|
23
|
+
try {
|
|
24
|
+
assert.throws(
|
|
25
|
+
() => createAuthConfig(),
|
|
26
|
+
(err) =>
|
|
27
|
+
err instanceof Error &&
|
|
28
|
+
/NEXTAUTH_SECRET/.test(err.message),
|
|
29
|
+
);
|
|
30
|
+
} finally {
|
|
31
|
+
if (orig !== undefined) process.env.NEXTAUTH_SECRET = orig;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await t.test("createAuthConfig with custom options", () => {
|
|
36
|
+
const customProvider = { id: "custom" };
|
|
37
|
+
const config = createAuthConfig({
|
|
38
|
+
providers: [customProvider],
|
|
39
|
+
secret: "test-secret",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
assert.deepStrictEqual(config.providers, [customProvider]);
|
|
43
|
+
assert(config.secret === "test-secret");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await t.test("isAuthenticated returns true for valid session", () => {
|
|
47
|
+
const session = {
|
|
48
|
+
user: { email: "test@example.com", id: "123" },
|
|
49
|
+
};
|
|
50
|
+
assert(isAuthenticated(session));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await t.test("isAuthenticated returns false for invalid session", () => {
|
|
54
|
+
assert(!isAuthenticated(null));
|
|
55
|
+
assert(!isAuthenticated({ user: null }));
|
|
56
|
+
assert(!isAuthenticated({ user: { email: null } }));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await t.test("getUserId extracts user ID", () => {
|
|
60
|
+
const session = { user: { id: "user-123" } };
|
|
61
|
+
assert(getUserId(session) === "user-123");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await t.test("getUserId returns null when missing", () => {
|
|
65
|
+
assert(getUserId(null) === null);
|
|
66
|
+
assert(getUserId({ user: null }) === null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await t.test("getUserEmail extracts user email", () => {
|
|
70
|
+
const session = { user: { email: "test@example.com" } };
|
|
71
|
+
assert(getUserEmail(session) === "test@example.com");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await t.test("getUserEmail returns null when missing", () => {
|
|
75
|
+
assert(getUserEmail(null) === null);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await t.test("requireAuth returns user ID for authenticated session", () => {
|
|
79
|
+
const session = { user: { id: "user-123", email: "test@example.com" } };
|
|
80
|
+
const userId = requireAuth(session);
|
|
81
|
+
assert(userId === "user-123");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await t.test("requireAuth throws error for unauthenticated session", () => {
|
|
85
|
+
assert.throws(
|
|
86
|
+
() => requireAuth(null),
|
|
87
|
+
(err) => err instanceof Error,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|