From 4b03fc4e7a957006fa2c1e9020e5d068ae6f7309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Wed, 13 May 2026 15:54:15 -0300 Subject: [PATCH] fix(dev-env): gate powersync localhost defaults to development NODE_ENV Closes HIGH bot finding: empty default in non-dev forces explicit env var. --- backend/src/config/settings.test.ts | 32 +++++++++++++++++++++++++++-- backend/src/config/settings.ts | 19 ++++++++++------- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/backend/src/config/settings.test.ts b/backend/src/config/settings.test.ts index 8068d46e..16c56e29 100644 --- a/backend/src/config/settings.test.ts +++ b/backend/src/config/settings.test.ts @@ -419,6 +419,7 @@ describe('Config Settings', () => { 'POWERSYNC_JWT_KID', 'POWERSYNC_JWT_SECRET', 'POWERSYNC_TOKEN_EXPIRY_SECONDS', + 'NODE_ENV', ] as const let savedEnv: Partial> @@ -444,16 +445,29 @@ describe('Config Settings', () => { clearSettingsCache() }) - it('should use default values when PowerSync env vars are unset', () => { + it('should default PowerSync env vars to empty strings outside development', () => { for (const key of POWERSYNC_ENV_KEYS) { delete process.env[key] } const settings = getSettings() + expect(settings.powersyncUrl).toBe('') + expect(settings.powersyncJwtKid).toBe('') + expect(settings.powersyncJwtSecret).toBe('') + expect(settings.powersyncTokenExpirySeconds).toBe(3600) + }) + + it('should use localhost defaults when NODE_ENV=development', () => { + for (const key of POWERSYNC_ENV_KEYS) { + delete process.env[key] + } + process.env.NODE_ENV = 'development' + + const settings = getSettings() + expect(settings.powersyncUrl).toBe('http://localhost:8080') expect(settings.powersyncJwtKid).toBe('powersync-dev') expect(settings.powersyncJwtSecret).toBe('powersync-dev-secret-change-in-production') - expect(settings.powersyncTokenExpirySeconds).toBe(3600) }) it('should read PowerSync values from env when set', () => { @@ -505,6 +519,20 @@ describe('Config Settings', () => { process.env.POWERSYNC_JWT_SECRET = 'a'.repeat(32) expect(() => getSettings()).not.toThrow() }) + + it('should allow empty JWT secret when powersyncUrl is empty', () => { + process.env.POWERSYNC_URL = '' + process.env.POWERSYNC_JWT_SECRET = '' + const settings = getSettings() + expect(settings.powersyncJwtSecret).toBe('') + }) + + it('should reject empty JWT secret in non-dev when POWERSYNC_URL is set', () => { + delete process.env.NODE_ENV + process.env.POWERSYNC_URL = 'https://sync.example.com' + delete process.env.POWERSYNC_JWT_SECRET + expect(() => getSettings()).toThrow() + }) }) describe('isOAuthRedirectUriAllowed', () => { diff --git a/backend/src/config/settings.ts b/backend/src/config/settings.ts index aa27a79b..72ae71aa 100644 --- a/backend/src/config/settings.ts +++ b/backend/src/config/settings.ts @@ -58,10 +58,10 @@ const settingsSchema = z waitlistEnabled: z.boolean().default(false), waitlistAutoApproveDomains: z.string().default(''), - // PowerSync settings — defaults match the local dev stack in powersync-service/config/config.yaml - powersyncUrl: z.string().default('http://localhost:8080'), - powersyncJwtKid: z.string().default('powersync-dev'), - powersyncJwtSecret: z.string().default('powersync-dev-secret-change-in-production'), + // PowerSync settings + powersyncUrl: z.string().default(''), + powersyncJwtKid: z.string().default(''), + powersyncJwtSecret: z.string().default(''), powersyncTokenExpirySeconds: z.coerce.number().int().positive().default(3600), // CORS settings — comma-separated list of exact origins. @@ -113,6 +113,7 @@ export type Settings = z.infer * Parse and validate environment variables into settings */ const parseSettings = (): Settings => { + const isDevelopment = process.env.NODE_ENV === 'development' const env = { fireworksApiKey: process.env.FIREWORKS_API_KEY || '', mistralApiKey: process.env.MISTRAL_API_KEY || '', @@ -143,9 +144,13 @@ const parseSettings = (): Settings => { posthogApiKey: process.env.POSTHOG_API_KEY || '', waitlistEnabled: process.env.WAITLIST_ENABLED === 'true', waitlistAutoApproveDomains: process.env.WAITLIST_AUTO_APPROVE_DOMAINS || '', - powersyncUrl: process.env.POWERSYNC_URL || 'http://localhost:8080', - powersyncJwtKid: process.env.POWERSYNC_JWT_KID || 'powersync-dev', - powersyncJwtSecret: process.env.POWERSYNC_JWT_SECRET || 'powersync-dev-secret-change-in-production', + // Localhost defaults apply only in development. In any other NODE_ENV the + // value defaults to '' so the schema's superRefine guard correctly rejects + // an empty JWT secret whenever POWERSYNC_URL is set explicitly. + powersyncUrl: process.env.POWERSYNC_URL || (isDevelopment ? 'http://localhost:8080' : ''), + powersyncJwtKid: process.env.POWERSYNC_JWT_KID || (isDevelopment ? 'powersync-dev' : ''), + powersyncJwtSecret: + process.env.POWERSYNC_JWT_SECRET || (isDevelopment ? 'powersync-dev-secret-change-in-production' : ''), powersyncTokenExpirySeconds: process.env.POWERSYNC_TOKEN_EXPIRY_SECONDS || '3600', corsOrigins: process.env.CORS_ORIGINS || 'http://localhost:1420,tauri://localhost,http://tauri.localhost', corsAllowCredentials: process.env.CORS_ALLOW_CREDENTIALS !== 'false',