@techstream/quark-create-app 1.5.3 → 1.7.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 +4 -2
- package/src/index.js +62 -14
- package/templates/base-project/.github/dependabot.yml +12 -0
- package/templates/base-project/.github/workflows/ci.yml +97 -0
- package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
- package/templates/base-project/.github/workflows/release.yml +38 -0
- package/templates/base-project/apps/web/biome.json +7 -0
- package/templates/base-project/apps/web/jsconfig.json +5 -5
- package/templates/base-project/apps/web/next.config.js +90 -1
- package/templates/base-project/apps/web/package.json +7 -7
- package/templates/base-project/apps/web/railway.json +15 -0
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +6 -7
- package/templates/base-project/apps/web/src/app/layout.js +3 -4
- package/templates/base-project/apps/web/src/app/manifest.js +12 -0
- package/templates/base-project/apps/web/src/app/robots.js +21 -0
- package/templates/base-project/apps/web/src/app/sitemap.js +20 -0
- package/templates/base-project/apps/web/src/lib/seo/indexing.js +23 -0
- package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +33 -0
- package/templates/base-project/apps/web/src/proxy.js +1 -2
- package/templates/base-project/apps/worker/package.json +5 -5
- package/templates/base-project/apps/worker/railway.json +13 -0
- package/templates/base-project/apps/worker/src/index.js +30 -12
- package/templates/base-project/apps/worker/src/index.test.js +296 -15
- package/templates/base-project/biome.json +44 -0
- package/templates/base-project/docker-compose.yml +7 -4
- package/templates/base-project/package.json +1 -1
- package/templates/base-project/packages/db/package.json +1 -1
- package/templates/base-project/packages/db/prisma/schema.prisma +1 -17
- package/templates/base-project/packages/db/prisma.config.ts +8 -10
- package/templates/base-project/packages/db/scripts/seed.js +117 -30
- package/templates/base-project/packages/db/src/client.js +1 -18
- package/templates/base-project/packages/db/src/connection.js +44 -0
- package/templates/base-project/packages/db/src/connection.test.js +119 -0
- package/templates/base-project/packages/db/src/queries.js +52 -118
- package/templates/base-project/packages/db/src/queries.test.js +0 -29
- package/templates/base-project/packages/db/src/schemas.js +0 -12
- package/templates/base-project/pnpm-workspace.yaml +4 -0
- package/templates/base-project/turbo.json +5 -3
- package/templates/config/package.json +2 -0
- package/templates/config/src/environment.js +270 -0
- package/templates/config/src/index.js +13 -18
- package/templates/config/src/load-config.js +135 -0
- package/templates/config/src/validate-env.js +123 -16
- package/templates/jobs/package.json +2 -2
- package/templates/jobs/src/definitions.test.js +34 -0
- package/templates/jobs/src/index.js +1 -1
- package/templates/ui/package.json +4 -4
- package/templates/ui/src/button.test.js +23 -0
- package/templates/ui/src/index.js +1 -3
- package/templates/base-project/apps/web/src/app/api/posts/[id]/route.js +0 -65
- package/templates/base-project/apps/web/src/app/api/posts/route.js +0 -95
- package/templates/ui/src/card.js +0 -14
- package/templates/ui/src/input.js +0 -11
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-config - Environment Configuration
|
|
3
|
+
* Provides environment-specific defaults for dev, test, staging, and production.
|
|
4
|
+
* Each environment defines sensible defaults that can be overridden via env vars.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {"development" | "test" | "staging" | "production"} Environment
|
|
9
|
+
*
|
|
10
|
+
* @typedef {Object} EnvironmentConfig
|
|
11
|
+
* @property {Environment} environment - Resolved environment name
|
|
12
|
+
* @property {boolean} isProduction - True in production and staging
|
|
13
|
+
* @property {boolean} isDevelopment - True in development
|
|
14
|
+
* @property {boolean} isTest - True in test
|
|
15
|
+
* @property {Object} server - Server configuration
|
|
16
|
+
* @property {number} server.port - HTTP port
|
|
17
|
+
* @property {Object} rateLimit - Rate limiting defaults
|
|
18
|
+
* @property {number} rateLimit.windowMs - Rate limit window in ms
|
|
19
|
+
* @property {number} rateLimit.maxRequests - Max requests per window
|
|
20
|
+
* @property {number} rateLimit.authMaxRequests - Max auth requests per window
|
|
21
|
+
* @property {Object} cache - Cache configuration
|
|
22
|
+
* @property {number} cache.defaultTtl - Default cache TTL in seconds
|
|
23
|
+
* @property {Object} logging - Logging configuration
|
|
24
|
+
* @property {string} logging.level - Minimum log level
|
|
25
|
+
* @property {boolean} logging.json - Use JSON format
|
|
26
|
+
* @property {Object} db - Database configuration
|
|
27
|
+
* @property {number} db.poolMax - Max DB pool connections
|
|
28
|
+
* @property {number} db.poolIdleTimeout - Idle timeout in seconds
|
|
29
|
+
* @property {number} db.connectionTimeout - Connection timeout in seconds
|
|
30
|
+
* @property {Object} email - Email configuration
|
|
31
|
+
* @property {number} email.timeout - SMTP/API timeout in ms
|
|
32
|
+
* @property {Object} security - Security configuration
|
|
33
|
+
* @property {boolean} security.enforceHttps - Require HTTPS
|
|
34
|
+
* @property {boolean} security.trustProxy - Trust proxy headers
|
|
35
|
+
* @property {Object} features - Feature flags
|
|
36
|
+
* @property {boolean} features.debugRoutes - Enable debug endpoints
|
|
37
|
+
* @property {boolean} features.seedOnStart - Auto-seed database on startup
|
|
38
|
+
* @property {boolean} features.detailedErrors - Include stack traces in error responses
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** @type {Record<Environment, EnvironmentConfig>} */
|
|
42
|
+
const ENVIRONMENT_CONFIGS = {
|
|
43
|
+
development: {
|
|
44
|
+
environment: "development",
|
|
45
|
+
isProduction: false,
|
|
46
|
+
isDevelopment: true,
|
|
47
|
+
isTest: false,
|
|
48
|
+
server: {
|
|
49
|
+
port: 3000,
|
|
50
|
+
},
|
|
51
|
+
rateLimit: {
|
|
52
|
+
windowMs: 15 * 60 * 1000,
|
|
53
|
+
maxRequests: 1000,
|
|
54
|
+
authMaxRequests: 50,
|
|
55
|
+
},
|
|
56
|
+
cache: {
|
|
57
|
+
defaultTtl: 60,
|
|
58
|
+
},
|
|
59
|
+
logging: {
|
|
60
|
+
level: "debug",
|
|
61
|
+
json: false,
|
|
62
|
+
},
|
|
63
|
+
db: {
|
|
64
|
+
poolMax: 5,
|
|
65
|
+
poolIdleTimeout: 30,
|
|
66
|
+
connectionTimeout: 5,
|
|
67
|
+
},
|
|
68
|
+
email: {
|
|
69
|
+
timeout: 10_000,
|
|
70
|
+
},
|
|
71
|
+
security: {
|
|
72
|
+
enforceHttps: false,
|
|
73
|
+
trustProxy: false,
|
|
74
|
+
},
|
|
75
|
+
features: {
|
|
76
|
+
debugRoutes: true,
|
|
77
|
+
seedOnStart: false,
|
|
78
|
+
detailedErrors: true,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
test: {
|
|
83
|
+
environment: "test",
|
|
84
|
+
isProduction: false,
|
|
85
|
+
isDevelopment: false,
|
|
86
|
+
isTest: true,
|
|
87
|
+
server: {
|
|
88
|
+
port: 3001,
|
|
89
|
+
},
|
|
90
|
+
rateLimit: {
|
|
91
|
+
windowMs: 15 * 60 * 1000,
|
|
92
|
+
maxRequests: 10_000,
|
|
93
|
+
authMaxRequests: 10_000,
|
|
94
|
+
},
|
|
95
|
+
cache: {
|
|
96
|
+
defaultTtl: 0,
|
|
97
|
+
},
|
|
98
|
+
logging: {
|
|
99
|
+
level: "warn",
|
|
100
|
+
json: false,
|
|
101
|
+
},
|
|
102
|
+
db: {
|
|
103
|
+
poolMax: 3,
|
|
104
|
+
poolIdleTimeout: 10,
|
|
105
|
+
connectionTimeout: 5,
|
|
106
|
+
},
|
|
107
|
+
email: {
|
|
108
|
+
timeout: 5_000,
|
|
109
|
+
},
|
|
110
|
+
security: {
|
|
111
|
+
enforceHttps: false,
|
|
112
|
+
trustProxy: false,
|
|
113
|
+
},
|
|
114
|
+
features: {
|
|
115
|
+
debugRoutes: true,
|
|
116
|
+
seedOnStart: false,
|
|
117
|
+
detailedErrors: true,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
staging: {
|
|
122
|
+
environment: "staging",
|
|
123
|
+
isProduction: true,
|
|
124
|
+
isDevelopment: false,
|
|
125
|
+
isTest: false,
|
|
126
|
+
server: {
|
|
127
|
+
port: 3000,
|
|
128
|
+
},
|
|
129
|
+
rateLimit: {
|
|
130
|
+
windowMs: 15 * 60 * 1000,
|
|
131
|
+
maxRequests: 100,
|
|
132
|
+
authMaxRequests: 5,
|
|
133
|
+
},
|
|
134
|
+
cache: {
|
|
135
|
+
defaultTtl: 300,
|
|
136
|
+
},
|
|
137
|
+
logging: {
|
|
138
|
+
level: "info",
|
|
139
|
+
json: true,
|
|
140
|
+
},
|
|
141
|
+
db: {
|
|
142
|
+
poolMax: 10,
|
|
143
|
+
poolIdleTimeout: 30,
|
|
144
|
+
connectionTimeout: 5,
|
|
145
|
+
},
|
|
146
|
+
email: {
|
|
147
|
+
timeout: 10_000,
|
|
148
|
+
},
|
|
149
|
+
security: {
|
|
150
|
+
enforceHttps: true,
|
|
151
|
+
trustProxy: true,
|
|
152
|
+
},
|
|
153
|
+
features: {
|
|
154
|
+
debugRoutes: false,
|
|
155
|
+
seedOnStart: false,
|
|
156
|
+
detailedErrors: false,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
production: {
|
|
161
|
+
environment: "production",
|
|
162
|
+
isProduction: true,
|
|
163
|
+
isDevelopment: false,
|
|
164
|
+
isTest: false,
|
|
165
|
+
server: {
|
|
166
|
+
port: 3000,
|
|
167
|
+
},
|
|
168
|
+
rateLimit: {
|
|
169
|
+
windowMs: 15 * 60 * 1000,
|
|
170
|
+
maxRequests: 100,
|
|
171
|
+
authMaxRequests: 5,
|
|
172
|
+
},
|
|
173
|
+
cache: {
|
|
174
|
+
defaultTtl: 600,
|
|
175
|
+
},
|
|
176
|
+
logging: {
|
|
177
|
+
level: "info",
|
|
178
|
+
json: true,
|
|
179
|
+
},
|
|
180
|
+
db: {
|
|
181
|
+
poolMax: 10,
|
|
182
|
+
poolIdleTimeout: 30,
|
|
183
|
+
connectionTimeout: 5,
|
|
184
|
+
},
|
|
185
|
+
email: {
|
|
186
|
+
timeout: 10_000,
|
|
187
|
+
},
|
|
188
|
+
security: {
|
|
189
|
+
enforceHttps: true,
|
|
190
|
+
trustProxy: true,
|
|
191
|
+
},
|
|
192
|
+
features: {
|
|
193
|
+
debugRoutes: false,
|
|
194
|
+
seedOnStart: false,
|
|
195
|
+
detailedErrors: false,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** Valid environment names */
|
|
201
|
+
export const ENVIRONMENTS = /** @type {const} */ ([
|
|
202
|
+
"development",
|
|
203
|
+
"test",
|
|
204
|
+
"staging",
|
|
205
|
+
"production",
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolves the current environment from NODE_ENV.
|
|
210
|
+
* Maps common aliases (e.g. "dev" → "development", "prod" → "production").
|
|
211
|
+
* Defaults to "development" if unset or unrecognized.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} [nodeEnv] - Override for NODE_ENV (defaults to process.env.NODE_ENV)
|
|
214
|
+
* @returns {Environment}
|
|
215
|
+
*/
|
|
216
|
+
export function resolveEnvironment(nodeEnv) {
|
|
217
|
+
const raw = (nodeEnv ?? process.env.NODE_ENV ?? "").toLowerCase().trim();
|
|
218
|
+
|
|
219
|
+
const aliases = {
|
|
220
|
+
dev: "development",
|
|
221
|
+
development: "development",
|
|
222
|
+
test: "test",
|
|
223
|
+
testing: "test",
|
|
224
|
+
staging: "staging",
|
|
225
|
+
stage: "staging",
|
|
226
|
+
prod: "production",
|
|
227
|
+
production: "production",
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
return aliases[raw] || "development";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Returns the full environment configuration for a given environment.
|
|
235
|
+
* Unknown environments fall back to development.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} [nodeEnv] - Override for NODE_ENV
|
|
238
|
+
* @returns {EnvironmentConfig}
|
|
239
|
+
*/
|
|
240
|
+
export function getEnvironmentConfig(nodeEnv) {
|
|
241
|
+
const env = resolveEnvironment(nodeEnv);
|
|
242
|
+
return { ...ENVIRONMENT_CONFIGS[env] };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Shallow-merges environment config with user-provided overrides (one level deep).
|
|
247
|
+
* Top-level scalars are replaced; top-level objects are spread-merged.
|
|
248
|
+
* Useful when downstream apps need to adjust defaults per-environment.
|
|
249
|
+
*
|
|
250
|
+
* @param {EnvironmentConfig} base - Base environment config
|
|
251
|
+
* @param {Record<string, unknown>} overrides - Partial overrides to merge
|
|
252
|
+
* @returns {EnvironmentConfig}
|
|
253
|
+
*/
|
|
254
|
+
export function mergeConfig(base, overrides) {
|
|
255
|
+
const result = { ...base };
|
|
256
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
257
|
+
if (
|
|
258
|
+
value != null &&
|
|
259
|
+
typeof value === "object" &&
|
|
260
|
+
!Array.isArray(value) &&
|
|
261
|
+
typeof result[key] === "object" &&
|
|
262
|
+
result[key] != null
|
|
263
|
+
) {
|
|
264
|
+
result[key] = { ...result[key], ...value };
|
|
265
|
+
} else {
|
|
266
|
+
result[key] = value;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
export const config = {
|
|
2
|
-
appName:
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
},
|
|
7
|
-
database: {
|
|
8
|
-
url:
|
|
9
|
-
process.env.DATABASE_URL ||
|
|
10
|
-
"postgresql://user:password@localhost:5432/myapp",
|
|
11
|
-
},
|
|
12
|
-
redis: {
|
|
13
|
-
url: process.env.REDIS_URL || "redis://localhost:6379",
|
|
14
|
-
},
|
|
15
|
-
email: {
|
|
16
|
-
from: process.env.EMAIL_FROM || "noreply@myquarkapp.com",
|
|
17
|
-
provider: process.env.EMAIL_PROVIDER || "smtp",
|
|
18
|
-
},
|
|
2
|
+
appName: process.env.APP_NAME || "Quark",
|
|
3
|
+
appDescription:
|
|
4
|
+
process.env.APP_DESCRIPTION ||
|
|
5
|
+
"A modern monorepo with Next.js, React, and Prisma",
|
|
19
6
|
};
|
|
20
7
|
|
|
21
|
-
export
|
|
8
|
+
export { getAllowedOrigins, getAppUrl, syncNextAuthUrl } from "./app-url.js";
|
|
9
|
+
export {
|
|
10
|
+
ENVIRONMENTS,
|
|
11
|
+
getEnvironmentConfig,
|
|
12
|
+
mergeConfig,
|
|
13
|
+
resolveEnvironment,
|
|
14
|
+
} from "./environment.js";
|
|
15
|
+
export { getConfig, loadConfig, resetConfig } from "./load-config.js";
|
|
16
|
+
export { loadEnv, validateEnv } from "./validate-env.js";
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-config - Configuration Loader
|
|
3
|
+
* Centralised configuration management that combines environment validation,
|
|
4
|
+
* environment-specific defaults, and user overrides into a single config object.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { loadConfig } from "@techstream/quark-config";
|
|
8
|
+
* const config = loadConfig(); // validates env + returns typed config
|
|
9
|
+
* const config = loadConfig({ cache: { defaultTtl: 120 } }); // with overrides
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getAllowedOrigins, getAppUrl } from "./app-url.js";
|
|
13
|
+
import { getEnvironmentConfig, mergeConfig } from "./environment.js";
|
|
14
|
+
import { validateEnv } from "./validate-env.js";
|
|
15
|
+
|
|
16
|
+
/** @type {import("./environment.js").EnvironmentConfig | null} */
|
|
17
|
+
let cachedConfig = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Loads the full application configuration.
|
|
21
|
+
*
|
|
22
|
+
* 1. Validates all required environment variables (throws on failure).
|
|
23
|
+
* 2. Resolves the current environment (dev/test/staging/production).
|
|
24
|
+
* 3. Merges environment-specific defaults with any user overrides.
|
|
25
|
+
* 4. Enriches with computed values (APP_URL, allowed origins, ports).
|
|
26
|
+
* 5. Caches the result — subsequent calls return the same object.
|
|
27
|
+
*
|
|
28
|
+
* @param {Record<string, unknown>} [overrides] - Optional partial overrides
|
|
29
|
+
* @param {Object} [options]
|
|
30
|
+
* @param {boolean} [options.fresh=false] - Force re-computation (bypass cache)
|
|
31
|
+
* @returns {import("./environment.js").EnvironmentConfig & { appUrl: string, allowedOrigins: string[], validated: Record<string, string> }}
|
|
32
|
+
*/
|
|
33
|
+
export function loadConfig(overrides = {}, options = {}) {
|
|
34
|
+
if (cachedConfig && !options.fresh) {
|
|
35
|
+
return cachedConfig;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Step 1: Validate environment variables
|
|
39
|
+
const { validated } = validateEnv();
|
|
40
|
+
|
|
41
|
+
// Step 2: Resolve environment and get defaults
|
|
42
|
+
const envConfig = getEnvironmentConfig();
|
|
43
|
+
|
|
44
|
+
// Step 3: Apply env-var driven overrides
|
|
45
|
+
const envOverrides = buildEnvOverrides(envConfig);
|
|
46
|
+
|
|
47
|
+
// Step 4: Merge: env defaults → env-var overrides → user overrides
|
|
48
|
+
let config = mergeConfig(envConfig, envOverrides);
|
|
49
|
+
config = mergeConfig(config, overrides);
|
|
50
|
+
|
|
51
|
+
// Step 5: Attach computed values
|
|
52
|
+
config.appUrl = getAppUrl();
|
|
53
|
+
config.allowedOrigins = getAllowedOrigins();
|
|
54
|
+
config.validated = validated;
|
|
55
|
+
|
|
56
|
+
cachedConfig = config;
|
|
57
|
+
return config;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Clears the cached configuration. Useful in tests.
|
|
62
|
+
*/
|
|
63
|
+
export function resetConfig() {
|
|
64
|
+
cachedConfig = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns the cached configuration if already loaded, otherwise null.
|
|
69
|
+
* Does NOT trigger validation — use `loadConfig()` for that.
|
|
70
|
+
* @returns {ReturnType<typeof loadConfig> | null}
|
|
71
|
+
*/
|
|
72
|
+
export function getConfig() {
|
|
73
|
+
return cachedConfig;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reads specific environment variables and converts them to config overrides.
|
|
78
|
+
* This allows env vars to take precedence over environment defaults.
|
|
79
|
+
*
|
|
80
|
+
* @param {import("./environment.js").EnvironmentConfig} envConfig
|
|
81
|
+
* @returns {Record<string, unknown>}
|
|
82
|
+
*/
|
|
83
|
+
function buildEnvOverrides(_envConfig) {
|
|
84
|
+
const overrides = {};
|
|
85
|
+
|
|
86
|
+
// Server
|
|
87
|
+
if (process.env.PORT) {
|
|
88
|
+
const port = Number.parseInt(process.env.PORT, 10);
|
|
89
|
+
if (!Number.isNaN(port)) overrides.server = { port };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Rate limiting
|
|
93
|
+
if (process.env.RATE_LIMIT_MAX) {
|
|
94
|
+
const maxRequests = Number.parseInt(process.env.RATE_LIMIT_MAX, 10);
|
|
95
|
+
if (!Number.isNaN(maxRequests)) {
|
|
96
|
+
overrides.rateLimit = { ...overrides.rateLimit, maxRequests };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (process.env.RATE_LIMIT_WINDOW_MS) {
|
|
100
|
+
const windowMs = Number.parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10);
|
|
101
|
+
if (!Number.isNaN(windowMs)) {
|
|
102
|
+
overrides.rateLimit = { ...overrides.rateLimit, windowMs };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Cache
|
|
107
|
+
if (process.env.CACHE_TTL) {
|
|
108
|
+
const defaultTtl = Number.parseInt(process.env.CACHE_TTL, 10);
|
|
109
|
+
if (!Number.isNaN(defaultTtl)) overrides.cache = { defaultTtl };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Logging
|
|
113
|
+
if (process.env.LOG_LEVEL) {
|
|
114
|
+
overrides.logging = { ...overrides.logging, level: process.env.LOG_LEVEL };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Database pool
|
|
118
|
+
if (process.env.DB_POOL_MAX) {
|
|
119
|
+
const poolMax = Number.parseInt(process.env.DB_POOL_MAX, 10);
|
|
120
|
+
if (!Number.isNaN(poolMax)) {
|
|
121
|
+
overrides.db = { ...overrides.db, poolMax };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (process.env.DB_POOL_IDLE_TIMEOUT) {
|
|
125
|
+
const poolIdleTimeout = Number.parseInt(
|
|
126
|
+
process.env.DB_POOL_IDLE_TIMEOUT,
|
|
127
|
+
10,
|
|
128
|
+
);
|
|
129
|
+
if (!Number.isNaN(poolIdleTimeout)) {
|
|
130
|
+
overrides.db = { ...overrides.db, poolIdleTimeout };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return overrides;
|
|
135
|
+
}
|
|
@@ -22,16 +22,31 @@ const envSchema = {
|
|
|
22
22
|
REDIS_HOST: { required: false, description: "Redis host" },
|
|
23
23
|
REDIS_PORT: { required: false, description: "Redis port" },
|
|
24
24
|
|
|
25
|
-
// Mail (local SMTP
|
|
26
|
-
MAIL_SMTP_URL: { required: false, description: "Mail SMTP URL" },
|
|
25
|
+
// Mail (local SMTP — Mailpit in dev)
|
|
27
26
|
MAIL_HOST: { required: false, description: "Mail host" },
|
|
28
27
|
MAIL_SMTP_PORT: { required: false, description: "Mail SMTP port" },
|
|
29
28
|
MAIL_UI_PORT: { required: false, description: "Mail UI port" },
|
|
30
29
|
|
|
30
|
+
// Production SMTP (used when SMTP_HOST is set)
|
|
31
|
+
SMTP_HOST: { required: false, description: "Production SMTP host" },
|
|
32
|
+
SMTP_PORT: { required: false, description: "Production SMTP port" },
|
|
33
|
+
SMTP_SECURE: { required: false, description: "Use TLS for SMTP" },
|
|
34
|
+
SMTP_USER: { required: false, description: "SMTP username" },
|
|
35
|
+
SMTP_PASSWORD: { required: false, description: "SMTP password" },
|
|
36
|
+
|
|
37
|
+
// Email provider
|
|
38
|
+
EMAIL_PROVIDER: {
|
|
39
|
+
required: false,
|
|
40
|
+
description: 'Email provider — "smtp" (default) or "resend"',
|
|
41
|
+
},
|
|
42
|
+
EMAIL_FROM: { required: false, description: "Sender email address" },
|
|
43
|
+
RESEND_API_KEY: { required: false, description: "Resend API key" },
|
|
44
|
+
|
|
31
45
|
// NextAuth
|
|
32
46
|
NEXTAUTH_SECRET: {
|
|
33
47
|
required: true,
|
|
34
48
|
description: "NextAuth secret for JWT signing",
|
|
49
|
+
minLength: 32,
|
|
35
50
|
},
|
|
36
51
|
NEXTAUTH_URL: {
|
|
37
52
|
required: false,
|
|
@@ -39,6 +54,10 @@ const envSchema = {
|
|
|
39
54
|
},
|
|
40
55
|
|
|
41
56
|
// Application
|
|
57
|
+
APP_NAME: {
|
|
58
|
+
required: false,
|
|
59
|
+
description: "Application name — used in metadata, emails, and page titles",
|
|
60
|
+
},
|
|
42
61
|
APP_URL: {
|
|
43
62
|
required: false,
|
|
44
63
|
description:
|
|
@@ -46,57 +65,145 @@ const envSchema = {
|
|
|
46
65
|
},
|
|
47
66
|
NODE_ENV: {
|
|
48
67
|
required: false,
|
|
49
|
-
description: "Environment (development, test, production)",
|
|
68
|
+
description: "Environment (development, test, staging, production)",
|
|
50
69
|
},
|
|
51
70
|
PORT: { required: false, description: "Web server port" },
|
|
71
|
+
|
|
72
|
+
// Storage
|
|
73
|
+
STORAGE_PROVIDER: {
|
|
74
|
+
required: false,
|
|
75
|
+
description: 'Storage provider — "local" (default) or "s3"',
|
|
76
|
+
},
|
|
77
|
+
STORAGE_LOCAL_DIR: {
|
|
78
|
+
required: false,
|
|
79
|
+
description: "Local storage directory",
|
|
80
|
+
},
|
|
81
|
+
S3_BUCKET: { required: false, description: "S3 bucket name" },
|
|
82
|
+
S3_REGION: { required: false, description: "S3 region" },
|
|
83
|
+
S3_ENDPOINT: { required: false, description: "S3-compatible endpoint URL" },
|
|
84
|
+
S3_ACCESS_KEY_ID: { required: false, description: "S3 access key" },
|
|
85
|
+
S3_SECRET_ACCESS_KEY: { required: false, description: "S3 secret key" },
|
|
86
|
+
S3_PUBLIC_URL: { required: false, description: "S3 public URL prefix" },
|
|
52
87
|
};
|
|
53
88
|
|
|
54
89
|
/**
|
|
55
|
-
* Validates environment variables against schema
|
|
90
|
+
* Validates environment variables against schema.
|
|
91
|
+
*
|
|
92
|
+
* @param {"web" | "worker"} [service="web"] — The service being validated.
|
|
93
|
+
* Worker skips web-only checks (e.g. NEXTAUTH_SECRET).
|
|
56
94
|
* @throws {Error} If required environment variables are missing
|
|
57
|
-
* @returns {Object
|
|
95
|
+
* @returns {{ validated: Object, warnings: string[] }}
|
|
58
96
|
*/
|
|
59
|
-
export function validateEnv() {
|
|
97
|
+
export function validateEnv(service = "web") {
|
|
60
98
|
const errors = [];
|
|
99
|
+
const warnings = [];
|
|
61
100
|
const validated = {};
|
|
101
|
+
const isTest = process.env.NODE_ENV === "test";
|
|
102
|
+
|
|
103
|
+
// Web-only required fields that workers can skip
|
|
104
|
+
const webOnlyRequired = new Set(["NEXTAUTH_SECRET"]);
|
|
62
105
|
|
|
63
106
|
for (const [key, config] of Object.entries(envSchema)) {
|
|
64
107
|
const value = process.env[key];
|
|
65
108
|
|
|
66
|
-
|
|
109
|
+
// Skip web-only required checks for worker service
|
|
110
|
+
const isRequired =
|
|
111
|
+
config.required && !(service === "worker" && webOnlyRequired.has(key));
|
|
112
|
+
|
|
113
|
+
if (isRequired && !value) {
|
|
67
114
|
errors.push(
|
|
68
115
|
`Missing required environment variable: ${key} (${config.description})`,
|
|
69
116
|
);
|
|
70
117
|
}
|
|
71
118
|
|
|
119
|
+
if (value && config.minLength && value.length < config.minLength) {
|
|
120
|
+
errors.push(
|
|
121
|
+
`${key} must be at least ${config.minLength} characters (${config.description})`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
72
125
|
if (value) {
|
|
73
126
|
validated[key] = value;
|
|
74
127
|
}
|
|
75
128
|
}
|
|
76
129
|
|
|
130
|
+
// --- Cross-field validation ---
|
|
131
|
+
|
|
132
|
+
// Database: either DATABASE_URL or POSTGRES_USER must be set (skip in test)
|
|
133
|
+
if (!isTest) {
|
|
134
|
+
const hasDbUrl = !!process.env.DATABASE_URL;
|
|
135
|
+
const hasPostgresUser = !!process.env.POSTGRES_USER;
|
|
136
|
+
if (!hasDbUrl && !hasPostgresUser) {
|
|
137
|
+
errors.push(
|
|
138
|
+
"Database not configured: set DATABASE_URL or POSTGRES_USER + POSTGRES_PASSWORD + POSTGRES_DB",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Redis: warn if not configured (defaults to localhost in dev, will fail in prod)
|
|
144
|
+
if (
|
|
145
|
+
!process.env.REDIS_URL &&
|
|
146
|
+
!process.env.REDIS_HOST &&
|
|
147
|
+
process.env.NODE_ENV === "production"
|
|
148
|
+
) {
|
|
149
|
+
warnings.push(
|
|
150
|
+
"Redis not configured: set REDIS_URL or REDIS_HOST (defaults to localhost)",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Conditional: S3 storage requires bucket + credentials
|
|
155
|
+
if (process.env.STORAGE_PROVIDER === "s3") {
|
|
156
|
+
for (const key of [
|
|
157
|
+
"S3_BUCKET",
|
|
158
|
+
"S3_ACCESS_KEY_ID",
|
|
159
|
+
"S3_SECRET_ACCESS_KEY",
|
|
160
|
+
]) {
|
|
161
|
+
if (!process.env[key]) {
|
|
162
|
+
errors.push(
|
|
163
|
+
`Missing ${key} — required when STORAGE_PROVIDER=s3 (${envSchema[key].description})`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Conditional: Resend provider requires API key
|
|
170
|
+
if (process.env.EMAIL_PROVIDER === "resend" && !process.env.RESEND_API_KEY) {
|
|
171
|
+
errors.push("Missing RESEND_API_KEY — required when EMAIL_PROVIDER=resend");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Log warnings (non-fatal)
|
|
175
|
+
for (const warning of warnings) {
|
|
176
|
+
console.warn(`[env] ⚠️ ${warning}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
77
179
|
if (errors.length > 0) {
|
|
78
180
|
const errorMessage = `Environment Validation Failed:\n${errors.join("\n")}`;
|
|
79
181
|
throw new Error(errorMessage);
|
|
80
182
|
}
|
|
81
183
|
|
|
82
184
|
// Ensure NEXTAUTH_URL is derived from APP_URL when not explicitly set
|
|
83
|
-
|
|
185
|
+
if (service === "web") {
|
|
186
|
+
syncNextAuthUrl();
|
|
84
187
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
188
|
+
// Include the (possibly derived) NEXTAUTH_URL in the validated object
|
|
189
|
+
if (process.env.NEXTAUTH_URL && !validated.NEXTAUTH_URL) {
|
|
190
|
+
validated.NEXTAUTH_URL = process.env.NEXTAUTH_URL;
|
|
191
|
+
}
|
|
88
192
|
}
|
|
89
193
|
|
|
90
|
-
return validated;
|
|
194
|
+
return { validated, warnings };
|
|
91
195
|
}
|
|
92
196
|
|
|
93
197
|
/**
|
|
94
|
-
* Loads and validates environment variables
|
|
95
|
-
* Call this function at application startup
|
|
198
|
+
* Loads and validates environment variables.
|
|
199
|
+
* Call this function at application startup.
|
|
200
|
+
*
|
|
201
|
+
* @param {"web" | "worker"} [service="web"]
|
|
96
202
|
*/
|
|
97
|
-
export function loadEnv() {
|
|
203
|
+
export function loadEnv(service = "web") {
|
|
98
204
|
try {
|
|
99
|
-
|
|
205
|
+
const { validated } = validateEnv(service);
|
|
206
|
+
return validated;
|
|
100
207
|
} catch (error) {
|
|
101
208
|
console.error(error.message);
|
|
102
209
|
process.exit(1);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { JOB_NAMES, JOB_QUEUES } from "./definitions.js";
|
|
4
|
+
|
|
5
|
+
test("Job Definitions - defines email queue", () => {
|
|
6
|
+
assert.strictEqual(JOB_QUEUES.EMAIL, "email-queue");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("Job Definitions - defines files queue", () => {
|
|
10
|
+
assert.strictEqual(JOB_QUEUES.FILES, "files-queue");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("Job Definitions - defines welcome email job name", () => {
|
|
14
|
+
assert.strictEqual(JOB_NAMES.SEND_WELCOME_EMAIL, "send-welcome-email");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("Job Definitions - defines reset password email job name", () => {
|
|
18
|
+
assert.strictEqual(
|
|
19
|
+
JOB_NAMES.SEND_RESET_PASSWORD_EMAIL,
|
|
20
|
+
"send-reset-password-email",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("Job Definitions - defines cleanup orphaned files job name", () => {
|
|
25
|
+
assert.strictEqual(
|
|
26
|
+
JOB_NAMES.CLEANUP_ORPHANED_FILES,
|
|
27
|
+
"cleanup-orphaned-files",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("Job Definitions - queues are readonly", () => {
|
|
32
|
+
assert.strictEqual(typeof JOB_QUEUES.EMAIL, "string");
|
|
33
|
+
assert.strictEqual(typeof JOB_QUEUES.FILES, "string");
|
|
34
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export * from "./definitions.js";
|