@techstream/quark-create-app 1.2.0 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techstream/quark-create-app",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "quark-create-app": "src/index.js",
@@ -29,6 +29,7 @@
29
29
  "access": "public"
30
30
  },
31
31
  "engines": {
32
- "node": ">=24"
33
- }
32
+ "node": ">=22"
33
+ },
34
+ "license": "ISC"
34
35
  }
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import crypto from "node:crypto";
3
+ import net from "node:net";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import chalk from "chalk";
@@ -47,6 +48,48 @@ function generateSecurePassword(length = 24) {
47
48
  return result;
48
49
  }
49
50
 
51
+ /**
52
+ * Check if a TCP port is available on localhost.
53
+ * Uses a connect test (not bind) to reliably detect Docker-bound ports on macOS.
54
+ * @param {number} port
55
+ * @returns {Promise<boolean>}
56
+ */
57
+ function isPortAvailable(port) {
58
+ return new Promise((resolve) => {
59
+ const socket = new net.Socket();
60
+ socket.setTimeout(500);
61
+ socket.once("connect", () => {
62
+ socket.destroy();
63
+ resolve(false); // something is listening — port is in use
64
+ });
65
+ socket.once("error", () => {
66
+ socket.destroy();
67
+ resolve(true); // ECONNREFUSED — port is free
68
+ });
69
+ socket.once("timeout", () => {
70
+ socket.destroy();
71
+ resolve(true); // no response — port is free
72
+ });
73
+ socket.connect(port, "127.0.0.1");
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Find the next available port starting from a given port
79
+ * @param {number} startPort
80
+ * @param {number} [maxAttempts=20]
81
+ * @returns {Promise<number>}
82
+ */
83
+ async function findAvailablePort(startPort, maxAttempts = 20) {
84
+ for (let i = 0; i < maxAttempts; i++) {
85
+ const port = startPort + i;
86
+ if (await isPortAvailable(port)) {
87
+ return port;
88
+ }
89
+ }
90
+ return startPort; // fallback to default if all checked ports are busy
91
+ }
92
+
50
93
  /**
51
94
  * Copy a template directory to the target location, with variable substitution
52
95
  */
@@ -131,8 +174,8 @@ function replaceDepsScope(deps, scope, selectedPackages) {
131
174
  if (key.startsWith("@techstream/quark-") && value === "workspace:*") {
132
175
  const packageName = key.replace("@techstream/quark-", "");
133
176
  delete deps[key];
134
- // Only keep the dep if the package was selected (or is "db" which is always required)
135
- if (packageName === "db" || selectedPackages.includes(packageName)) {
177
+ // Only keep the dep if the package was selected (or is always required)
178
+ if (packageName === "db" || packageName === "config" || selectedPackages.includes(packageName)) {
136
179
  deps[`@${scope}/${packageName}`] = value;
137
180
  }
138
181
  }
@@ -210,8 +253,30 @@ program
210
253
 
211
254
  // Check if directory already exists
212
255
  if (await fs.pathExists(targetDir)) {
213
- console.error(chalk.red(`✗ Directory already exists: ${targetDir}`));
214
- process.exit(1);
256
+ const { overwrite } = await prompts({
257
+ type: "confirm",
258
+ name: "overwrite",
259
+ message: `Directory "${projectName}" already exists. Remove it and recreate?`,
260
+ initial: false,
261
+ });
262
+
263
+ if (!overwrite) {
264
+ console.log(chalk.yellow("Aborted."));
265
+ process.exit(1);
266
+ }
267
+
268
+ // Clean up Docker resources (volumes hold old credentials)
269
+ try {
270
+ await execa("docker", ["compose", "down", "-v"], {
271
+ cwd: targetDir,
272
+ stdio: "ignore",
273
+ });
274
+ console.log(chalk.green(" ✓ Cleaned up Docker volumes"));
275
+ } catch {
276
+ // No docker-compose file or Docker not running — fine
277
+ }
278
+
279
+ await fs.remove(targetDir);
215
280
  }
216
281
 
217
282
  try {
@@ -233,25 +298,33 @@ program
233
298
  // Step 4: Copy required packages (always included)
234
299
  console.log(chalk.cyan("\n 📦 Setting up required packages..."));
235
300
 
236
- // Database package is always required (already copied from base-project)
237
- // Just update its package.json name
238
- const dbPackageDir = path.join(targetDir, "packages", "db");
239
- const dbPackageJsonPath = path.join(dbPackageDir, "package.json");
240
- const dbPackageJson = await fs.readJSON(dbPackageJsonPath);
241
- dbPackageJson.name = `@${scope}/db`;
242
- await fs.writeFile(
243
- dbPackageJsonPath,
244
- `${JSON.stringify(dbPackageJson, null, 2)}\n`,
245
- );
246
- console.log(chalk.green(` ✓ db (required)`));
301
+ // Database and config packages are always required
302
+ const requiredPackages = ["db", "config"];
303
+ for (const reqPkg of requiredPackages) {
304
+ const pkgDir = path.join(targetDir, "packages", reqPkg);
305
+ // db is already copied from base-project; config needs to be copied from its template
306
+ if (!(await fs.pathExists(pkgDir))) {
307
+ await fs.ensureDir(pkgDir);
308
+ await copyTemplate(reqPkg, pkgDir);
309
+ }
310
+ const pkgJsonPath = path.join(pkgDir, "package.json");
311
+ const pkgJson = await fs.readJSON(pkgJsonPath);
312
+ pkgJson.name = `@${scope}/${reqPkg}`;
313
+ await fs.writeFile(
314
+ pkgJsonPath,
315
+ `${JSON.stringify(pkgJson, null, 2)}\n`,
316
+ );
317
+ console.log(chalk.green(` ✓ ${reqPkg} (required)`));
318
+ }
247
319
 
248
320
  // Step 5: Ask which optional features to eject
249
- console.log(chalk.cyan("\n 🎯 Configuring optional features..."));
321
+ console.log(chalk.cyan("\n 🎯 Configuring optional features...\n"));
250
322
  const response = await prompts([
251
323
  {
252
324
  type: "multiselect",
253
325
  name: "features",
254
326
  message: "Which optional packages would you like to include?",
327
+ instructions: false,
255
328
  choices: [
256
329
  {
257
330
  title: "UI Components (packages/ui)",
@@ -263,11 +336,6 @@ program
263
336
  value: "jobs",
264
337
  selected: true,
265
338
  },
266
- {
267
- title: "Configuration (packages/config)",
268
- value: "config",
269
- selected: false,
270
- },
271
339
  ],
272
340
  },
273
341
  ]);
@@ -298,27 +366,47 @@ program
298
366
  }
299
367
  }
300
368
 
301
- // Step 7: Update app dependencies to use correct scope
369
+ // Step 7: Update all package.json dependencies to use correct scope
302
370
  console.log(chalk.cyan("\n 🔧 Updating app dependencies..."));
303
371
 
304
- // Update app package.json files to use correct scope
305
- const appPaths = [
372
+ // Collect all package.json files that need scope replacement (apps + packages)
373
+ const allPkgPaths = [
306
374
  path.join(targetDir, "apps", "web", "package.json"),
307
375
  path.join(targetDir, "apps", "worker", "package.json"),
376
+ // Also update cross-dependencies in scaffolded packages (e.g. db → config)
377
+ ...["db", ...features].map((pkg) =>
378
+ path.join(targetDir, "packages", pkg, "package.json"),
379
+ ),
308
380
  ];
309
381
 
310
- for (const appPkgPath of appPaths) {
311
- if (await fs.pathExists(appPkgPath)) {
312
- const appPkg = await fs.readJSON(appPkgPath);
313
- replaceDepsScope(appPkg.dependencies, scope, features);
314
- replaceDepsScope(appPkg.devDependencies, scope, features);
382
+ for (const pkgPath of allPkgPaths) {
383
+ if (await fs.pathExists(pkgPath)) {
384
+ const pkg = await fs.readJSON(pkgPath);
385
+ // Rename package name if it uses @quark/ prefix
386
+ if (pkg.name && pkg.name.startsWith("@quark/")) {
387
+ const shortName = pkg.name.replace("@quark/", "");
388
+ pkg.name = `@${scope}/${shortName}`;
389
+ }
390
+ replaceDepsScope(pkg.dependencies, scope, features);
391
+ replaceDepsScope(pkg.devDependencies, scope, features);
315
392
  await fs.writeFile(
316
- appPkgPath,
317
- `${JSON.stringify(appPkg, null, 2)}\n`,
393
+ pkgPath,
394
+ `${JSON.stringify(pkg, null, 2)}\n`,
318
395
  );
319
396
  }
320
397
  }
321
398
 
399
+ // Also rename root package.json
400
+ const rootPkgPath = path.join(targetDir, "package.json");
401
+ if (await fs.pathExists(rootPkgPath)) {
402
+ const rootPkg = await fs.readJSON(rootPkgPath);
403
+ rootPkg.name = `@${scope}/root`;
404
+ await fs.writeFile(
405
+ rootPkgPath,
406
+ `${JSON.stringify(rootPkg, null, 2)}\n`,
407
+ );
408
+ }
409
+
322
410
  console.log(chalk.green(` ✓ App dependencies updated`));
323
411
 
324
412
  // Step 7b: Replace workspace package imports in source files
@@ -354,11 +442,9 @@ MAILHOG_UI_PORT=8025
354
442
  # MAILHOG_SMTP_URL="smtp://localhost:1025"
355
443
 
356
444
  # --- Application URL ---
357
- # The canonical URL of your application.
358
- # NEXTAUTH_URL, CORS origins, and other URL-dependent settings are derived from this.
359
- # Development: http://localhost:3000
360
- # Production: https://yourdomain.com
361
- APP_URL=http://localhost:3000
445
+ # In development, APP_URL is derived automatically from PORT — no need to set it.
446
+ # In production, set this to your real domain:
447
+ # APP_URL=https://yourdomain.com
362
448
 
363
449
  # --- NextAuth Configuration ---
364
450
  # ⚠️ CRITICAL: Generate a secure secret with: openssl rand -base64 32
@@ -375,7 +461,7 @@ NEXTAUTH_SECRET=CHANGE_ME_TO_STRONG_SECRET
375
461
  # GOOGLE_CLIENT_SECRET=your_google_client_secret
376
462
 
377
463
  # --- Web App Configuration ---
378
- WEB_PORT=3000
464
+ PORT=3000
379
465
 
380
466
  # --- Worker Configuration ---
381
467
  WORKER_CONCURRENCY=5
@@ -386,7 +472,30 @@ WORKER_CONCURRENCY=5
386
472
  );
387
473
  console.log(chalk.green(` ✓ .env.example`));
388
474
 
389
- // Step 9: Generate .env with secure defaults
475
+ // Step 9: Find available ports and generate .env
476
+ console.log(chalk.cyan("\n 🔌 Checking port availability..."));
477
+ const postgresPort = await findAvailablePort(5432);
478
+ const redisPort = await findAvailablePort(6379);
479
+ const mailSmtpPort = await findAvailablePort(1025);
480
+ const mailUiPort = await findAvailablePort(8025);
481
+ const webPort = await findAvailablePort(3000);
482
+
483
+ const portChanges = [];
484
+ if (postgresPort !== 5432) portChanges.push(`PostgreSQL: ${postgresPort}`);
485
+ if (redisPort !== 6379) portChanges.push(`Redis: ${redisPort}`);
486
+ if (mailSmtpPort !== 1025) portChanges.push(`Mail SMTP: ${mailSmtpPort}`);
487
+ if (mailUiPort !== 8025) portChanges.push(`Mail UI: ${mailUiPort}`);
488
+ if (webPort !== 3000) portChanges.push(`Web: ${webPort}`);
489
+
490
+ if (portChanges.length > 0) {
491
+ console.log(chalk.yellow(` ⚡ Ports adjusted to avoid conflicts:`));
492
+ for (const change of portChanges) {
493
+ console.log(chalk.yellow(` • ${change}`));
494
+ }
495
+ } else {
496
+ console.log(chalk.green(` ✓ All default ports available`));
497
+ }
498
+
390
499
  console.log(chalk.cyan("\n 🔑 Generating secure environment file..."));
391
500
 
392
501
  // Generate secure random values
@@ -396,28 +505,27 @@ WORKER_CONCURRENCY=5
396
505
  // Create .env with auto-generated secure values
397
506
  const envContent = `# --- Database Configuration ---
398
507
  POSTGRES_HOST=localhost
399
- POSTGRES_PORT=5432
508
+ POSTGRES_PORT=${postgresPort}
400
509
  POSTGRES_USER=quark_user
401
510
  POSTGRES_PASSWORD=${dbPassword}
402
511
  POSTGRES_DB=${scope}_dev
403
512
 
404
513
  # --- Redis Configuration ---
405
514
  REDIS_HOST=localhost
406
- REDIS_PORT=6379
515
+ REDIS_PORT=${redisPort}
407
516
 
408
- # --- Mailhog Configuration ---
517
+ # --- Mail Configuration ---
409
518
  MAILHOG_HOST=localhost
410
- MAILHOG_SMTP_PORT=1025
411
- MAILHOG_UI_PORT=8025
519
+ MAILHOG_SMTP_PORT=${mailSmtpPort}
520
+ MAILHOG_UI_PORT=${mailUiPort}
412
521
 
413
522
  # --- NextAuth Configuration ---
414
523
  NEXTAUTH_SECRET=${nextAuthSecret}
415
524
 
416
- # --- Application URL ---
417
- APP_URL=http://localhost:3000
418
-
419
525
  # --- Web App Configuration ---
420
- WEB_PORT=3000
526
+ # APP_URL is derived from PORT automatically in development.
527
+ # In production, set APP_URL explicitly in your environment.
528
+ PORT=${webPort}
421
529
 
422
530
  # --- Worker Configuration ---
423
531
  WORKER_CONCURRENCY=5
@@ -432,7 +540,7 @@ WORKER_CONCURRENCY=5
432
540
  quarkVersion: process.env.QUARK_VERSION || "latest",
433
541
  quarkSourcePath: process.env.QUARK_SOURCE_PATH || "../../quark",
434
542
  scaffoldedDate: new Date().toISOString(),
435
- requiredPackages: ["db"],
543
+ requiredPackages: ["db", "config"],
436
544
  packages: features,
437
545
  };
438
546
  await fs.writeFile(
@@ -469,6 +577,27 @@ WORKER_CONCURRENCY=5
469
577
  );
470
578
  }
471
579
 
580
+ // Step 13: Generate Prisma client
581
+ console.log(chalk.cyan("\n 🗄️ Generating Prisma client..."));
582
+ try {
583
+ await execa("pnpm", ["--filter", "db", "db:generate"], {
584
+ cwd: targetDir,
585
+ stdio: "inherit",
586
+ });
587
+ console.log(chalk.green(` ✓ Prisma client generated`));
588
+ } catch (generateError) {
589
+ console.warn(
590
+ chalk.yellow(
591
+ `\n ⚠️ Prisma generate failed: ${generateError.message}`,
592
+ ),
593
+ );
594
+ console.warn(
595
+ chalk.yellow(
596
+ ` Run 'pnpm --filter db db:generate' manually.`,
597
+ ),
598
+ );
599
+ }
600
+
472
601
  // Success message
473
602
  console.log(
474
603
  chalk.green.bold(
@@ -480,7 +609,8 @@ WORKER_CONCURRENCY=5
480
609
  console.log(chalk.cyan("Next steps:"));
481
610
  console.log(chalk.white(` 1. cd ${projectName}`));
482
611
  console.log(chalk.white(` 2. docker compose up -d`));
483
- console.log(chalk.white(` 3. pnpm dev\n`));
612
+ console.log(chalk.white(` 3. pnpm --filter db db:push`));
613
+ console.log(chalk.white(` 4. pnpm dev\n`));
484
614
 
485
615
  console.log(chalk.cyan("Important:"));
486
616
  console.log(
@@ -1,8 +1,10 @@
1
- import { AppError } from "@techstream/quark-core";
1
+ import { AppError, createLogger } from "@techstream/quark-core";
2
2
  import { NextResponse } from "next/server";
3
3
 
4
+ const logger = createLogger("api");
5
+
4
6
  export function handleError(error) {
5
- console.error("API Error:", error);
7
+ logger.error("API Error", { error: error.message, stack: error.stack });
6
8
 
7
9
  if (error instanceof AppError) {
8
10
  return NextResponse.json(error.toJSON(), { status: error.statusCode });
@@ -4,10 +4,12 @@
4
4
  * Times out after 5 seconds to prevent hanging.
5
5
  */
6
6
 
7
- import { pingRedis } from "@techstream/quark-core";
7
+ import { createLogger, pingRedis } from "@techstream/quark-core";
8
8
  import { prisma } from "@techstream/quark-db";
9
9
  import { NextResponse } from "next/server";
10
10
 
11
+ const logger = createLogger("health");
12
+
11
13
  /** Overall timeout for the health check (ms). */
12
14
  const HEALTH_CHECK_TIMEOUT = 5000;
13
15
 
@@ -27,12 +29,12 @@ export async function GET() {
27
29
  status: result.status === "ok" ? 200 : 503,
28
30
  });
29
31
  } catch (error) {
30
- console.error("Health check failed:", error);
32
+ logger.error("Health check failed", { error: error.message, stack: error.stack });
31
33
  return NextResponse.json(
32
34
  {
33
35
  status: "error",
34
36
  timestamp: new Date().toISOString(),
35
- message: error.message,
37
+ message: "Service health check failed",
36
38
  },
37
39
  { status: 500 },
38
40
  );
@@ -1,4 +1,4 @@
1
- import { UnauthorizedError, validateBody } from "@techstream/quark-core";
1
+ import { UnauthorizedError, validateBody, withCsrfProtection } from "@techstream/quark-core";
2
2
  import { post, postUpdateSchema } from "@techstream/quark-db";
3
3
  import { NextResponse } from "next/server";
4
4
  import { requireAuth } from "@/lib/auth-middleware";
@@ -17,7 +17,7 @@ export async function GET(_request, { params }) {
17
17
  }
18
18
  }
19
19
 
20
- export async function PATCH(request, { params }) {
20
+ export const PATCH = withCsrfProtection(async (request, { params }) => {
21
21
  try {
22
22
  const session = await requireAuth();
23
23
  const { id } = await params;
@@ -37,9 +37,9 @@ export async function PATCH(request, { params }) {
37
37
  } catch (error) {
38
38
  return handleError(error);
39
39
  }
40
- }
40
+ });
41
41
 
42
- export async function DELETE(_request, { params }) {
42
+ export const DELETE = withCsrfProtection(async (_request, { params }) => {
43
43
  try {
44
44
  const session = await requireAuth();
45
45
  const { id } = await params;
@@ -58,4 +58,4 @@ export async function DELETE(_request, { params }) {
58
58
  } catch (error) {
59
59
  return handleError(error);
60
60
  }
61
- }
61
+ });
@@ -1,14 +1,22 @@
1
- import { validateBody } from "@techstream/quark-core";
1
+ import { validateBody, withCsrfProtection } from "@techstream/quark-core";
2
2
  import { post, postCreateSchema } from "@techstream/quark-db";
3
3
  import { NextResponse } from "next/server";
4
+ import { z } from "zod";
4
5
  import { requireAuth } from "@/lib/auth-middleware";
5
6
  import { handleError } from "../error-handler";
6
7
 
8
+ const paginationSchema = z.object({
9
+ page: z.coerce.number().int().min(1).default(1),
10
+ limit: z.coerce.number().int().min(1).max(100).default(10),
11
+ });
12
+
7
13
  export async function GET(request) {
8
14
  try {
9
15
  const { searchParams } = new URL(request.url);
10
- const page = parseInt(searchParams.get("page") || "1", 10);
11
- const limit = parseInt(searchParams.get("limit") || "10", 10);
16
+ const { page, limit } = paginationSchema.parse({
17
+ page: searchParams.get("page") ?? undefined,
18
+ limit: searchParams.get("limit") ?? undefined,
19
+ });
12
20
  const skip = (page - 1) * limit;
13
21
 
14
22
  const posts = await post.findAll({ skip, take: limit });
@@ -18,7 +26,7 @@ export async function GET(request) {
18
26
  }
19
27
  }
20
28
 
21
- export async function POST(request) {
29
+ export const POST = withCsrfProtection(async (request) => {
22
30
  try {
23
31
  const session = await requireAuth();
24
32
  const data = await validateBody(request, postCreateSchema);
@@ -31,4 +39,4 @@ export async function POST(request) {
31
39
  } catch (error) {
32
40
  return handleError(error);
33
41
  }
34
- }
42
+ });
@@ -1,12 +1,12 @@
1
- import { validateBody } from "@techstream/quark-core";
1
+ import { validateBody, withCsrfProtection } from "@techstream/quark-core";
2
2
  import { user, userUpdateSchema } from "@techstream/quark-db";
3
3
  import { NextResponse } from "next/server";
4
- import { requireAuth } from "@/lib/auth-middleware";
4
+ import { requireRole } from "@/lib/auth-middleware";
5
5
  import { handleError } from "../../error-handler";
6
6
 
7
7
  export async function GET(_request, { params }) {
8
8
  try {
9
- await requireAuth();
9
+ await requireRole("admin");
10
10
  const { id } = await params;
11
11
  const foundUser = await user.findById(id);
12
12
  if (!foundUser) {
@@ -18,9 +18,9 @@ export async function GET(_request, { params }) {
18
18
  }
19
19
  }
20
20
 
21
- export async function PATCH(request, { params }) {
21
+ export const PATCH = withCsrfProtection(async (request, { params }) => {
22
22
  try {
23
- await requireAuth();
23
+ await requireRole("admin");
24
24
  const { id } = await params;
25
25
 
26
26
  const existingUser = await user.findById(id);
@@ -34,11 +34,11 @@ export async function PATCH(request, { params }) {
34
34
  } catch (error) {
35
35
  return handleError(error);
36
36
  }
37
- }
37
+ });
38
38
 
39
- export async function DELETE(_request, { params }) {
39
+ export const DELETE = withCsrfProtection(async (_request, { params }) => {
40
40
  try {
41
- await requireAuth();
41
+ await requireRole("admin");
42
42
  const { id } = await params;
43
43
 
44
44
  const existingUser = await user.findById(id);
@@ -51,4 +51,4 @@ export async function DELETE(_request, { params }) {
51
51
  } catch (error) {
52
52
  return handleError(error);
53
53
  }
54
- }
54
+ });
@@ -1,12 +1,12 @@
1
- import { validateBody } from "@techstream/quark-core";
1
+ import { validateBody, withCsrfProtection } from "@techstream/quark-core";
2
2
  import { user, userCreateSchema } from "@techstream/quark-db";
3
3
  import { NextResponse } from "next/server";
4
- import { requireAuth } from "@/lib/auth-middleware";
4
+ import { requireRole } from "@/lib/auth-middleware";
5
5
  import { handleError } from "../error-handler";
6
6
 
7
7
  export async function GET(_request) {
8
8
  try {
9
- await requireAuth();
9
+ await requireRole("admin");
10
10
  const users = await user.findAll();
11
11
  return NextResponse.json(users);
12
12
  } catch (error) {
@@ -14,9 +14,9 @@ export async function GET(_request) {
14
14
  }
15
15
  }
16
16
 
17
- export async function POST(request) {
17
+ export const POST = withCsrfProtection(async (request) => {
18
18
  try {
19
- await requireAuth();
19
+ await requireRole("admin");
20
20
  const data = await validateBody(request, userCreateSchema);
21
21
 
22
22
  // Check if email already exists
@@ -33,4 +33,4 @@ export async function POST(request) {
33
33
  } catch (error) {
34
34
  return handleError(error);
35
35
  }
36
- }
36
+ });
@@ -1,4 +1,4 @@
1
- import { UnauthorizedError } from "@techstream/quark-core";
1
+ import { ForbiddenError, UnauthorizedError } from "@techstream/quark-core";
2
2
  import { auth } from "./auth";
3
3
 
4
4
  export async function requireAuth() {
@@ -12,3 +12,20 @@ export async function requireAuth() {
12
12
 
13
13
  return session;
14
14
  }
15
+
16
+ /**
17
+ * Require the current user to have a specific role.
18
+ * @param {string} role - Required role (e.g. "admin")
19
+ * @returns {Promise<import("next-auth").Session>}
20
+ */
21
+ export async function requireRole(role) {
22
+ const session = await requireAuth();
23
+
24
+ if (session.user?.role !== role) {
25
+ throw new ForbiddenError(
26
+ "You do not have permission to access this resource",
27
+ );
28
+ }
29
+
30
+ return session;
31
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Next.js Middleware
2
+ * Next.js Proxy
3
3
  * Handles rate limiting, CORS, and security headers
4
4
  */
5
5
 
@@ -112,7 +112,7 @@ const REQUEST_SIZE_LIMITS = {
112
112
  upload: parseInt(process.env.UPLOAD_SIZE_LIMIT || "10485760", 10), // 10MB for uploads
113
113
  };
114
114
 
115
- export function middleware(request) {
115
+ export function proxy(request) {
116
116
  const { pathname } = request.nextUrl;
117
117
  const origin = request.headers.get("origin") || "";
118
118
 
@@ -250,7 +250,7 @@ export function middleware(request) {
250
250
  return response;
251
251
  }
252
252
 
253
- // Configure which routes the middleware runs on
253
+ // Configure which routes the proxy runs on
254
254
  export const config = {
255
255
  matcher: [
256
256
  /*
@@ -17,8 +17,7 @@
17
17
  "@techstream/quark-core": "^1.0.0",
18
18
  "@techstream/quark-db": "workspace:*",
19
19
  "@techstream/quark-jobs": "workspace:*",
20
- "bullmq": "^5.64.1",
21
- "dotenv": "^17.2.3"
20
+ "bullmq": "^5.64.1"
22
21
  },
23
22
  "devDependencies": {
24
23
  "@techstream/quark-config": "workspace:*",
@@ -11,10 +11,6 @@ import {
11
11
  } from "@techstream/quark-core";
12
12
  import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
13
13
  import { prisma } from "@techstream/quark-db";
14
- import dotenv from "dotenv";
15
-
16
- // Load environment variables
17
- dotenv.config();
18
14
 
19
15
  const logger = createLogger("worker");
20
16
 
@@ -24,6 +20,18 @@ const workers = [];
24
20
  // Initialize email service
25
21
  const emailService = createEmailService();
26
22
 
23
+ /**
24
+ * Escape HTML entities to prevent XSS in email body
25
+ */
26
+ function escapeHtml(str) {
27
+ return str
28
+ .replace(/&/g, "&amp;")
29
+ .replace(/</g, "&lt;")
30
+ .replace(/>/g, "&gt;")
31
+ .replace(/"/g, "&quot;")
32
+ .replace(/'/g, "&#039;");
33
+ }
34
+
27
35
  /**
28
36
  * Job handler for SEND_WELCOME_EMAIL
29
37
  * @param {Job} bullJob - BullMQ job object
@@ -51,11 +59,12 @@ async function handleSendWelcomeEmail(bullJob) {
51
59
  }
52
60
 
53
61
  const displayName = userRecord.name || "there";
62
+ const safeDisplayName = escapeHtml(displayName);
54
63
 
55
64
  await emailService.sendEmail(
56
65
  userRecord.email,
57
66
  "Welcome to Quark!",
58
- `<h1>Welcome, ${displayName}!</h1>
67
+ `<h1>Welcome, ${safeDisplayName}!</h1>
59
68
  <p>Your account has been created successfully.</p>
60
69
  <p>You can now sign in and start using the application.</p>`,
61
70
  `Welcome, ${displayName}!\n\nYour account has been created successfully.\nYou can now sign in and start using the application.`,
@@ -2,7 +2,6 @@ services:
2
2
  # --- 1. PostgreSQL Database ---
3
3
  postgres:
4
4
  image: postgres:16-alpine
5
- container_name: postgres
6
5
  restart: always
7
6
  ports:
8
7
  - "${POSTGRES_PORT:-5432}:5432"
@@ -16,7 +15,6 @@ services:
16
15
  # --- 2. Redis Cache & Job Queue ---
17
16
  redis:
18
17
  image: redis:7-alpine
19
- container_name: redis
20
18
  restart: always
21
19
  ports:
22
20
  - "${REDIS_PORT:-6379}:6379"
@@ -24,10 +22,9 @@ services:
24
22
  volumes:
25
23
  - redis_data:/data
26
24
 
27
- # --- 3. Mailhog (Local SMTP Server) ---
28
- mailhog:
29
- image: mailhog/mailhog
30
- container_name: mailhog
25
+ # --- 3. Mailpit (Local SMTP Server) ---
26
+ mailpit:
27
+ image: ghcr.io/axllent/mailpit
31
28
  restart: always
32
29
  ports:
33
30
  # SMTP port (used by application to send mail)
@@ -6,7 +6,7 @@
6
6
  "main": "index.js",
7
7
  "scripts": {
8
8
  "build": "turbo run build",
9
- "dev": "turbo run dev",
9
+ "dev": "dotenv -- turbo run dev",
10
10
  "lint": "turbo run lint",
11
11
  "test": "turbo run test",
12
12
  "docker:up": "docker compose up -d",
@@ -17,9 +17,18 @@
17
17
  "author": "",
18
18
  "license": "ISC",
19
19
  "packageManager": "pnpm@10.12.1",
20
+ "pnpm": {
21
+ "onlyBuiltDependencies": ["@prisma/engines", "esbuild", "msgpackr-extract", "prisma", "sharp"],
22
+ "peerDependencyRules": {
23
+ "allowedVersions": {
24
+ "nodemailer": "*"
25
+ }
26
+ }
27
+ },
20
28
  "devDependencies": {
21
29
  "@biomejs/biome": "^2.3.13",
22
30
  "@types/node": "^24.10.9",
31
+ "dotenv-cli": "^11.0.0",
23
32
  "tsx": "^4.21.0",
24
33
  "turbo": "^2.8.1"
25
34
  }
@@ -10,20 +10,26 @@
10
10
  "db:generate": "prisma generate",
11
11
  "db:migrate": "prisma migrate dev",
12
12
  "db:push": "prisma db push",
13
- "db:seed": "node scripts/seed.js",
13
+ "db:seed": "prisma db seed",
14
14
  "db:studio": "prisma studio"
15
15
  },
16
+ "prisma": {
17
+ "seed": "node scripts/seed.js"
18
+ },
16
19
  "keywords": [],
17
20
  "author": "",
18
21
  "license": "ISC",
19
22
  "packageManager": "pnpm@10.12.1",
20
23
  "devDependencies": {
21
24
  "@techstream/quark-config": "workspace:*",
22
- "prisma": "^7.0.0"
25
+ "bcryptjs": "^3.0.3",
26
+ "prisma": "^7.3.0"
23
27
  },
24
28
  "dependencies": {
25
- "@prisma/client": "^7.0.0",
26
- "dotenv": "^17.2.3",
29
+ "@prisma/adapter-pg": "^7.3.0",
30
+ "@prisma/client": "^7.3.0",
31
+ "dotenv": "^17.2.4",
32
+ "pg": "^8.18.0",
27
33
  "zod": "^4.3.6"
28
34
  }
29
35
  }
@@ -2,8 +2,8 @@ import { resolve } from "node:path";
2
2
  import { config } from "dotenv";
3
3
  import { defineConfig } from "prisma/config";
4
4
 
5
- // Load .env from monorepo root
6
- config({ path: resolve(__dirname, "../../.env") });
5
+ // Load .env from monorepo root (needed for standalone commands like db:push, db:seed)
6
+ config({ path: resolve(__dirname, "../../.env"), quiet: true });
7
7
 
8
8
  // Construct DATABASE_URL from individual env vars - single source of truth
9
9
  const user = process.env.POSTGRES_USER || "quark_user";
@@ -49,4 +49,4 @@ if (!isProduction) {
49
49
  globalForPrisma.prisma = prisma;
50
50
  }
51
51
 
52
- export * from "./generated/prisma/client.js";
52
+ export * from "./generated/prisma/client.ts";
@@ -67,12 +67,17 @@ export const user = {
67
67
  },
68
68
  };
69
69
 
70
+ /**
71
+ * Safe author include — returns author without sensitive fields.
72
+ */
73
+ const AUTHOR_SAFE_INCLUDE = { author: { select: USER_SAFE_SELECT } };
74
+
70
75
  // Post queries
71
76
  export const post = {
72
77
  findById: (id) => {
73
78
  return prisma.post.findUnique({
74
79
  where: { id },
75
- include: { author: true },
80
+ include: AUTHOR_SAFE_INCLUDE,
76
81
  });
77
82
  },
78
83
  findAll: (options = {}) => {
@@ -80,7 +85,7 @@ export const post = {
80
85
  return prisma.post.findMany({
81
86
  skip,
82
87
  take,
83
- include: { author: true },
88
+ include: AUTHOR_SAFE_INCLUDE,
84
89
  orderBy: { createdAt: "desc" },
85
90
  });
86
91
  },
@@ -90,7 +95,7 @@ export const post = {
90
95
  where: { published: true },
91
96
  skip,
92
97
  take,
93
- include: { author: true },
98
+ include: AUTHOR_SAFE_INCLUDE,
94
99
  orderBy: { createdAt: "desc" },
95
100
  });
96
101
  },
@@ -100,21 +105,21 @@ export const post = {
100
105
  where: { authorId },
101
106
  skip,
102
107
  take,
103
- include: { author: true },
108
+ include: AUTHOR_SAFE_INCLUDE,
104
109
  orderBy: { createdAt: "desc" },
105
110
  });
106
111
  },
107
112
  create: (data) => {
108
113
  return prisma.post.create({
109
114
  data,
110
- include: { author: true },
115
+ include: AUTHOR_SAFE_INCLUDE,
111
116
  });
112
117
  },
113
118
  update: (id, data) => {
114
119
  return prisma.post.update({
115
120
  where: { id },
116
121
  data,
117
- include: { author: true },
122
+ include: AUTHOR_SAFE_INCLUDE,
118
123
  });
119
124
  },
120
125
  delete: (id) => {
@@ -122,7 +127,15 @@ export const post = {
122
127
  where: { id },
123
128
  });
124
129
  },
125
- };\n\n// Note: Job tracking is handled by BullMQ's built-in Redis persistence.\n// The Prisma Job model is retained in the schema for optional audit/reporting\n// but these query helpers have been removed to avoid confusion with BullMQ.\n// If you need database-backed job auditing, re-add job queries here and wire\n// the worker to write status updates to the Job table.\n\n// Account queries (NextAuth)
130
+ };
131
+
132
+ // Note: Job tracking is handled by BullMQ's built-in Redis persistence.
133
+ // The Prisma Job model is retained in the schema for optional audit/reporting
134
+ // but these query helpers have been removed to avoid confusion with BullMQ.
135
+ // If you need database-backed job auditing, re-add job queries here and wire
136
+ // the worker to write status updates to the Job table.
137
+
138
+ // Account queries (NextAuth)
126
139
  export const account = {
127
140
  findById: (id) => {
128
141
  return prisma.account.findUnique({
@@ -156,7 +169,7 @@ export const session = {
156
169
  findByToken: (sessionToken) => {
157
170
  return prisma.session.findUnique({
158
171
  where: { sessionToken },
159
- include: { user: true },
172
+ include: { user: { select: USER_SAFE_SELECT } },
160
173
  });
161
174
  },
162
175
  findByUserId: (userId) => {
@@ -167,7 +180,7 @@ export const session = {
167
180
  create: (data) => {
168
181
  return prisma.session.create({
169
182
  data,
170
- include: { user: true },
183
+ include: { user: { select: USER_SAFE_SELECT } },
171
184
  });
172
185
  },
173
186
  update: (sessionToken, data) => {
@@ -8,7 +8,12 @@ export const userCreateSchema = z.object({
8
8
 
9
9
  export const userRegisterSchema = z.object({
10
10
  email: z.string().email("Invalid email address"),
11
- password: z.string().min(8, "Password must be at least 8 characters"),
11
+ password: z
12
+ .string()
13
+ .min(8, "Password must be at least 8 characters")
14
+ .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
15
+ .regex(/[a-z]/, "Password must contain at least one lowercase letter")
16
+ .regex(/[0-9]/, "Password must contain at least one number"),
12
17
  name: z.string().min(2, "Name must be at least 2 characters").optional(),
13
18
  });
14
19
 
@@ -19,7 +19,8 @@
19
19
  },
20
20
  "dev": {
21
21
  "cache": false,
22
- "persistent": true
22
+ "persistent": true,
23
+ "passThroughEnv": ["PORT", "POSTGRES_HOST", "POSTGRES_PORT", "POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DB", "REDIS_HOST", "REDIS_PORT", "MAILHOG_HOST", "MAILHOG_SMTP_PORT", "MAILHOG_UI_PORT", "NEXTAUTH_SECRET", "APP_URL", "WORKER_CONCURRENCY"]
23
24
  }
24
25
  }
25
26
  }
@@ -3,6 +3,8 @@
3
3
  "version": "1.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
- ".": "./src/index.js"
6
+ ".": "./src/index.js",
7
+ "./app-url": "./src/app-url.js",
8
+ "./validate-env": "./src/validate-env.js"
7
9
  }
8
10
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * APP_URL — Single source of truth for the application's canonical URL.
3
+ *
4
+ * Resolution order:
5
+ * 1. APP_URL (production — set to your real domain)
6
+ * 2. NEXTAUTH_URL (legacy / backward-compat)
7
+ * 3. http://localhost:${PORT || 3000} (local dev fallback)
8
+ *
9
+ * In development PORT is the single source of truth for the web server port.
10
+ * APP_URL is derived from it automatically so the two can never drift.
11
+ * In production, set APP_URL explicitly (e.g. https://yourdomain.com).
12
+ *
13
+ * Derived values:
14
+ * - NEXTAUTH_URL — always set equal to the resolved APP_URL so
15
+ * NextAuth works without a separate variable.
16
+ * - allowedOrigins — the resolved APP_URL plus any extra origins
17
+ * listed in ALLOWED_ORIGINS (comma-separated).
18
+ */
19
+
20
+ /**
21
+ * Resolves the canonical application URL.
22
+ * @returns {string} The canonical URL (no trailing slash)
23
+ */
24
+ export function getAppUrl() {
25
+ const raw =
26
+ process.env.APP_URL ||
27
+ process.env.NEXTAUTH_URL ||
28
+ `http://localhost:${process.env.PORT || "3000"}`;
29
+
30
+ // Strip trailing slash for consistency
31
+ return raw.replace(/\/+$/, "");
32
+ }
33
+
34
+ /**
35
+ * Returns the list of allowed CORS origins.
36
+ *
37
+ * Always includes the canonical APP_URL.
38
+ * If ALLOWED_ORIGINS is set, those are *added* (not replacing) the canonical
39
+ * origin so the primary domain is never accidentally excluded.
40
+ *
41
+ * @returns {string[]} De-duplicated list of allowed origins
42
+ */
43
+ export function getAllowedOrigins() {
44
+ const canonical = getAppUrl();
45
+ const extras = process.env.ALLOWED_ORIGINS
46
+ ? process.env.ALLOWED_ORIGINS.split(",")
47
+ .map((o) => o.trim())
48
+ .filter(Boolean)
49
+ : [];
50
+
51
+ // In development, always allow the common local ports
52
+ const isDev = process.env.NODE_ENV !== "production";
53
+ const devOrigins = isDev
54
+ ? ["http://localhost:3000", "http://localhost:3001"]
55
+ : [];
56
+
57
+ // De-duplicate
58
+ return [...new Set([canonical, ...extras, ...devOrigins])];
59
+ }
60
+
61
+ /**
62
+ * Ensures NEXTAUTH_URL is set in process.env so NextAuth picks it up,
63
+ * even when only APP_URL was configured.
64
+ *
65
+ * Call this once at startup (e.g. in your env validation step).
66
+ */
67
+ export function syncNextAuthUrl() {
68
+ if (!process.env.NEXTAUTH_URL) {
69
+ process.env.NEXTAUTH_URL = getAppUrl();
70
+ }
71
+ }
@@ -0,0 +1,104 @@
1
+ import { syncNextAuthUrl } from "./app-url.js";
2
+
3
+ /**
4
+ * Environment variable validation schema
5
+ * Validates all required and optional environment variables on startup
6
+ */
7
+
8
+ const envSchema = {
9
+ // Database
10
+ DATABASE_URL: {
11
+ required: false,
12
+ description: "PostgreSQL connection string",
13
+ },
14
+ POSTGRES_HOST: { required: false, description: "PostgreSQL host" },
15
+ POSTGRES_PORT: { required: false, description: "PostgreSQL port" },
16
+ POSTGRES_USER: { required: false, description: "PostgreSQL user" },
17
+ POSTGRES_PASSWORD: { required: false, description: "PostgreSQL password" },
18
+ POSTGRES_DB: { required: false, description: "PostgreSQL database name" },
19
+
20
+ // Redis
21
+ REDIS_URL: { required: false, description: "Redis connection string" },
22
+ REDIS_HOST: { required: false, description: "Redis host" },
23
+ REDIS_PORT: { required: false, description: "Redis port" },
24
+
25
+ // Mailhog
26
+ MAILHOG_SMTP_URL: { required: false, description: "Mailhog SMTP URL" },
27
+ MAILHOG_HOST: { required: false, description: "Mailhog host" },
28
+ MAILHOG_SMTP_PORT: { required: false, description: "Mailhog SMTP port" },
29
+ MAILHOG_UI_PORT: { required: false, description: "Mailhog UI port" },
30
+
31
+ // NextAuth
32
+ NEXTAUTH_SECRET: {
33
+ required: true,
34
+ description: "NextAuth secret for JWT signing",
35
+ },
36
+ NEXTAUTH_URL: {
37
+ required: false,
38
+ description: "NextAuth callback URL (derived from APP_URL if not set)",
39
+ },
40
+
41
+ // Application
42
+ APP_URL: {
43
+ required: false,
44
+ description:
45
+ "Canonical application URL — derives NEXTAUTH_URL and CORS origins",
46
+ },
47
+ NODE_ENV: {
48
+ required: false,
49
+ description: "Environment (development, test, production)",
50
+ },
51
+ PORT: { required: false, description: "Web server port" },
52
+ };
53
+
54
+ /**
55
+ * Validates environment variables against schema
56
+ * @throws {Error} If required environment variables are missing
57
+ * @returns {Object} Validated environment object
58
+ */
59
+ export function validateEnv() {
60
+ const errors = [];
61
+ const validated = {};
62
+
63
+ for (const [key, config] of Object.entries(envSchema)) {
64
+ const value = process.env[key];
65
+
66
+ if (config.required && !value) {
67
+ errors.push(
68
+ `Missing required environment variable: ${key} (${config.description})`,
69
+ );
70
+ }
71
+
72
+ if (value) {
73
+ validated[key] = value;
74
+ }
75
+ }
76
+
77
+ if (errors.length > 0) {
78
+ const errorMessage = `Environment Validation Failed:\n${errors.join("\n")}`;
79
+ throw new Error(errorMessage);
80
+ }
81
+
82
+ // Ensure NEXTAUTH_URL is derived from APP_URL when not explicitly set
83
+ syncNextAuthUrl();
84
+
85
+ // Include the (possibly derived) NEXTAUTH_URL in the validated object
86
+ if (process.env.NEXTAUTH_URL && !validated.NEXTAUTH_URL) {
87
+ validated.NEXTAUTH_URL = process.env.NEXTAUTH_URL;
88
+ }
89
+
90
+ return validated;
91
+ }
92
+
93
+ /**
94
+ * Loads and validates environment variables
95
+ * Call this function at application startup
96
+ */
97
+ export function loadEnv() {
98
+ try {
99
+ return validateEnv();
100
+ } catch (error) {
101
+ console.error(error.message);
102
+ process.exit(1);
103
+ }
104
+ }