@techstream/quark-core 1.1.0 → 1.4.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/README.md +20 -398
- package/package.json +33 -28
- package/src/auth/index.js +1 -3
- package/src/auth.test.js +1 -3
- package/src/cache.test.js +1 -1
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +13 -6
- package/src/email.test.js +138 -126
- package/src/index.js +4 -2
- package/src/mailhog.js +2 -2
- package/src/rate-limiter.js +1 -1
- package/.turbo/turbo-lint.log +0 -7
- package/.turbo/turbo-test.log +0 -1376
- package/test-imports.mjs +0 -21
package/README.md
CHANGED
|
@@ -1,419 +1,41 @@
|
|
|
1
|
-
# @techstream/quark-core
|
|
1
|
+
# @techstream/quark-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shared infrastructure for the Quark platform — authentication, job queues, error handling, and utilities.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## What's Included
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
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
|
-
```
|
|
7
|
+
- **Authentication** — Next-auth helpers (`createAuthConfig`, `requireAuth`, password hashing)
|
|
8
|
+
- **Job Queue** — BullMQ integration (`createQueue`, `createWorker`, `addJob`)
|
|
9
|
+
- **Errors** — Standardized error types (`ValidationError`, `NotFoundError`, `UnauthorizedError`, etc.)
|
|
10
|
+
- **Utilities** — `retryAsync`, `deepMerge`, `randomString`, `sanitizeId`, `measureTime`, `memoize`
|
|
11
|
+
- **Validation** — Zod-based request body validation
|
|
12
|
+
- **Redis / Mailhog** — Connection helpers for Redis and Mailhog
|
|
53
13
|
|
|
54
14
|
## Usage
|
|
55
15
|
|
|
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
16
|
```javascript
|
|
153
17
|
import {
|
|
18
|
+
createAuthConfig,
|
|
19
|
+
requireAuth,
|
|
20
|
+
createQueue,
|
|
21
|
+
createWorker,
|
|
22
|
+
addJob,
|
|
154
23
|
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
24
|
retryAsync,
|
|
211
|
-
validateEnv,
|
|
212
|
-
deepMerge,
|
|
213
|
-
randomString,
|
|
214
|
-
sanitizeId,
|
|
215
|
-
measureTime,
|
|
216
|
-
memoize,
|
|
217
25
|
} 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
26
|
```
|
|
278
27
|
|
|
279
|
-
|
|
28
|
+
All modules are re-exported from the package root. See JSDoc comments on each function for options and usage details.
|
|
280
29
|
|
|
281
|
-
|
|
30
|
+
## Testing
|
|
282
31
|
|
|
283
32
|
```bash
|
|
284
33
|
cd packages/core
|
|
285
34
|
pnpm test
|
|
286
35
|
```
|
|
287
36
|
|
|
288
|
-
|
|
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
|
|
37
|
+
## Support
|
|
418
38
|
|
|
419
|
-
|
|
39
|
+
For issues, questions, and discussions:
|
|
40
|
+
- 🐛 [Issue Tracker](https://github.com/Bobnoddle/quark/issues)
|
|
41
|
+
- 💬 [Discussions](https://github.com/Bobnoddle/quark/discussions)
|
package/package.json
CHANGED
|
@@ -1,29 +1,34 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
2
|
+
"name": "@techstream/quark-core",
|
|
3
|
+
"version": "1.4.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
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"registry": "https://registry.npmjs.org",
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"license": "ISC",
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --test $(find src -name '*.test.js')",
|
|
32
|
+
"lint": "biome format --write && biome check --write"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/auth/index.js
CHANGED
|
@@ -27,9 +27,7 @@ export const createAuthConfig = (options = {}) => {
|
|
|
27
27
|
} = options;
|
|
28
28
|
|
|
29
29
|
if (!secret) {
|
|
30
|
-
throw new Error(
|
|
31
|
-
"NEXTAUTH_SECRET must be set (env var or options.secret)",
|
|
32
|
-
);
|
|
30
|
+
throw new Error("NEXTAUTH_SECRET must be set (env var or options.secret)");
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
return {
|
package/src/auth.test.js
CHANGED
|
@@ -23,9 +23,7 @@ test("Auth Module", async (t) => {
|
|
|
23
23
|
try {
|
|
24
24
|
assert.throws(
|
|
25
25
|
() => createAuthConfig(),
|
|
26
|
-
(err) =>
|
|
27
|
-
err instanceof Error &&
|
|
28
|
-
/NEXTAUTH_SECRET/.test(err.message),
|
|
26
|
+
(err) => err instanceof Error && /NEXTAUTH_SECRET/.test(err.message),
|
|
29
27
|
);
|
|
30
28
|
} finally {
|
|
31
29
|
if (orig !== undefined) process.env.NEXTAUTH_SECRET = orig;
|
package/src/cache.test.js
CHANGED
|
@@ -31,7 +31,7 @@ function createMockRedis() {
|
|
|
31
31
|
expires.delete(key);
|
|
32
32
|
},
|
|
33
33
|
|
|
34
|
-
async scan(
|
|
34
|
+
async scan(_cursor, ...args) {
|
|
35
35
|
// Simple mock: return all matching keys in one batch
|
|
36
36
|
const matchIdx = args.indexOf("MATCH");
|
|
37
37
|
const pattern = matchIdx !== -1 ? args[matchIdx + 1] : "*";
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-core - Email Templates
|
|
3
|
+
*
|
|
4
|
+
* Reusable HTML email templates with plain-text fallbacks.
|
|
5
|
+
* Each template function returns { subject, html, text }.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Escape HTML entities to prevent XSS in user-provided values
|
|
10
|
+
* @param {string} str
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
function escapeHtml(str) {
|
|
14
|
+
return String(str)
|
|
15
|
+
.replace(/&/g, "&")
|
|
16
|
+
.replace(/</g, "<")
|
|
17
|
+
.replace(/>/g, ">")
|
|
18
|
+
.replace(/"/g, """)
|
|
19
|
+
.replace(/'/g, "'");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Shared outer layout for all emails
|
|
24
|
+
* @param {string} body - Inner HTML content
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function layout(body) {
|
|
28
|
+
return `<!DOCTYPE html>
|
|
29
|
+
<html lang="en">
|
|
30
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
31
|
+
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f4f4f5">
|
|
32
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px">
|
|
33
|
+
<tr><td align="center">
|
|
34
|
+
<table width="100%" style="max-width:560px;background:#fff;border-radius:8px;border:1px solid #e4e4e7;padding:32px">
|
|
35
|
+
<tr><td>${body}</td></tr>
|
|
36
|
+
</table>
|
|
37
|
+
<p style="color:#a1a1aa;font-size:12px;margin-top:24px">
|
|
38
|
+
You received this email because an account was created with this address.
|
|
39
|
+
</p>
|
|
40
|
+
</td></tr>
|
|
41
|
+
</table>
|
|
42
|
+
</body>
|
|
43
|
+
</html>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Welcome email — sent after user registration
|
|
48
|
+
*
|
|
49
|
+
* @param {{ name?: string, appName?: string, loginUrl?: string }} data
|
|
50
|
+
* @returns {{ subject: string, html: string, text: string }}
|
|
51
|
+
*/
|
|
52
|
+
export function welcomeEmail(data = {}) {
|
|
53
|
+
const name = data.name ? escapeHtml(data.name) : "there";
|
|
54
|
+
const appName = data.appName || "Quark";
|
|
55
|
+
const loginUrl = data.loginUrl || "";
|
|
56
|
+
|
|
57
|
+
const buttonHtml = loginUrl
|
|
58
|
+
? `<p style="text-align:center;margin:24px 0">
|
|
59
|
+
<a href="${escapeHtml(loginUrl)}" style="display:inline-block;padding:12px 24px;background:#18181b;color:#fff;text-decoration:none;border-radius:6px;font-weight:500">
|
|
60
|
+
Sign in to ${escapeHtml(appName)}
|
|
61
|
+
</a>
|
|
62
|
+
</p>`
|
|
63
|
+
: "";
|
|
64
|
+
|
|
65
|
+
const html = layout(`
|
|
66
|
+
<h1 style="margin:0 0 16px;font-size:24px;color:#18181b">Welcome, ${name}!</h1>
|
|
67
|
+
<p style="color:#3f3f46;line-height:1.6;margin:0 0 16px">
|
|
68
|
+
Your account has been created successfully. You can now sign in and start using ${escapeHtml(appName)}.
|
|
69
|
+
</p>
|
|
70
|
+
${buttonHtml}
|
|
71
|
+
<p style="color:#71717a;font-size:14px;margin:0">
|
|
72
|
+
If you didn't create this account, you can safely ignore this email.
|
|
73
|
+
</p>
|
|
74
|
+
`);
|
|
75
|
+
|
|
76
|
+
const text = [
|
|
77
|
+
`Welcome, ${data.name || "there"}!`,
|
|
78
|
+
"",
|
|
79
|
+
`Your account has been created successfully. You can now sign in and start using ${appName}.`,
|
|
80
|
+
...(loginUrl ? ["", `Sign in: ${loginUrl}`] : []),
|
|
81
|
+
"",
|
|
82
|
+
"If you didn't create this account, you can safely ignore this email.",
|
|
83
|
+
].join("\n");
|
|
84
|
+
|
|
85
|
+
return { subject: `Welcome to ${appName}!`, html, text };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Password reset email — sent when user requests a reset
|
|
90
|
+
*
|
|
91
|
+
* @param {{ name?: string, resetUrl: string, appName?: string, expiresIn?: string }} data
|
|
92
|
+
* @returns {{ subject: string, html: string, text: string }}
|
|
93
|
+
*/
|
|
94
|
+
export function passwordResetEmail(data) {
|
|
95
|
+
if (!data?.resetUrl) {
|
|
96
|
+
throw new Error("resetUrl is required for password reset email");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const name = data.name ? escapeHtml(data.name) : "there";
|
|
100
|
+
const appName = data.appName || "Quark";
|
|
101
|
+
const expiresIn = data.expiresIn || "1 hour";
|
|
102
|
+
|
|
103
|
+
const html = layout(`
|
|
104
|
+
<h1 style="margin:0 0 16px;font-size:24px;color:#18181b">Reset your password</h1>
|
|
105
|
+
<p style="color:#3f3f46;line-height:1.6;margin:0 0 16px">
|
|
106
|
+
Hi ${name}, we received a request to reset your ${escapeHtml(appName)} password.
|
|
107
|
+
</p>
|
|
108
|
+
<p style="text-align:center;margin:24px 0">
|
|
109
|
+
<a href="${escapeHtml(data.resetUrl)}" style="display:inline-block;padding:12px 24px;background:#18181b;color:#fff;text-decoration:none;border-radius:6px;font-weight:500">
|
|
110
|
+
Reset password
|
|
111
|
+
</a>
|
|
112
|
+
</p>
|
|
113
|
+
<p style="color:#71717a;font-size:14px;margin:0 0 8px">
|
|
114
|
+
This link will expire in ${escapeHtml(expiresIn)}.
|
|
115
|
+
</p>
|
|
116
|
+
<p style="color:#71717a;font-size:14px;margin:0">
|
|
117
|
+
If you didn't request this, you can safely ignore this email. Your password will not be changed.
|
|
118
|
+
</p>
|
|
119
|
+
`);
|
|
120
|
+
|
|
121
|
+
const text = [
|
|
122
|
+
`Reset your password`,
|
|
123
|
+
"",
|
|
124
|
+
`Hi ${data.name || "there"}, we received a request to reset your ${appName} password.`,
|
|
125
|
+
"",
|
|
126
|
+
`Reset your password: ${data.resetUrl}`,
|
|
127
|
+
"",
|
|
128
|
+
`This link will expire in ${expiresIn}.`,
|
|
129
|
+
"",
|
|
130
|
+
"If you didn't request this, you can safely ignore this email. Your password will not be changed.",
|
|
131
|
+
].join("\n");
|
|
132
|
+
|
|
133
|
+
return { subject: `Reset your ${appName} password`, html, text };
|
|
134
|
+
}
|