@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.
Files changed (75) hide show
  1. package/LICENSE +22 -6
  2. package/dist/common/src/data/query_builder.d.ts +51 -0
  3. package/dist/common/src/index.d.ts +1 -0
  4. package/dist/common/src/util/entities.d.ts +2 -2
  5. package/dist/common/src/util/relations.d.ts +1 -1
  6. package/dist/{index-DXVBFp5V.js → index-BZoAtuqi.js} +6 -2
  7. package/dist/index-BZoAtuqi.js.map +1 -0
  8. package/dist/index.es.js +16038 -15240
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/index.umd.js +15980 -15178
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/server-core/src/auth/adapter-middleware.d.ts +33 -0
  13. package/dist/server-core/src/auth/admin-routes.d.ts +6 -0
  14. package/dist/server-core/src/auth/auth-overrides.d.ts +139 -0
  15. package/dist/server-core/src/auth/builtin-auth-adapter.d.ts +49 -0
  16. package/dist/server-core/src/auth/crypto-utils.d.ts +16 -0
  17. package/dist/server-core/src/auth/custom-auth-adapter.d.ts +39 -0
  18. package/dist/server-core/src/auth/index.d.ts +7 -0
  19. package/dist/server-core/src/auth/interfaces.d.ts +2 -0
  20. package/dist/server-core/src/auth/middleware.d.ts +18 -0
  21. package/dist/server-core/src/auth/rls-scope.d.ts +31 -0
  22. package/dist/server-core/src/auth/routes.d.ts +7 -1
  23. package/dist/server-core/src/env.d.ts +131 -0
  24. package/dist/server-core/src/index.d.ts +2 -0
  25. package/dist/server-core/src/init.d.ts +62 -3
  26. package/dist/types/src/controllers/auth.d.ts +9 -8
  27. package/dist/types/src/controllers/client.d.ts +3 -0
  28. package/dist/types/src/controllers/data.d.ts +21 -0
  29. package/dist/types/src/types/auth_adapter.d.ts +356 -0
  30. package/dist/types/src/types/collections.d.ts +67 -2
  31. package/dist/types/src/types/database_adapter.d.ts +94 -0
  32. package/dist/types/src/types/entity_actions.d.ts +7 -1
  33. package/dist/types/src/types/entity_callbacks.d.ts +1 -1
  34. package/dist/types/src/types/entity_views.d.ts +36 -1
  35. package/dist/types/src/types/index.d.ts +2 -0
  36. package/dist/types/src/types/plugins.d.ts +1 -1
  37. package/dist/types/src/types/properties.d.ts +24 -5
  38. package/dist/types/src/types/property_config.d.ts +6 -2
  39. package/dist/types/src/types/relations.d.ts +1 -1
  40. package/dist/types/src/types/translations.d.ts +8 -0
  41. package/dist/types/src/users/user.d.ts +5 -0
  42. package/jest.config.cjs +4 -1
  43. package/package.json +27 -27
  44. package/src/api/errors.ts +1 -1
  45. package/src/api/graphql/graphql-schema-generator.ts +7 -0
  46. package/src/api/openapi-generator.ts +13 -1
  47. package/src/api/rest/api-generator-count.test.ts +14 -12
  48. package/src/api/rest/query-parser.ts +2 -20
  49. package/src/auth/adapter-middleware.ts +83 -0
  50. package/src/auth/admin-routes.ts +36 -43
  51. package/src/auth/auth-overrides.ts +172 -0
  52. package/src/auth/builtin-auth-adapter.ts +384 -0
  53. package/src/auth/crypto-utils.ts +31 -0
  54. package/src/auth/custom-auth-adapter.ts +85 -0
  55. package/src/auth/index.ts +10 -0
  56. package/src/auth/interfaces.ts +2 -0
  57. package/src/auth/jwt.ts +3 -1
  58. package/src/auth/middleware.ts +2 -46
  59. package/src/auth/rls-scope.ts +58 -0
  60. package/src/auth/routes.ts +74 -32
  61. package/src/cron/cron-scheduler.test.ts +9 -9
  62. package/src/cron/cron-scheduler.ts +1 -1
  63. package/src/env.ts +224 -0
  64. package/src/index.ts +4 -0
  65. package/src/init.ts +355 -135
  66. package/src/storage/routes.ts +1 -19
  67. package/src/utils/logging.ts +3 -3
  68. package/test/admin-routes.test.ts +10 -4
  69. package/test/auth-routes.test.ts +2 -2
  70. package/test/backend-hooks-admin.test.ts +32 -12
  71. package/test/custom-auth-adapter.test.ts +177 -0
  72. package/test/env.test.ts +138 -0
  73. package/test/query-parser.test.ts +0 -29
  74. package/tsconfig.json +3 -0
  75. package/dist/index-DXVBFp5V.js.map +0 -1
@@ -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 { hashPassword, verifyPassword, validatePasswordStrength } from "./password";
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
- oauthProviders?: OAuthProvider[];
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
- const user = await authRepo.createUser({
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
- const user = await authRepo.getUserByEmail(email);
270
- if (!user) {
271
- throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
272
- }
296
+ let user;
273
297
 
274
- if (!user.passwordHash) {
275
- throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
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
- const isValidPassword = await verifyPassword(password, user.passwordHash);
279
- if (!isValidPassword) {
280
- throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
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 a dynamic map of enabled providers for frontend discovery.
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: any) => Promise<void>>().mockResolvedValue(undefined);
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<any[]>>().mockResolvedValue([]),
483
- fetchJobStats: jest.fn<() => Promise<Map<string, any>>>().mockResolvedValue(new Map()),
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<any[]>>().mockResolvedValue([]),
497
- fetchJobStats: jest.fn<() => Promise<Map<string, any>>>().mockResolvedValue(new Map()),
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, any>();
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<any[]>>().mockResolvedValue([]),
513
- fetchJobStats: jest.fn<() => Promise<Map<string, any>>>().mockResolvedValue(stats),
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`, `*​/S`, and comma-separated combinations.
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