From 9545943219a5eb70ee567f5922808d0b8df69388 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sun, 22 Mar 2026 15:22:11 +0000 Subject: [PATCH 01/10] perf(sessions): move heartbeat from DB writes to Redis sorted set fix(about): update LinkedIn follower fallback to 1800 --- frontend/app/api/sessions/activity/route.ts | 99 ++++++++++++--------- frontend/lib/about/stats.ts | 2 +- 2 files changed, 59 insertions(+), 42 deletions(-) diff --git a/frontend/app/api/sessions/activity/route.ts b/frontend/app/api/sessions/activity/route.ts index 354204e2..8e92c844 100644 --- a/frontend/app/api/sessions/activity/route.ts +++ b/frontend/app/api/sessions/activity/route.ts @@ -5,8 +5,11 @@ import { NextResponse } from 'next/server'; import { db } from '@/db'; import { activeSessions } from '@/db/schema/sessions'; +import { getRedisClient } from '@/lib/redis'; const SESSION_TIMEOUT_MINUTES = 15; +const SESSION_TIMEOUT_MS = SESSION_TIMEOUT_MINUTES * 60 * 1000; +const REDIS_KEY = 'online_sessions'; function getHeartbeatThrottleMs(): number { const raw = process.env.HEARTBEAT_THROTTLE_MS; @@ -17,8 +20,59 @@ function getHeartbeatThrottleMs(): number { return Math.max(floor, parsed); } -export async function POST() { +async function heartbeatViaRedis(sessionId: string): Promise { + const redis = getRedisClient(); + if (!redis) return null; + try { + const now = Date.now(); + const pipeline = redis.pipeline(); + pipeline.zadd(REDIS_KEY, { score: now, member: sessionId }); + pipeline.zremrangebyscore(REDIS_KEY, '-inf', now - SESSION_TIMEOUT_MS); + pipeline.zcard(REDIS_KEY); + + const results = await pipeline.exec(); + return results[2] as number; + } catch (err) { + console.warn('Redis heartbeat failed, falling back to DB:', err); + return null; + } +} + +async function heartbeatViaDb(sessionId: string): Promise { + const now = new Date(); + const heartbeatThreshold = new Date( + now.getTime() - getHeartbeatThrottleMs() + ); + + await db + .insert(activeSessions) + .values({ sessionId, lastActivity: now }) + .onConflictDoUpdate({ + target: activeSessions.sessionId, + set: { lastActivity: now }, + setWhere: lt(activeSessions.lastActivity, heartbeatThreshold), + }); + + if (Math.random() < 0.05) { + const cleanupThreshold = new Date(Date.now() - SESSION_TIMEOUT_MS); + db.delete(activeSessions) + .where(lt(activeSessions.lastActivity, cleanupThreshold)) + .catch(err => console.error('Cleanup error:', err)); + } + + const countThreshold = new Date(Date.now() - SESSION_TIMEOUT_MS); + const result = await db + .select({ total: sql`count(*)` }) + .from(activeSessions) + .where(gte(activeSessions.lastActivity, countThreshold)); + + return Number(result[0]?.total || 0); +} + + +export async function POST() { + try { const cookieStore = await cookies(); let sessionId = cookieStore.get('user_session_id')?.value; @@ -26,47 +80,10 @@ export async function POST() { sessionId = randomUUID(); } - const now = new Date(); - const heartbeatThreshold = new Date( - now.getTime() - getHeartbeatThrottleMs() - ); - - await db - .insert(activeSessions) - .values({ - sessionId, - lastActivity: now, - }) - .onConflictDoUpdate({ - target: activeSessions.sessionId, - set: { lastActivity: now }, - setWhere: lt(activeSessions.lastActivity, heartbeatThreshold), - }); - - if (Math.random() < 0.05) { - const cleanupThreshold = new Date( - Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000 - ); - - db.delete(activeSessions) - .where(lt(activeSessions.lastActivity, cleanupThreshold)) - .catch(err => console.error('Cleanup error:', err)); - } - - const countThreshold = new Date( - Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000 - ); + const redisCount = await heartbeatViaRedis(sessionId); + const online = redisCount ?? (await heartbeatViaDb(sessionId)); - const result = await db - .select({ - total: sql`count(*)`, - }) - .from(activeSessions) - .where(gte(activeSessions.lastActivity, countThreshold)); - - const response = NextResponse.json({ - online: Number(result[0]?.total || 0), - }); + const response = NextResponse.json({ online }); response.cookies.set('user_session_id', sessionId, { httpOnly: true, diff --git a/frontend/lib/about/stats.ts b/frontend/lib/about/stats.ts index c4ee20ef..e51afc9d 100644 --- a/frontend/lib/about/stats.ts +++ b/frontend/lib/about/stats.ts @@ -43,7 +43,7 @@ export const getPlatformStats = unstable_cache( const linkedinCount = process.env.LINKEDIN_FOLLOWER_COUNT ? parseInt(process.env.LINKEDIN_FOLLOWER_COUNT) - : 1700; + : 1800; let totalUsers = 243; let solvedTests = 1890; From 6b4dfec01c7471557b3dc1e1db76397bb36e08bd Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 11:43:27 +0000 Subject: [PATCH 02/10] fix(db): require explicit APP_ENV and add runtime env diagnostics --- frontend/app/[locale]/layout.tsx | 1 + frontend/db/index.ts | 19 +++++++++++++++++-- .../db/queries/categories/admin-categories.ts | 8 ++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index e7a496fd..ddf43e1f 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -37,6 +37,7 @@ export default async function LocaleLayout({ title: string; }> = []; + const enableAdmin = ( process.env.ENABLE_ADMIN_API ?? diff --git a/frontend/db/index.ts b/frontend/db/index.ts index 8648dd00..cb9b681e 100644 --- a/frontend/db/index.ts +++ b/frontend/db/index.ts @@ -12,14 +12,29 @@ dotenv.config(); type AppDatabase = PgDatabase; const APP_ENV = process.env.APP_ENV?.trim().toLowerCase(); + +if (!APP_ENV) { + throw new Error( + '[db] APP_ENV is required. Set APP_ENV=local in .env for development, or APP_ENV=develop/production for deployment' + ); +} + const IS_LOCAL_ENV = APP_ENV === 'local'; + +if (process.env.NODE_ENV !== 'test') { + console.log('[db] runtime env check', { + has_DATABASE_URL: Boolean(process.env.DATABASE_URL?.trim()), + has_DATABASE_URL_LOCAL: Boolean(process.env.DATABASE_URL_LOCAL?.trim()), + }); +} + const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; const REQUIRED_LOCAL_DB_URL = process.env.SHOP_REQUIRED_DATABASE_URL_LOCAL; if (STRICT_LOCAL_DB_GUARD) { if (!IS_LOCAL_ENV) { throw new Error( - `[db] SHOP_STRICT_LOCAL_DB=1 requires APP_ENV=local (got "${APP_ENV ?? 'undefined'}")` + `[db] SHOP_STRICT_LOCAL_DB=1 requires APP_ENV=local (got "${APP_ENV}")` ); } @@ -68,7 +83,7 @@ if (IS_LOCAL_ENV) { if (!url) { throw new Error( - `[db] APP_ENV=${APP_ENV ?? 'undefined'} requires DATABASE_URL to be set` + `[db] APP_ENV=${APP_ENV} requires DATABASE_URL to be set` ); } diff --git a/frontend/db/queries/categories/admin-categories.ts b/frontend/db/queries/categories/admin-categories.ts index 97a59934..2e92e358 100644 --- a/frontend/db/queries/categories/admin-categories.ts +++ b/frontend/db/queries/categories/admin-categories.ts @@ -20,13 +20,17 @@ export async function getAdminCategoryList(): Promise { title: categoryTranslations.title, }) .from(categories) - .innerJoin( + .leftJoin( categoryTranslations, sql`${categoryTranslations.categoryId} = ${categories.id} AND ${categoryTranslations.locale} = ${ADMIN_LOCALE}` ) .orderBy(categories.displayOrder); - return rows; + return rows.map(row => ({ + id: row.id, + slug: row.slug, + title: row.title ?? row.slug, + })); } export async function getMaxQuizDisplayOrder( From 35a423e110e50e747786e642f34eafeffe7a7192 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 12:23:58 +0000 Subject: [PATCH 03/10] fix(db): replace APP_ENV throw with runtime env diagnostics for Netlify debugging --- frontend/db/index.ts | 14 ++++++-------- frontend/db/queries/categories/admin-categories.ts | 2 +- frontend/vitest.config.ts | 3 +++ frontend/vitest.shop.config.ts | 3 +++ 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/frontend/db/index.ts b/frontend/db/index.ts index cb9b681e..5e76ca23 100644 --- a/frontend/db/index.ts +++ b/frontend/db/index.ts @@ -13,21 +13,19 @@ type AppDatabase = PgDatabase; const APP_ENV = process.env.APP_ENV?.trim().toLowerCase(); -if (!APP_ENV) { - throw new Error( - '[db] APP_ENV is required. Set APP_ENV=local in .env for development, or APP_ENV=develop/production for deployment' - ); -} - -const IS_LOCAL_ENV = APP_ENV === 'local'; - if (process.env.NODE_ENV !== 'test') { console.log('[db] runtime env check', { + APP_ENV: process.env.APP_ENV ?? '', has_DATABASE_URL: Boolean(process.env.DATABASE_URL?.trim()), has_DATABASE_URL_LOCAL: Boolean(process.env.DATABASE_URL_LOCAL?.trim()), + NETLIFY: process.env.NETLIFY ?? '', + CONTEXT: process.env.CONTEXT ?? '', + NODE_ENV: process.env.NODE_ENV ?? '', }); } +const IS_LOCAL_ENV = APP_ENV === 'local'; + const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; const REQUIRED_LOCAL_DB_URL = process.env.SHOP_REQUIRED_DATABASE_URL_LOCAL; diff --git a/frontend/db/queries/categories/admin-categories.ts b/frontend/db/queries/categories/admin-categories.ts index 2e92e358..cbc2bf6a 100644 --- a/frontend/db/queries/categories/admin-categories.ts +++ b/frontend/db/queries/categories/admin-categories.ts @@ -29,7 +29,7 @@ export async function getAdminCategoryList(): Promise { return rows.map(row => ({ id: row.id, slug: row.slug, - title: row.title ?? row.slug, + title: row.title?.trim() || row.slug })); } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 5c42afa4..3f49e0ab 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -13,6 +13,9 @@ export default defineConfig({ }, }, test: { + env: { + APP_ENV: 'local', + }, environment: 'node', include: [ 'lib/tests/**/*.test.ts', diff --git a/frontend/vitest.shop.config.ts b/frontend/vitest.shop.config.ts index 9ecb7b5f..8cc77d6b 100644 --- a/frontend/vitest.shop.config.ts +++ b/frontend/vitest.shop.config.ts @@ -13,6 +13,9 @@ export default defineConfig({ }, }, test: { + env: { + APP_ENV: 'local', + }, environment: 'node', include: ['lib/tests/shop/**/*.test.ts'], globals: true, From 03dee65a332cdf6b84c2154e2b8ebd4269cca6c0 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 14:17:25 +0000 Subject: [PATCH 04/10] fix(deploy): inline all server env vars at build time for Netlify SSR runtime --- frontend/app/[locale]/layout.tsx | 18 +++++------------- frontend/next.config.ts | 9 +++++---- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index ddf43e1f..3cd94956 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -10,7 +10,7 @@ import { CookieBanner } from '@/components/shared/CookieBanner'; import Footer from '@/components/shared/Footer'; import { ScrollWatcher } from '@/components/shared/ScrollWatcher'; import { ThemeProvider } from '@/components/theme/ThemeProvider'; -// import { getCachedBlogCategories } from '@/db/queries/blog/blog-categories'; +import { getCachedBlogCategories } from '@/db/queries/blog/blog-categories'; import { AuthProvider } from '@/hooks/useAuth'; import { locales } from '@/i18n/config'; @@ -25,18 +25,10 @@ export default async function LocaleLayout({ if (!locales.includes(locale as any)) notFound(); - // const [messages, blogCategories] = await Promise.all([ - // getMessages({ locale }), - // getCachedBlogCategories(locale), - // ]); - - const messages = await getMessages({ locale }); - const blogCategories: Array<{ - id: string; - slug: string; - title: string; - }> = []; - + const [messages, blogCategories] = await Promise.all([ + getMessages({ locale }), + getCachedBlogCategories(locale), + ]); const enableAdmin = ( diff --git a/frontend/next.config.ts b/frontend/next.config.ts index a4b22934..9cbd1824 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -5,10 +5,11 @@ import createNextIntlPlugin from 'next-intl/plugin'; const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); const nextConfig: NextConfig = { - env: { - APP_ENV: process.env.APP_ENV, - DATABASE_URL: process.env.DATABASE_URL, - }, + env: Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.startsWith('NEXT_PUBLIC_') && !key.startsWith('npm_') + ) + ), images: { remotePatterns: [ { From 12d44398cb7be10e12f721fc829b660cb6d4ed4c Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 14:49:58 +0000 Subject: [PATCH 05/10] fix(deploy): add netlify-plugin-bundle-env to inject env vars into SSR runtime --- frontend/db/index.ts | 2 +- frontend/next.config.ts | 5 ----- netlify.toml | 3 +++ 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/db/index.ts b/frontend/db/index.ts index 5e76ca23..57d807e1 100644 --- a/frontend/db/index.ts +++ b/frontend/db/index.ts @@ -26,7 +26,7 @@ if (process.env.NODE_ENV !== 'test') { const IS_LOCAL_ENV = APP_ENV === 'local'; -const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; +0const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; const REQUIRED_LOCAL_DB_URL = process.env.SHOP_REQUIRED_DATABASE_URL_LOCAL; if (STRICT_LOCAL_DB_GUARD) { diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 9cbd1824..017c8b8c 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -5,11 +5,6 @@ import createNextIntlPlugin from 'next-intl/plugin'; const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); const nextConfig: NextConfig = { - env: Object.fromEntries( - Object.entries(process.env).filter( - ([key]) => !key.startsWith('NEXT_PUBLIC_') && !key.startsWith('npm_') - ) - ), images: { remotePatterns: [ { diff --git a/netlify.toml b/netlify.toml index a7c4d600..8c30c95f 100644 --- a/netlify.toml +++ b/netlify.toml @@ -8,3 +8,6 @@ [[plugins]] package = "@netlify/plugin-nextjs" + +[[plugins]] + package = "netlify-plugin-bundle-env" From 7b9212bca4595552b3f26a5ea30f093d7c0ff216 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 14:59:28 +0000 Subject: [PATCH 06/10] fix(deploy): typo fix --- frontend/db/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/db/index.ts b/frontend/db/index.ts index 57d807e1..5e76ca23 100644 --- a/frontend/db/index.ts +++ b/frontend/db/index.ts @@ -26,7 +26,7 @@ if (process.env.NODE_ENV !== 'test') { const IS_LOCAL_ENV = APP_ENV === 'local'; -0const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; +const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; const REQUIRED_LOCAL_DB_URL = process.env.SHOP_REQUIRED_DATABASE_URL_LOCAL; if (STRICT_LOCAL_DB_GUARD) { From 50c6789d7e285201bc6cf97ca925c2a82d88caa2 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 15:46:38 +0000 Subject: [PATCH 07/10] fix(netlify): deliver env vars to SSR runtime via .env generation at build time --- frontend/db/index.ts | 2 +- netlify.toml | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/frontend/db/index.ts b/frontend/db/index.ts index 57d807e1..5e76ca23 100644 --- a/frontend/db/index.ts +++ b/frontend/db/index.ts @@ -26,7 +26,7 @@ if (process.env.NODE_ENV !== 'test') { const IS_LOCAL_ENV = APP_ENV === 'local'; -0const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; +const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; const REQUIRED_LOCAL_DB_URL = process.env.SHOP_REQUIRED_DATABASE_URL_LOCAL; if (STRICT_LOCAL_DB_GUARD) { diff --git a/netlify.toml b/netlify.toml index 8c30c95f..fa7b322a 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,13 +1,10 @@ [build] base = "frontend" - command = "npm ci --include=optional && npm run build" + command = "printenv > .env && npm ci --include=optional && npm run build" publish = ".next" [build.environment] NODE_VERSION = "20.19.0" [[plugins]] - package = "@netlify/plugin-nextjs" - -[[plugins]] - package = "netlify-plugin-bundle-env" + package = "@netlify/plugin-nextjs" \ No newline at end of file From 2510901e1b8f58657d4bc8a0ffab2613e7fba017 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 15:56:12 +0000 Subject: [PATCH 08/10] fix(netlify): generate allowlist-based .env for SSR runtime env vars --- frontend/scripts/generate-env.sh | 7 +++++++ netlify.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 frontend/scripts/generate-env.sh diff --git a/frontend/scripts/generate-env.sh b/frontend/scripts/generate-env.sh new file mode 100644 index 00000000..953d8216 --- /dev/null +++ b/frontend/scripts/generate-env.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Generate .env from .env.example allowlist using current environment. +# Only variables listed in .env.example are included — no platform internals leak. +grep '^[A-Z]' .env.example | cut -d= -f1 | while read -r var; do + val="${!var}" + [ -n "$val" ] && printf '%s=%s\n' "$var" "$val" +done > .env diff --git a/netlify.toml b/netlify.toml index fa7b322a..e071879d 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,6 +1,6 @@ [build] base = "frontend" - command = "printenv > .env && npm ci --include=optional && npm run build" + command = "bash scripts/generate-env.sh && npm ci --include=optional && npm run build" publish = ".next" [build.environment] From 667b860b639d481c192fc93c943ed40f10893508 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 17:07:03 +0000 Subject: [PATCH 09/10] fix(auth): load dotenv in auth.ts for Netlify SSR OAuth env vars --- frontend/lib/env/auth.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/lib/env/auth.ts b/frontend/lib/env/auth.ts index f20d2c39..9b79c9f4 100644 --- a/frontend/lib/env/auth.ts +++ b/frontend/lib/env/auth.ts @@ -1,3 +1,6 @@ +import * as dotenv from 'dotenv'; +dotenv.config(); + type AppEnv = 'local' | 'develop' | 'production'; const validAppEnvs = ['local', 'develop', 'production']; From 79f3967cd9a09137375dd2229ed04e385783df07 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Sat, 28 Mar 2026 18:01:26 +0000 Subject: [PATCH 10/10] fix(netlify): hybrid env resolution with Netlify.env.get() fallback --- frontend/db/index.ts | 68 +++++++++++++++----------------- frontend/lib/env/auth.ts | 56 ++++++++++++++++---------- frontend/lib/env/server-env.ts | 17 ++++++++ frontend/proxy.ts | 8 ++-- frontend/scripts/generate-env.sh | 7 ---- netlify.toml | 2 +- 6 files changed, 88 insertions(+), 70 deletions(-) create mode 100644 frontend/lib/env/server-env.ts delete mode 100644 frontend/scripts/generate-env.sh diff --git a/frontend/db/index.ts b/frontend/db/index.ts index 5e76ca23..931d67d8 100644 --- a/frontend/db/index.ts +++ b/frontend/db/index.ts @@ -1,33 +1,36 @@ import { neon } from '@neondatabase/serverless'; -import * as dotenv from 'dotenv'; import { drizzle as drizzleNeon } from 'drizzle-orm/neon-http'; import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'; import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core'; import { Pool } from 'pg'; +import { readServerEnv } from '@/lib/env/server-env'; + import * as schema from './schema'; -dotenv.config(); type AppDatabase = PgDatabase; -const APP_ENV = process.env.APP_ENV?.trim().toLowerCase(); +const APP_ENV = readServerEnv('APP_ENV')?.toLowerCase(); + +const DATABASE_URL = readServerEnv('DATABASE_URL'); +const DATABASE_URL_LOCAL = readServerEnv('DATABASE_URL_LOCAL'); +const IS_LOCAL_ENV = APP_ENV === 'local'; + +const STRICT_LOCAL_DB_GUARD = readServerEnv('SHOP_STRICT_LOCAL_DB') === '1'; +const REQUIRED_LOCAL_DB_URL = readServerEnv('SHOP_REQUIRED_DATABASE_URL_LOCAL'); if (process.env.NODE_ENV !== 'test') { console.log('[db] runtime env check', { - APP_ENV: process.env.APP_ENV ?? '', - has_DATABASE_URL: Boolean(process.env.DATABASE_URL?.trim()), - has_DATABASE_URL_LOCAL: Boolean(process.env.DATABASE_URL_LOCAL?.trim()), - NETLIFY: process.env.NETLIFY ?? '', - CONTEXT: process.env.CONTEXT ?? '', + APP_ENV: APP_ENV ?? '', + has_DATABASE_URL: Boolean(DATABASE_URL), + has_DATABASE_URL_LOCAL: Boolean(DATABASE_URL_LOCAL), + NETLIFY: readServerEnv('NETLIFY') ?? '', + CONTEXT: readServerEnv('CONTEXT') ?? '', NODE_ENV: process.env.NODE_ENV ?? '', }); } -const IS_LOCAL_ENV = APP_ENV === 'local'; - -const STRICT_LOCAL_DB_GUARD = process.env.SHOP_STRICT_LOCAL_DB === '1'; -const REQUIRED_LOCAL_DB_URL = process.env.SHOP_REQUIRED_DATABASE_URL_LOCAL; if (STRICT_LOCAL_DB_GUARD) { if (!IS_LOCAL_ENV) { @@ -36,13 +39,13 @@ if (STRICT_LOCAL_DB_GUARD) { ); } - if (!process.env.DATABASE_URL_LOCAL?.trim()) { + if (!DATABASE_URL_LOCAL) { throw new Error( '[db] SHOP_STRICT_LOCAL_DB=1 requires DATABASE_URL_LOCAL to be set' ); } - if (process.env.DATABASE_URL?.trim()) { + if (DATABASE_URL) { throw new Error( '[db] SHOP_STRICT_LOCAL_DB=1 forbids DATABASE_URL during shop-local tests' ); @@ -50,7 +53,7 @@ if (STRICT_LOCAL_DB_GUARD) { if ( REQUIRED_LOCAL_DB_URL && - process.env.DATABASE_URL_LOCAL !== REQUIRED_LOCAL_DB_URL + DATABASE_URL_LOCAL !== REQUIRED_LOCAL_DB_URL ) { throw new Error( '[db] SHOP_STRICT_LOCAL_DB=1 requires DATABASE_URL_LOCAL to match SHOP_REQUIRED_DATABASE_URL_LOCAL exactly' @@ -60,38 +63,29 @@ if (STRICT_LOCAL_DB_GUARD) { let db: AppDatabase; -if (IS_LOCAL_ENV) { - const url = process.env.DATABASE_URL_LOCAL?.trim(); +if (DATABASE_URL) { + const sql = neon(DATABASE_URL); + db = drizzleNeon(sql, { schema }); - if (!url) { + if (process.env.NODE_ENV !== 'test') { + console.log('[db] using production database (neon http)'); + } +} else if (IS_LOCAL_ENV) { + if (!DATABASE_URL_LOCAL) { throw new Error('[db] APP_ENV=local requires DATABASE_URL_LOCAL to be set'); } - const pool = new Pool({ - connectionString: url, - }); - + const pool = new Pool({ connectionString: DATABASE_URL_LOCAL }); db = drizzlePg(pool, { schema }); if (process.env.NODE_ENV !== 'test') { console.log('[db] using local PostgreSQL (pg)'); } } else { - const url = process.env.DATABASE_URL?.trim(); - - if (!url) { - throw new Error( - `[db] APP_ENV=${APP_ENV} requires DATABASE_URL to be set` - ); - } - - const sql = neon(url); - - db = drizzleNeon(sql, { schema }); - - if (process.env.NODE_ENV !== 'test') { - console.log('[db] using production database (neon http)'); - } + throw new Error( + `[db] no usable database configuration found (APP_ENV=${APP_ENV ?? 'undefined'})` + ); } + export { db }; diff --git a/frontend/lib/env/auth.ts b/frontend/lib/env/auth.ts index 9b79c9f4..5f131eea 100644 --- a/frontend/lib/env/auth.ts +++ b/frontend/lib/env/auth.ts @@ -1,25 +1,31 @@ -import * as dotenv from 'dotenv'; -dotenv.config(); +import { readServerEnv } from './server-env'; type AppEnv = 'local' | 'develop' | 'production'; -const validAppEnvs = ['local', 'develop', 'production']; -const rawAppEnv = process.env.APP_ENV; +const validAppEnvs: AppEnv[] = ['local', 'develop', 'production']; -if (!rawAppEnv) { - throw new Error('APP_ENV is not defined'); -} +const rawAppEnv = readServerEnv('APP_ENV')?.toLowerCase(); +const context = readServerEnv('CONTEXT')?.toLowerCase(); + +const inferredAppEnv: AppEnv | undefined = + context === 'production' + ? 'production' + : context + ? 'develop' + : undefined; + +const resolvedAppEnv = (rawAppEnv ?? inferredAppEnv) as AppEnv | undefined; -if (!validAppEnvs.includes(rawAppEnv as AppEnv)) { +if (!resolvedAppEnv || !validAppEnvs.includes(resolvedAppEnv)) { throw new Error( - `Invalid APP_ENV: ${rawAppEnv}. Must be one of: ${validAppEnvs.join(', ')}` + `Invalid APP_ENV: ${rawAppEnv ?? ''}. Must be one of: ${validAppEnvs.join(', ')}` ); } -const APP_ENV = rawAppEnv as AppEnv; +const APP_ENV: AppEnv = resolvedAppEnv; function requireEnv(name: string): string { - const value = process.env[name]; + const value = readServerEnv(name); if (!value) { throw new Error(`Missing env var: ${name}`); } @@ -29,16 +35,24 @@ function requireEnv(name: string): string { export const authEnv = { appEnv: APP_ENV, - google: { - clientId: requireEnv('GOOGLE_CLIENT_ID'), - clientSecret: requireEnv('GOOGLE_CLIENT_SECRET'), - redirectUri: - APP_ENV === 'local' - ? requireEnv('GOOGLE_CLIENT_REDIRECT_URI_LOCAL') - : APP_ENV === 'develop' - ? requireEnv('GOOGLE_CLIENT_REDIRECT_URI_DEVELOP') - : requireEnv('GOOGLE_CLIENT_REDIRECT_URI_PROD'), - }, + google: + APP_ENV === 'local' + ? { + clientId: requireEnv('GOOGLE_CLIENT_ID_LOCAL'), + clientSecret: requireEnv('GOOGLE_CLIENT_SECRET_LOCAL'), + redirectUri: requireEnv('GOOGLE_CLIENT_REDIRECT_URI_LOCAL'), + } + : APP_ENV === 'develop' + ? { + clientId: requireEnv('GOOGLE_CLIENT_ID_DEVELOP'), + clientSecret: requireEnv('GOOGLE_CLIENT_SECRET_DEVELOP'), + redirectUri: requireEnv('GOOGLE_CLIENT_REDIRECT_URI_DEVELOP'), + } + : { + clientId: requireEnv('GOOGLE_CLIENT_ID_PROD'), + clientSecret: requireEnv('GOOGLE_CLIENT_SECRET_PROD'), + redirectUri: requireEnv('GOOGLE_CLIENT_REDIRECT_URI_PROD'), + }, github: APP_ENV === 'local' diff --git a/frontend/lib/env/server-env.ts b/frontend/lib/env/server-env.ts new file mode 100644 index 00000000..a1466f16 --- /dev/null +++ b/frontend/lib/env/server-env.ts @@ -0,0 +1,17 @@ +import 'server-only'; + +type NetlifyEnv = { + get?: (key: string) => string | undefined; +}; + +function readFromNetlifyEnv(key: string): string | undefined { + const maybeNetlify = (globalThis as { Netlify?: { env?: NetlifyEnv } }).Netlify; + const value = maybeNetlify?.env?.get?.(key); + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +export function readServerEnv(key: string): string | undefined { + const fromProcess = process.env[key]?.trim(); + if (fromProcess) return fromProcess; + return readFromNetlifyEnv(key); +} diff --git a/frontend/proxy.ts b/frontend/proxy.ts index dae0ce43..6db449b6 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -8,10 +8,10 @@ import { routing } from './i18n/routing'; const AUTH_COOKIE_NAME = 'auth_session'; -const AUTH_SECRET = process.env.AUTH_SECRET; -if (!AUTH_SECRET) { - throw new Error('AUTH_SECRET is not defined'); -} +// const AUTH_SECRET = process.env.AUTH_SECRET; +// if (!AUTH_SECRET) { +// throw new Error('AUTH_SECRET is not defined'); +// } function decodeAuthToken(token: string): AuthTokenPayload | null { const parts = token.split('.'); diff --git a/frontend/scripts/generate-env.sh b/frontend/scripts/generate-env.sh deleted file mode 100644 index 953d8216..00000000 --- a/frontend/scripts/generate-env.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# Generate .env from .env.example allowlist using current environment. -# Only variables listed in .env.example are included — no platform internals leak. -grep '^[A-Z]' .env.example | cut -d= -f1 | while read -r var; do - val="${!var}" - [ -n "$val" ] && printf '%s=%s\n' "$var" "$val" -done > .env diff --git a/netlify.toml b/netlify.toml index e071879d..12f80de1 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,6 +1,6 @@ [build] base = "frontend" - command = "bash scripts/generate-env.sh && npm ci --include=optional && npm run build" + command = "npm ci --include=optional && npm run build" publish = ".next" [build.environment]