@techstream/quark-create-app 1.2.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.
Files changed (40) hide show
  1. package/package.json +34 -33
  2. package/src/index.js +193 -56
  3. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +17 -1
  4. package/templates/base-project/apps/web/src/app/api/error-handler.js +4 -2
  5. package/templates/base-project/apps/web/src/app/api/health/route.js +8 -3
  6. package/templates/base-project/apps/web/src/app/api/posts/[id]/route.js +9 -5
  7. package/templates/base-project/apps/web/src/app/api/posts/route.js +13 -5
  8. package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +9 -9
  9. package/templates/base-project/apps/web/src/app/api/users/route.js +6 -6
  10. package/templates/base-project/apps/web/src/lib/auth-middleware.js +18 -1
  11. package/templates/base-project/apps/web/src/{middleware.js → proxy.js} +3 -3
  12. package/templates/base-project/apps/worker/package.json +1 -2
  13. package/templates/base-project/apps/worker/src/index.js +71 -19
  14. package/templates/base-project/docker-compose.yml +3 -6
  15. package/templates/base-project/package.json +16 -1
  16. package/templates/base-project/packages/db/package.json +10 -4
  17. package/templates/base-project/packages/db/prisma.config.ts +2 -2
  18. package/templates/base-project/packages/db/scripts/seed.js +1 -1
  19. package/templates/base-project/packages/db/src/client.js +41 -25
  20. package/templates/base-project/packages/db/src/queries.js +22 -9
  21. package/templates/base-project/packages/db/src/schemas.js +6 -1
  22. package/templates/base-project/turbo.json +17 -1
  23. package/templates/config/package.json +3 -1
  24. package/templates/config/src/app-url.js +71 -0
  25. package/templates/config/src/validate-env.js +104 -0
  26. package/templates/base-project/packages/db/src/generated/prisma/browser.ts +0 -53
  27. package/templates/base-project/packages/db/src/generated/prisma/client.ts +0 -82
  28. package/templates/base-project/packages/db/src/generated/prisma/commonInputTypes.ts +0 -649
  29. package/templates/base-project/packages/db/src/generated/prisma/enums.ts +0 -19
  30. package/templates/base-project/packages/db/src/generated/prisma/internal/class.ts +0 -305
  31. package/templates/base-project/packages/db/src/generated/prisma/internal/prismaNamespace.ts +0 -1428
  32. package/templates/base-project/packages/db/src/generated/prisma/internal/prismaNamespaceBrowser.ts +0 -217
  33. package/templates/base-project/packages/db/src/generated/prisma/models/Account.ts +0 -2098
  34. package/templates/base-project/packages/db/src/generated/prisma/models/AuditLog.ts +0 -1805
  35. package/templates/base-project/packages/db/src/generated/prisma/models/Job.ts +0 -1737
  36. package/templates/base-project/packages/db/src/generated/prisma/models/Post.ts +0 -1762
  37. package/templates/base-project/packages/db/src/generated/prisma/models/Session.ts +0 -1738
  38. package/templates/base-project/packages/db/src/generated/prisma/models/User.ts +0 -2298
  39. package/templates/base-project/packages/db/src/generated/prisma/models/VerificationToken.ts +0 -1450
  40. package/templates/base-project/packages/db/src/generated/prisma/models.ts +0 -18
@@ -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:*",
@@ -8,13 +8,11 @@ import {
8
8
  createEmailService,
9
9
  createLogger,
10
10
  createWorker,
11
+ passwordResetEmail,
12
+ welcomeEmail,
11
13
  } from "@techstream/quark-core";
12
- import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
13
14
  import { prisma } from "@techstream/quark-db";
14
- import dotenv from "dotenv";
15
-
16
- // Load environment variables
17
- dotenv.config();
15
+ import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
18
16
 
19
17
  const logger = createLogger("worker");
20
18
 
@@ -40,7 +38,6 @@ async function handleSendWelcomeEmail(bullJob) {
40
38
  userId,
41
39
  });
42
40
 
