@rebasepro/server-core 0.1.2 → 0.2.3
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/LICENSE +22 -6
- package/dist/common/src/data/query_builder.d.ts +51 -0
- package/dist/common/src/index.d.ts +1 -0
- package/dist/common/src/util/entities.d.ts +2 -2
- package/dist/common/src/util/relations.d.ts +1 -1
- package/dist/{index-DXVBFp5V.js → index-BZoAtuqi.js} +6 -2
- package/dist/index-BZoAtuqi.js.map +1 -0
- package/dist/index.es.js +16038 -15240
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +15980 -15178
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/auth/adapter-middleware.d.ts +33 -0
- package/dist/server-core/src/auth/admin-routes.d.ts +6 -0
- package/dist/server-core/src/auth/auth-overrides.d.ts +139 -0
- package/dist/server-core/src/auth/builtin-auth-adapter.d.ts +49 -0
- package/dist/server-core/src/auth/crypto-utils.d.ts +16 -0
- package/dist/server-core/src/auth/custom-auth-adapter.d.ts +39 -0
- package/dist/server-core/src/auth/index.d.ts +7 -0
- package/dist/server-core/src/auth/interfaces.d.ts +2 -0
- package/dist/server-core/src/auth/middleware.d.ts +18 -0
- package/dist/server-core/src/auth/rls-scope.d.ts +31 -0
- package/dist/server-core/src/auth/routes.d.ts +7 -1
- package/dist/server-core/src/env.d.ts +131 -0
- package/dist/server-core/src/index.d.ts +2 -0
- package/dist/server-core/src/init.d.ts +62 -3
- package/dist/types/src/controllers/auth.d.ts +9 -8
- package/dist/types/src/controllers/client.d.ts +3 -0
- package/dist/types/src/controllers/data.d.ts +21 -0
- package/dist/types/src/types/auth_adapter.d.ts +356 -0
- package/dist/types/src/types/collections.d.ts +67 -2
- package/dist/types/src/types/database_adapter.d.ts +94 -0
- package/dist/types/src/types/entity_actions.d.ts +7 -1
- package/dist/types/src/types/entity_callbacks.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +36 -1
- package/dist/types/src/types/index.d.ts +2 -0
- package/dist/types/src/types/plugins.d.ts +1 -1
- package/dist/types/src/types/properties.d.ts +24 -5
- package/dist/types/src/types/property_config.d.ts +6 -2
- package/dist/types/src/types/relations.d.ts +1 -1
- package/dist/types/src/types/translations.d.ts +8 -0
- package/dist/types/src/users/user.d.ts +5 -0
- package/jest.config.cjs +4 -1
- package/package.json +27 -27
- package/src/api/errors.ts +1 -1
- package/src/api/graphql/graphql-schema-generator.ts +7 -0
- package/src/api/openapi-generator.ts +13 -1
- package/src/api/rest/api-generator-count.test.ts +14 -12
- package/src/api/rest/query-parser.ts +2 -20
- package/src/auth/adapter-middleware.ts +83 -0
- package/src/auth/admin-routes.ts +36 -43
- package/src/auth/auth-overrides.ts +172 -0
- package/src/auth/builtin-auth-adapter.ts +384 -0
- package/src/auth/crypto-utils.ts +31 -0
- package/src/auth/custom-auth-adapter.ts +85 -0
- package/src/auth/index.ts +10 -0
- package/src/auth/interfaces.ts +2 -0
- package/src/auth/jwt.ts +3 -1
- package/src/auth/middleware.ts +2 -46
- package/src/auth/rls-scope.ts +58 -0
- package/src/auth/routes.ts +74 -32
- package/src/cron/cron-scheduler.test.ts +9 -9
- package/src/cron/cron-scheduler.ts +1 -1
- package/src/env.ts +224 -0
- package/src/index.ts +4 -0
- package/src/init.ts +355 -135
- package/src/storage/routes.ts +1 -19
- package/src/utils/logging.ts +3 -3
- package/test/admin-routes.test.ts +10 -4
- package/test/auth-routes.test.ts +2 -2
- package/test/backend-hooks-admin.test.ts +32 -12
- package/test/custom-auth-adapter.test.ts +177 -0
- package/test/env.test.ts +138 -0
- package/test/query-parser.test.ts +0 -29
- package/tsconfig.json +3 -0
- package/dist/index-DXVBFp5V.js.map +0 -1
package/src/auth/routes.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { ApiError, errorHandler } from "../api/errors";
|
|
|
3
3
|
import { randomBytes, createHash } from "crypto";
|
|
4
4
|
import type { AuthRepository, OAuthProvider } from "./interfaces";
|
|
5
5
|
import { generateAccessToken, generateRefreshToken, hashRefreshToken, getRefreshTokenExpiry, getAccessTokenExpiry } from "./jwt";
|
|
6
|
-
import {
|
|
6
|
+
import type { AuthOverrides } from "./auth-overrides";
|
|
7
|
+
import { resolveAuthOverrides } from "./auth-overrides";
|
|
7
8
|
import { requireAuth } from "./middleware";
|
|
8
9
|
import { EmailService, EmailConfig } from "../email";
|
|
9
10
|
import { getPasswordResetTemplate, getEmailVerificationTemplate, getWelcomeEmailTemplate } from "../email/templates";
|
|
@@ -23,9 +24,15 @@ export interface AuthModuleConfig {
|
|
|
23
24
|
/** Default role ID to assign to new users (default: none). Must NOT be "admin". */
|
|
24
25
|
defaultRole?: string;
|
|
25
26
|
/** Optional array of OAuth providers */
|
|
26
|
-
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
+
oauthProviders?: OAuthProvider<any>[];
|
|
27
29
|
/** When true, blocks all self-registration regardless of `allowRegistration`. */
|
|
28
30
|
disableSelfRegistration?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Auth overrides for customizing password hashing, credential
|
|
33
|
+
* verification, lifecycle hooks, etc.
|
|
34
|
+
*/
|
|
35
|
+
overrides?: AuthOverrides;
|
|
29
36
|
/**
|
|
30
37
|
* Callback that checks if bootstrap has already been completed.
|
|
31
38
|
* Used by GET /auth/config to report `needsSetup` status.
|
|
@@ -38,7 +45,7 @@ export interface AuthModuleConfig {
|
|
|
38
45
|
* Helper to build standard auth response output
|
|
39
46
|
*/
|
|
40
47
|
function buildAuthResponse(
|
|
41
|
-
user: { id: string; email: string; displayName?: string | null; photoUrl?: string | null },
|
|
48
|
+
user: { id: string; email: string; displayName?: string | null; photoUrl?: string | null; metadata?: Record<string, unknown> | null },
|
|
42
49
|
roleIds: string[],
|
|
43
50
|
accessToken: string,
|
|
44
51
|
refreshToken: string
|
|
@@ -49,7 +56,8 @@ function buildAuthResponse(
|
|
|
49
56
|
email: user.email,
|
|
50
57
|
displayName: user.displayName ?? null,
|
|
51
58
|
photoURL: user.photoUrl ?? null,
|
|
52
|
-
roles: roleIds
|
|
59
|
+
roles: roleIds,
|
|
60
|
+
metadata: user.metadata ?? {}
|
|
53
61
|
},
|
|
54
62
|
tokens: {
|
|
55
63
|
accessToken,
|
|
@@ -93,7 +101,8 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
93
101
|
router.onError(errorHandler);
|
|
94
102
|
|
|
95
103
|
const authRepo = config.authRepo;
|
|
96
|
-
const { emailService, emailConfig, allowRegistration = false } = config;
|
|
104
|
+
const { emailService, emailConfig, allowRegistration = false, overrides } = config;
|
|
105
|
+
const ops = resolveAuthOverrides(overrides);
|
|
97
106
|
|
|
98
107
|
// ── Zod input schemas ──────────────────────────────────────────────
|
|
99
108
|
const registerSchema = z.object({
|
|
@@ -214,7 +223,7 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
214
223
|
}
|
|
215
224
|
|
|
216
225
|
// Validate password strength
|
|
217
|
-
const passwordValidation = validatePasswordStrength(password);
|
|
226
|
+
const passwordValidation = ops.validatePasswordStrength(password);
|
|
218
227
|
if (!passwordValidation.valid) {
|
|
219
228
|
throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
220
229
|
}
|
|
@@ -226,12 +235,16 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
226
235
|
}
|
|
227
236
|
|
|
228
237
|
// Create user
|
|
229
|
-
const passwordHash = await hashPassword(password);
|
|
230
|
-
|
|
238
|
+
const passwordHash = await ops.hashPassword(password);
|
|
239
|
+
let createData: import("./interfaces").CreateUserData = {
|
|
231
240
|
email: email.toLowerCase(),
|
|
232
241
|
passwordHash,
|
|
233
242
|
displayName: displayName || undefined
|
|
234
|
-
}
|
|
243
|
+
};
|
|
244
|
+
if (overrides?.beforeUserCreate) {
|
|
245
|
+
createData = await overrides.beforeUserCreate(createData);
|
|
246
|
+
}
|
|
247
|
+
const user = await authRepo.createUser(createData);
|
|
235
248
|
|
|
236
249
|
// Auto-bootstrap: if this is the very first user in the system, promote to admin.
|
|
237
250
|
// This avoids the chicken-and-egg problem where the first user has no permissions
|
|
@@ -256,6 +269,20 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
256
269
|
sendWelcomeEmail({ email: user.email,
|
|
257
270
|
displayName: user.displayName });
|
|
258
271
|
|
|
272
|
+
// Fire afterUserCreate hook (fire-and-forget)
|
|
273
|
+
if (overrides?.afterUserCreate) {
|
|
274
|
+
overrides.afterUserCreate(user).catch(err => {
|
|
275
|
+
console.error("[AuthOverrides] afterUserCreate error:", err instanceof Error ? err.message : err);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Fire onAuthenticated hook (fire-and-forget)
|
|
280
|
+
if (overrides?.onAuthenticated) {
|
|
281
|
+
overrides.onAuthenticated(user, "register").catch(err => {
|
|
282
|
+
console.error("[AuthOverrides] onAuthenticated error:", err instanceof Error ? err.message : err);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
259
286
|
return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken), 201);
|
|
260
287
|
});
|
|
261
288
|
|
|
@@ -266,18 +293,29 @@ displayName: user.displayName });
|
|
|
266
293
|
router.post("/login", defaultAuthLimiter, async (c) => {
|
|
267
294
|
const { email, password } = parseBody(loginSchema, await c.req.json());
|
|
268
295
|
|
|
269
|
-
|
|
270
|
-
if (!user) {
|
|
271
|
-
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
272
|
-
}
|
|
296
|
+
let user;
|
|
273
297
|
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
298
|
+
if (overrides?.verifyCredentials) {
|
|
299
|
+
// Full credential verification override
|
|
300
|
+
user = await overrides.verifyCredentials(email, password, authRepo);
|
|
301
|
+
if (!user) {
|
|
302
|
+
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
// Default: email lookup + password hash verification
|
|
306
|
+
user = await authRepo.getUserByEmail(email);
|
|
307
|
+
if (!user) {
|
|
308
|
+
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
309
|
+
}
|
|
277
310
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
311
|
+
if (!user.passwordHash) {
|
|
312
|
+
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const isValidPassword = await ops.verifyPassword(password, user.passwordHash);
|
|
316
|
+
if (!isValidPassword) {
|
|
317
|
+
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
318
|
+
}
|
|
281
319
|
}
|
|
282
320
|
|
|
283
321
|
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(
|
|
@@ -286,6 +324,13 @@ displayName: user.displayName });
|
|
|
286
324
|
c.req.header("x-forwarded-for") || "unknown"
|
|
287
325
|
);
|
|
288
326
|
|
|
327
|
+
// Fire onAuthenticated hook (fire-and-forget)
|
|
328
|
+
if (overrides?.onAuthenticated) {
|
|
329
|
+
overrides.onAuthenticated(user, "login").catch(err => {
|
|
330
|
+
console.error("[AuthOverrides] onAuthenticated error:", err instanceof Error ? err.message : err);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
289
334
|
return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken));
|
|
290
335
|
});
|
|
291
336
|
|
|
@@ -434,7 +479,7 @@ displayName: user.displayName }, appName);
|
|
|
434
479
|
const { token, password } = parseBody(resetPasswordSchema, await c.req.json());
|
|
435
480
|
|
|
436
481
|
// Validate password strength
|
|
437
|
-
const passwordValidation = validatePasswordStrength(password);
|
|
482
|
+
const passwordValidation = ops.validatePasswordStrength(password);
|
|
438
483
|
if (!passwordValidation.valid) {
|
|
439
484
|
throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
440
485
|
}
|
|
@@ -448,7 +493,7 @@ displayName: user.displayName }, appName);
|
|
|
448
493
|
}
|
|
449
494
|
|
|
450
495
|
// Update password
|
|
451
|
-
const passwordHash = await hashPassword(password);
|
|
496
|
+
const passwordHash = await ops.hashPassword(password);
|
|
452
497
|
await authRepo.updatePassword(storedToken.userId, passwordHash);
|
|
453
498
|
|
|
454
499
|
// Mark token as used
|
|
@@ -480,19 +525,19 @@ message: "Password has been reset successfully" });
|
|
|
480
525
|
}
|
|
481
526
|
|
|
482
527
|
// Verify old password
|
|
483
|
-
const isValidOldPassword = await verifyPassword(oldPassword, user.passwordHash);
|
|
528
|
+
const isValidOldPassword = await ops.verifyPassword(oldPassword, user.passwordHash);
|
|
484
529
|
if (!isValidOldPassword) {
|
|
485
530
|
throw ApiError.unauthorized("Current password is incorrect", "INVALID_CREDENTIALS");
|
|
486
531
|
}
|
|
487
532
|
|
|
488
533
|
// Validate new password strength
|
|
489
|
-
const passwordValidation = validatePasswordStrength(newPassword);
|
|
534
|
+
const passwordValidation = ops.validatePasswordStrength(newPassword);
|
|
490
535
|
if (!passwordValidation.valid) {
|
|
491
536
|
throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
492
537
|
}
|
|
493
538
|
|
|
494
539
|
// Update password
|
|
495
|
-
const passwordHash = await hashPassword(newPassword);
|
|
540
|
+
const passwordHash = await ops.hashPassword(newPassword);
|
|
496
541
|
await authRepo.updatePassword(user.id, passwordHash);
|
|
497
542
|
|
|
498
543
|
// Invalidate all refresh tokens (security: log out all sessions)
|
|
@@ -727,7 +772,8 @@ message: "Session revoked successfully" });
|
|
|
727
772
|
displayName: result.user.displayName,
|
|
728
773
|
photoURL: result.user.photoUrl,
|
|
729
774
|
emailVerified: result.user.emailVerified,
|
|
730
|
-
roles: result.roles.map(r => r.id)
|
|
775
|
+
roles: result.roles.map(r => r.id),
|
|
776
|
+
metadata: result.user.metadata ?? {}
|
|
731
777
|
}
|
|
732
778
|
});
|
|
733
779
|
});
|
|
@@ -765,7 +811,8 @@ message: "Session revoked successfully" });
|
|
|
765
811
|
displayName: result.user.displayName,
|
|
766
812
|
photoURL: result.user.photoUrl,
|
|
767
813
|
emailVerified: result.user.emailVerified,
|
|
768
|
-
roles: result.roles.map(r => r.id)
|
|
814
|
+
roles: result.roles.map(r => r.id),
|
|
815
|
+
metadata: result.user.metadata ?? {}
|
|
769
816
|
}
|
|
770
817
|
});
|
|
771
818
|
});
|
|
@@ -788,18 +835,13 @@ message: "Session revoked successfully" });
|
|
|
788
835
|
// Registration is allowed when explicitly enabled OR during initial setup
|
|
789
836
|
const registrationAllowed = needsSetup || !!allowRegistration;
|
|
790
837
|
|
|
791
|
-
// Build
|
|
792
|
-
// Also maintain legacy boolean fields for backward compatibility.
|
|
838
|
+
// Build the list of enabled OAuth providers for frontend discovery.
|
|
793
839
|
const enabledProviders = (config.oauthProviders || []).map(p => p.id);
|
|
794
840
|
|
|
795
841
|
return c.json({
|
|
796
842
|
needsSetup,
|
|
797
843
|
registrationEnabled: registrationAllowed,
|
|
798
|
-
// Legacy fields (kept for backward compat)
|
|
799
|
-
googleEnabled: enabledProviders.includes("google"),
|
|
800
|
-
linkedinEnabled: enabledProviders.includes("linkedin"),
|
|
801
844
|
emailServiceEnabled: isEmailConfigured(),
|
|
802
|
-
// New: complete list of available OAuth providers
|
|
803
845
|
enabledProviders
|
|
804
846
|
});
|
|
805
847
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, jest } from "@jest/globals";
|
|
2
2
|
import { CronScheduler, validateCronExpression } from "./cron-scheduler";
|
|
3
|
-
import type { CronJobDefinition } from "@rebasepro/types";
|
|
3
|
+
import type { CronJobDefinition, CronJobLogEntry } from "@rebasepro/types";
|
|
4
4
|
import type { LoadedCronJob } from "./cron-loader";
|
|
5
5
|
|
|
6
6
|
// ─── Helpers ────────────────────────────────────────────────────────
|
|
@@ -475,12 +475,12 @@ describe("CronScheduler", () => {
|
|
|
475
475
|
beforeEach(() => { jest.useRealTimers(); });
|
|
476
476
|
|
|
477
477
|
it("persists logs to store after execution", async () => {
|
|
478
|
-
const insertLog = jest.fn<(entry:
|
|
478
|
+
const insertLog = jest.fn<(entry: CronJobLogEntry) => Promise<void>>().mockResolvedValue(undefined);
|
|
479
479
|
const mockStore = {
|
|
480
480
|
ensureTable: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
481
481
|
insertLog,
|
|
482
|
-
fetchLogs: jest.fn<() => Promise<
|
|
483
|
-
fetchJobStats: jest.fn<() => Promise<Map<string,
|
|
482
|
+
fetchLogs: jest.fn<() => Promise<CronJobLogEntry[]>>().mockResolvedValue([]),
|
|
483
|
+
fetchJobStats: jest.fn<() => Promise<Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string | Date | null }>>>().mockResolvedValue(new Map()),
|
|
484
484
|
};
|
|
485
485
|
scheduler.setStore(mockStore);
|
|
486
486
|
scheduler.registerJobs([makeJob("persisted")]);
|
|
@@ -493,8 +493,8 @@ describe("CronScheduler", () => {
|
|
|
493
493
|
const mockStore = {
|
|
494
494
|
ensureTable: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
495
495
|
insertLog: jest.fn<() => Promise<void>>().mockRejectedValue(new Error("DB down")),
|
|
496
|
-
fetchLogs: jest.fn<() => Promise<
|
|
497
|
-
fetchJobStats: jest.fn<() => Promise<Map<string,
|
|
496
|
+
fetchLogs: jest.fn<() => Promise<CronJobLogEntry[]>>().mockResolvedValue([]),
|
|
497
|
+
fetchJobStats: jest.fn<() => Promise<Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string | Date | null }>>>().mockResolvedValue(new Map()),
|
|
498
498
|
};
|
|
499
499
|
scheduler.setStore(mockStore);
|
|
500
500
|
scheduler.registerJobs([makeJob("resilient")]);
|
|
@@ -504,13 +504,13 @@ describe("CronScheduler", () => {
|
|
|
504
504
|
});
|
|
505
505
|
|
|
506
506
|
it("seeds counters from store on start", async () => {
|
|
507
|
-
const stats = new Map<string,
|
|
507
|
+
const stats = new Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string | Date | null }>();
|
|
508
508
|
stats.set("seeded", { totalRuns: 42, totalFailures: 3, lastRunAt: "2026-01-01T00:00:00Z" });
|
|
509
509
|
const mockStore = {
|
|
510
510
|
ensureTable: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
511
511
|
insertLog: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
512
|
-
fetchLogs: jest.fn<() => Promise<
|
|
513
|
-
fetchJobStats: jest.fn<() => Promise<Map<string,
|
|
512
|
+
fetchLogs: jest.fn<() => Promise<CronJobLogEntry[]>>().mockResolvedValue([]),
|
|
513
|
+
fetchJobStats: jest.fn<() => Promise<Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string | Date | null }>>>().mockResolvedValue(stats),
|
|
514
514
|
};
|
|
515
515
|
scheduler.setStore(mockStore);
|
|
516
516
|
scheduler.registerJobs([makeJob("seeded")]);
|
|
@@ -15,7 +15,7 @@ import type { CronStore } from "./cron-store";
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Expand a single cron field into an ordered array of allowed values.
|
|
18
|
-
* Supports: `*`, `N`, `N-M`, `N/S`, `N-M/S`,
|
|
18
|
+
* Supports: `*`, `N`, `N-M`, `N/S`, `N-M/S`, `*\/S`, and comma-separated combinations.
|
|
19
19
|
*/
|
|
20
20
|
function expandCronField(field: string, min: number, max: number): number[] {
|
|
21
21
|
const results = new Set<number>();
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import * as crypto from "crypto";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate a cryptographically secure random secret (hex-encoded).
|
|
6
|
+
* Used as a fallback when secrets are not explicitly configured —
|
|
7
|
+
* avoids the need for hardcoded dev secrets.
|
|
8
|
+
*/
|
|
9
|
+
function generateSecret(bytes = 48): string {
|
|
10
|
+
return crypto.randomBytes(bytes).toString("hex");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Zod coercion helper: transforms `"true"` → `true`, everything else → `false`.
|
|
15
|
+
*/
|
|
16
|
+
const boolString = z.enum(["true", "false", ""]).default("false").transform(v => v === "true");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Zod coercion helper for optional boolean strings.
|
|
20
|
+
*/
|
|
21
|
+
const optionalBoolString = z.enum(["true", "false", ""]).optional().transform(v => v === "true");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Helper to determine if a string is a localhost or loopback address/URL.
|
|
25
|
+
*/
|
|
26
|
+
function isLocalhostOrLoopback(value: string): boolean {
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
if (!trimmed) return false;
|
|
29
|
+
|
|
30
|
+
// 1. Try parsing as URL
|
|
31
|
+
try {
|
|
32
|
+
const parsed = new URL(trimmed);
|
|
33
|
+
const host = parsed.hostname.toLowerCase();
|
|
34
|
+
if (
|
|
35
|
+
host === "localhost" ||
|
|
36
|
+
host === "127.0.0.1" ||
|
|
37
|
+
host === "::1" ||
|
|
38
|
+
host.startsWith("127.")
|
|
39
|
+
) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// Not a standard URL, or custom protocol that URL class fails to parse
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 2. Custom protocol parser fallback (e.g. postgres://, mongodb://, etc.)
|
|
47
|
+
const protocolMatch = trimmed.match(/^[a-zA-Z0-9+-.]+:\/\/(?:[^@/]+@)?(?:\[([^\]]+)\]|([^:/]+))/);
|
|
48
|
+
if (protocolMatch) {
|
|
49
|
+
const host = (protocolMatch[1] || protocolMatch[2] || "").toLowerCase();
|
|
50
|
+
if (
|
|
51
|
+
host === "localhost" ||
|
|
52
|
+
host === "127.0.0.1" ||
|
|
53
|
+
host === "::1" ||
|
|
54
|
+
host.startsWith("127.")
|
|
55
|
+
) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 3. Plain hostname / host:port checker (e.g. "localhost", "127.0.0.1:5432", "[::1]:6379")
|
|
61
|
+
let plainHost = trimmed.toLowerCase();
|
|
62
|
+
if (plainHost.startsWith("[") && plainHost.includes("]")) {
|
|
63
|
+
const endBracket = plainHost.indexOf("]");
|
|
64
|
+
plainHost = plainHost.slice(1, endBracket);
|
|
65
|
+
} else {
|
|
66
|
+
const colonIndex = plainHost.lastIndexOf(":");
|
|
67
|
+
if (colonIndex !== -1 && plainHost.indexOf(":") === colonIndex) {
|
|
68
|
+
plainHost = plainHost.substring(0, colonIndex);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (
|
|
73
|
+
plainHost === "localhost" ||
|
|
74
|
+
plainHost === "127.0.0.1" ||
|
|
75
|
+
plainHost === "::1" ||
|
|
76
|
+
plainHost.startsWith("127.")
|
|
77
|
+
) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The full set of environment variables recognized by a Rebase backend.
|
|
86
|
+
*/
|
|
87
|
+
const rebaseEnvSchema = z.object({
|
|
88
|
+
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
|
89
|
+
PORT: z.string().default("3001").transform(Number),
|
|
90
|
+
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
|
|
91
|
+
ADMIN_CONNECTION_STRING: z.string().url().optional(),
|
|
92
|
+
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters long"),
|
|
93
|
+
JWT_ACCESS_EXPIRES_IN: z.string().default("1h"),
|
|
94
|
+
JWT_REFRESH_EXPIRES_IN: z.string().default("30d"),
|
|
95
|
+
GOOGLE_CLIENT_ID: z.string().optional(),
|
|
96
|
+
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
|
97
|
+
REBASE_SERVICE_KEY: z.string().optional(),
|
|
98
|
+
ALLOW_REGISTRATION: boolString,
|
|
99
|
+
ALLOW_LOCALHOST_IN_PRODUCTION: optionalBoolString,
|
|
100
|
+
CORS_ORIGINS: z.string().optional(),
|
|
101
|
+
FRONTEND_URL: z.string().optional(),
|
|
102
|
+
DB_POOL_MAX: z.string().default("20").transform(Number),
|
|
103
|
+
DB_POOL_IDLE_TIMEOUT: z.string().default("30000").transform(Number),
|
|
104
|
+
DB_POOL_CONNECT_TIMEOUT: z.string().default("10000").transform(Number),
|
|
105
|
+
FORCE_LOCAL_STORAGE: optionalBoolString,
|
|
106
|
+
STORAGE_TYPE: z.enum(["local", "s3"]).default("local"),
|
|
107
|
+
STORAGE_PATH: z.string().optional(),
|
|
108
|
+
S3_BUCKET: z.string().optional(),
|
|
109
|
+
S3_REGION: z.string().optional(),
|
|
110
|
+
S3_ACCESS_KEY_ID: z.string().optional(),
|
|
111
|
+
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
|
112
|
+
S3_ENDPOINT: z.string().url().optional(),
|
|
113
|
+
S3_FORCE_PATH_STYLE: optionalBoolString,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** Inferred type of the validated environment. */
|
|
117
|
+
export type RebaseEnv = z.infer<typeof rebaseEnvSchema>;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Load and validate the Rebase environment configuration from `process.env`.
|
|
121
|
+
*
|
|
122
|
+
* Call this **after** your `.env` file has been loaded (via `dotenv`, `--env-file`,
|
|
123
|
+
* container injection, etc.). This function does not load `.env` files itself —
|
|
124
|
+
* that is a deployment concern, not a framework concern.
|
|
125
|
+
*
|
|
126
|
+
* Behavior:
|
|
127
|
+
* - Auto-generates ephemeral `JWT_SECRET` and `REBASE_SERVICE_KEY` in
|
|
128
|
+
* non-production mode so developers can start without manual setup.
|
|
129
|
+
* - Blocks auto-generated secrets in production.
|
|
130
|
+
* - Returns a fully typed, validated env object.
|
|
131
|
+
*
|
|
132
|
+
* Use `extend` to add your own typed env variables on top of the base Rebase schema:
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* ```ts
|
|
136
|
+
* import dotenv from "dotenv";
|
|
137
|
+
* import { z } from "zod";
|
|
138
|
+
* import { loadEnv } from "@rebasepro/server-core";
|
|
139
|
+
*
|
|
140
|
+
* dotenv.config({ path: "../../.env" });
|
|
141
|
+
*
|
|
142
|
+
* // Basic — just Rebase env vars:
|
|
143
|
+
* export const env = loadEnv();
|
|
144
|
+
*
|
|
145
|
+
* // Extended — add your own typed vars:
|
|
146
|
+
* export const env = loadEnv({
|
|
147
|
+
* extend: z.object({
|
|
148
|
+
* SMTP_HOST: z.string().optional(),
|
|
149
|
+
* SMTP_PORT: z.string().default("587").transform(Number),
|
|
150
|
+
* STRIPE_SECRET_KEY: z.string(),
|
|
151
|
+
* })
|
|
152
|
+
* });
|
|
153
|
+
* // env.SMTP_HOST → string | undefined (fully typed)
|
|
154
|
+
* // env.STRIPE_SECRET_KEY → string (validated, required)
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
export function loadEnv(): RebaseEnv;
|
|
158
|
+
export function loadEnv<E extends z.AnyZodObject>(options: { extend: E }): RebaseEnv & z.infer<E>;
|
|
159
|
+
export function loadEnv(options?: { extend?: z.AnyZodObject }): Record<string, unknown> {
|
|
160
|
+
// Auto-generate dev secrets before validation so the Zod schema sees valid values.
|
|
161
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
162
|
+
const autoGeneratedSecrets: string[] = [];
|
|
163
|
+
|
|
164
|
+
if (!isProduction) {
|
|
165
|
+
if (!process.env.JWT_SECRET) {
|
|
166
|
+
process.env.JWT_SECRET = generateSecret();
|
|
167
|
+
autoGeneratedSecrets.push("JWT_SECRET");
|
|
168
|
+
}
|
|
169
|
+
if (!process.env.REBASE_SERVICE_KEY) {
|
|
170
|
+
process.env.REBASE_SERVICE_KEY = generateSecret();
|
|
171
|
+
autoGeneratedSecrets.push("REBASE_SERVICE_KEY");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Merge base schema with user extensions (if provided).
|
|
176
|
+
const combinedSchema = options?.extend
|
|
177
|
+
? rebaseEnvSchema.merge(options.extend)
|
|
178
|
+
: rebaseEnvSchema;
|
|
179
|
+
|
|
180
|
+
// Validate with production-specific refinements.
|
|
181
|
+
const schema = combinedSchema.superRefine((data, ctx) => {
|
|
182
|
+
const d = data as RebaseEnv & Record<string, unknown>;
|
|
183
|
+
if (d.NODE_ENV === "production" && !d.CORS_ORIGINS && !d.FRONTEND_URL) {
|
|
184
|
+
ctx.addIssue({
|
|
185
|
+
code: z.ZodIssueCode.custom,
|
|
186
|
+
message: "CORS_ORIGINS or FRONTEND_URL must be set in production to secure the API.",
|
|
187
|
+
path: ["CORS_ORIGINS"],
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (d.NODE_ENV === "production" && autoGeneratedSecrets.length > 0) {
|
|
191
|
+
ctx.addIssue({
|
|
192
|
+
code: z.ZodIssueCode.custom,
|
|
193
|
+
message: `${autoGeneratedSecrets.join(", ")} must be explicitly set in production. ` +
|
|
194
|
+
`Do not rely on auto-generated secrets outside development.`,
|
|
195
|
+
path: [autoGeneratedSecrets[0]],
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (d.NODE_ENV === "production" && !d.ALLOW_LOCALHOST_IN_PRODUCTION) {
|
|
199
|
+
for (const [key, value] of Object.entries(data)) {
|
|
200
|
+
if (key === "CORS_ORIGINS") continue;
|
|
201
|
+
if (typeof value === "string" && isLocalhostOrLoopback(value)) {
|
|
202
|
+
ctx.addIssue({
|
|
203
|
+
code: z.ZodIssueCode.custom,
|
|
204
|
+
message: `Environment variable ${key} contains a local/loopback URL or host "${value}". Deployed instances must not connect to localhost.`,
|
|
205
|
+
path: [key],
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const env = schema.parse(process.env);
|
|
213
|
+
|
|
214
|
+
// Warn after successful parse so the server still starts in dev.
|
|
215
|
+
if (autoGeneratedSecrets.length > 0) {
|
|
216
|
+
console.warn(
|
|
217
|
+
`⚠️ Auto-generated secrets for: ${autoGeneratedSecrets.join(", ")}. ` +
|
|
218
|
+
`These are ephemeral — existing tokens will be invalidated on restart. ` +
|
|
219
|
+
`Set them explicitly in .env for persistent sessions.`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return env as Record<string, unknown>;
|
|
224
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -62,5 +62,9 @@ export * from "./serve-spa";
|
|
|
62
62
|
// Dev-mode port resolution (retry on EADDRINUSE)
|
|
63
63
|
export * from "./utils/dev-port";
|
|
64
64
|
|
|
65
|
+
// Environment validation
|
|
66
|
+
export { loadEnv } from "./env";
|
|
67
|
+
export type { RebaseEnv } from "./env";
|
|
68
|
+
|
|
65
69
|
// Backend bootstrappers (pluggable driver initialization)
|
|
66
70
|
|