43
- // Look up the user's email
44
41
  const userRecord = await prisma.user.findUnique({
45
42
  where: { id: userId },
46
43
  select: { email: true, name: true },
@@ -50,15 +47,60 @@ async function handleSendWelcomeEmail(bullJob) {
50
47
  throw new Error(`User ${userId} not found or has no email`);
51
48
  }
52
49
 
53
- const displayName = userRecord.name || "there";
50
+ const template = welcomeEmail({
51
+ name: userRecord.name,
52
+ loginUrl: process.env.APP_URL
53
+ ? `${process.env.APP_URL}/api/auth/signin`
54
+ : undefined,
55
+ });
56
+
57
+ await emailService.sendEmail(
58
+ userRecord.email,
59
+ template.subject,
60
+ template.html,
61
+ template.text,
62
+ );
63
+
64
+ return { success: true, userId, email: userRecord.email };
65
+ }
66
+
67
+ /**
68
+ * Job handler for SEND_RESET_PASSWORD_EMAIL
69
+ * @param {Job} bullJob - BullMQ job object
70
+ */
71
+ async function handleSendResetPasswordEmail(bullJob) {
72
+ const { userId, resetUrl } = bullJob.data;
73
+
74
+ if (!userId || !resetUrl) {
75
+ throw new Error(
76
+ "userId and resetUrl are required for SEND_RESET_PASSWORD_EMAIL job",
77
+ );
78
+ }
79
+
80
+ logger.info(`Sending password reset email for user ${userId}`, {
81
+ job: JOB_NAMES.SEND_RESET_PASSWORD_EMAIL,
82
+ userId,
83
+ });
84
+
85
+ const userRecord = await prisma.user.findUnique({
86
+ where: { id: userId },
87
+ select: { email: true, name: true },
88
+ });
89
+
90
+ if (!userRecord?.email) {
91
+ throw new Error(`User ${userId} not found or has no email`);
92
+ }
93
+
94
+ const template = passwordResetEmail({
95
+ name: userRecord.name,
96
+ resetUrl,
97
+ });
54
98
 
55
99
  await emailService.sendEmail(
56
100
  userRecord.email,
57
- "Welcome to Quark!",
58
- `<h1>Welcome, ${displayName}!</h1>
59
- <p>Your account has been created successfully.</p>
60
- <p>You can now sign in and start using the application.</p>`,
61
- `Welcome, ${displayName}!\n\nYour account has been created successfully.\nYou can now sign in and start using the application.`,
101
+ template.subject,
102
+ template.html,
103
+ template.text,
62
104
  );
63
105
 
64
106
  return { success: true, userId, email: userRecord.email };
@@ -70,6 +112,7 @@ async function handleSendWelcomeEmail(bullJob) {
70
112
  */
71
113
  const jobHandlers = {
72
114
  [JOB_NAMES.SEND_WELCOME_EMAIL]: handleSendWelcomeEmail,
115
+ [JOB_NAMES.SEND_RESET_PASSWORD_EMAIL]: handleSendResetPasswordEmail,
73
116
  };
74
117
 
75
118
  /**
@@ -104,11 +147,14 @@ async function startWorker() {
104
147
  });
105
148
 
106
149
  emailQueueWorker.on("failed", (job, error) => {
107
- logger.error(`Job ${job.id} (${job.name}) failed after ${job.attemptsMade} attempts`, {
108
- error: error.message,
109
- jobName: job.name,
110
- attemptsMade: job.attemptsMade,
111
- });
150
+ logger.error(
151
+ `Job ${job.id} (${job.name}) failed after ${job.attemptsMade} attempts`,
152
+ {
153
+ error: error.message,
154
+ jobName: job.name,
155
+ attemptsMade: job.attemptsMade,
156
+ },
157
+ );
112
158
  });
113
159
 
114
160
  logger.info(
@@ -118,7 +164,10 @@ async function startWorker() {
118
164
  // Ready to process jobs
119
165
  logger.info("Worker service ready");
120
166
  } catch (error) {
121
- logger.error("Failed to start worker service", { error: error.message, stack: error.stack });
167
+ logger.error("Failed to start worker service", {
168
+ error: error.message,
169
+ stack: error.stack,
170
+ });
122
171
  process.exit(1);
123
172
  }
124
173
  }
@@ -141,7 +190,10 @@ async function shutdown() {
141
190
  logger.info("All workers closed");
142
191
  process.exit(0);
143
192
  } catch (error) {
144
- logger.error("Error during shutdown", { error: error.message, stack: error.stack });
193
+ logger.error("Error during shutdown", {
194
+ error: error.message,
195
+ stack: error.stack,
196
+ });
145
197
  process.exit(1);
146
198
  }
147
199
  }
@@ -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,24 @@
17
17
  "author": "",
18
18
  "license": "ISC",
19
19
  "packageManager": "pnpm@10.12.1",
20
+ "pnpm": {
21
+ "onlyBuiltDependencies": [
22
+ "@prisma/engines",
23
+ "esbuild",
24
+ "msgpackr-extract",
25
+ "prisma",
26
+ "sharp"
27
+ ],
28
+ "peerDependencyRules": {
29
+ "allowedVersions": {
30
+ "nodemailer": "*"
31
+ }
32
+ }
33
+ },
20
34
  "devDependencies": {
21
35
  "@biomejs/biome": "^2.3.13",
22
36
  "@types/node": "^24.10.9",
37
+ "dotenv-cli": "^11.0.0",
23
38
  "tsx": "^4.21.0",
24
39
  "turbo": "^2.8.1"
25
40
  }
@@ -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";
@@ -1,5 +1,5 @@
1
- import { PrismaClient } from "../src/generated/prisma/client.js";
2
1
  import bcrypt from "bcryptjs";
2
+ import { PrismaClient } from "../src/generated/prisma/client.js";
3
3
 
4
4
  const prisma = new PrismaClient();
5
5
 
@@ -1,19 +1,23 @@
1
1
  import { PrismaPg } from "@prisma/adapter-pg";
2
2
  import { PrismaClient } from "./generated/prisma/client.ts";
3
3
 
4
- // Construct DATABASE_URL from individual env vars (mirrors prisma.config.ts)
5
- // POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB are required no silent fallbacks.
6
- const user = process.env.POSTGRES_USER;
7
- if (!user) throw new Error("POSTGRES_USER environment variable is required");
8
- const password = process.env.POSTGRES_PASSWORD;
9
- if (!password) throw new Error("POSTGRES_PASSWORD environment variable is required");
10
- const host = process.env.POSTGRES_HOST || "localhost";
11
- const port = process.env.POSTGRES_PORT || "5432";
12
- const db = process.env.POSTGRES_DB;
13
- if (!db) throw new Error("POSTGRES_DB environment variable is required");
14
- const connectionString = `postgresql://${user}:${password}@${host}:${port}/${db}?schema=public`;
15
-
16
- const isProduction = process.env.NODE_ENV === "production";
4
+ /**
5
+ * Builds a Postgres connection string from individual env vars (mirrors prisma.config.ts).
6
+ * Throws if any required variable is missing — but only when actually called,
7
+ * so the module can be safely imported at build time (e.g. during `next build`).
8
+ */
9
+ function getConnectionString() {
10
+ const user = process.env.POSTGRES_USER;
11
+ if (!user) throw new Error("POSTGRES_USER environment variable is required");
12
+ const password = process.env.POSTGRES_PASSWORD;
13
+ if (!password)
14
+ throw new Error("POSTGRES_PASSWORD environment variable is required");
15
+ const host = process.env.POSTGRES_HOST || "localhost";
16
+ const port = process.env.POSTGRES_PORT || "5432";
17
+ const db = process.env.POSTGRES_DB;
18
+ if (!db) throw new Error("POSTGRES_DB environment variable is required");
19
+ return `postgresql://${user}:${password}@${host}:${port}/${db}?schema=public`;
20
+ }
17
21
 
18
22
  /**
19
23
  * Returns the connection pool configuration for the `pg` driver.
@@ -26,6 +30,7 @@ const isProduction = process.env.NODE_ENV === "production";
26
30
  * @returns {{ max: number, idleTimeoutMillis: number, connectionTimeoutMillis: number }}
27
31
  */
28
32
  export function getPoolConfig() {
33
+ const isProduction = process.env.NODE_ENV === "production";
29
34
  return {
30
35
  max: Number(process.env.DB_POOL_MAX) || (isProduction ? 10 : 5),
31
36
  idleTimeoutMillis: Number(process.env.DB_POOL_IDLE_TIMEOUT) || 30_000,
@@ -34,19 +39,30 @@ export function getPoolConfig() {
34
39
  };
35
40
  }
36
41
 
37
- // Create a singleton Prisma client with the PostgreSQL driver adapter and pool settings
42
+ // Lazy singleton the client is created on first property access, not at import time.
43
+ // This allows Next.js to import the module at build time without requiring DB env vars.
38
44
  const globalForPrisma = globalThis;
39
- export const prisma =
40
- globalForPrisma.prisma ||
41
- new PrismaClient({
42
- adapter: new PrismaPg({
43
- connectionString,
44
- ...getPoolConfig(),
45
- }),
46
- });
47
45
 
48
- if (!isProduction) {
49
- globalForPrisma.prisma = prisma;
46
+ function getPrismaClient() {
47
+ if (!globalForPrisma.__prisma) {
48
+ globalForPrisma.__prisma = new PrismaClient({
49
+ adapter: new PrismaPg({
50
+ connectionString: getConnectionString(),
51
+ ...getPoolConfig(),
52
+ }),
53
+ });
54
+ }
55
+ return globalForPrisma.__prisma;
50
56
  }
51
57
 
52
- export * from "./generated/prisma/client.js";
58
+ /**
59
+ * Prisma client singleton. Lazily initialized on first use so the module
60
+ * can be imported safely at build time (no DB env vars needed).
61
+ */
62
+ export const prisma = new Proxy(/** @type {PrismaClient} */ ({}), {
63
+ get(_target, prop) {
64
+ return getPrismaClient()[prop];
65
+ },
66
+ });
67
+
68
+ 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,23 @@
19
19
  },
20
20
  "dev": {
21
21
  "cache": false,
22
- "persistent": true
22
+ "persistent": true,
23
+ "passThroughEnv": [
24
+ "PORT",
25
+ "POSTGRES_HOST",
26
+ "POSTGRES_PORT",
27
+ "POSTGRES_USER",
28
+ "POSTGRES_PASSWORD",
29
+ "POSTGRES_DB",
30
+ "REDIS_HOST",
31
+ "REDIS_PORT",
32
+ "MAILHOG_HOST",
33
+ "MAILHOG_SMTP_PORT",
34
+ "MAILHOG_UI_PORT",
35
+ "NEXTAUTH_SECRET",
36
+ "APP_URL",
37
+ "WORKER_CONCURRENCY"
38
+ ]
23
39
  }
24
40
  }
25
41
  }
@@ -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
+ }