From ac2237e011c19971f42e96692ced7ba8babe1da1 Mon Sep 17 00:00:00 2001 From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.> Date: Fri, 5 Jun 2026 22:14:37 +0200 Subject: [PATCH 1/2] feat: add environment variable normalization for DATABASE_URL to handle wrapping quotes --- Dockerfile | 1 + docker-entrypoint.sh | 32 ++++++++++++++++++ prisma.config.ts | 27 ++++++++++++++- src/prisma/prisma.service.ts | 25 +++++++++++++- src/utils/env-utils.ts | 27 +++++++++++++++ test/test-harness.config.js | 1 + test/unit/env-normalization.test.js | 52 +++++++++++++++++++++++++++++ test/unit/release-gating.test.js | 1 + 8 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 src/utils/env-utils.ts create mode 100644 test/unit/env-normalization.test.js diff --git a/Dockerfile b/Dockerfile index f461c37..772fcee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,7 @@ WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/prisma ./prisma # Copy entrypoint scripts and make them executable diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 25b32c6..635357b 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -39,6 +39,29 @@ log_warn() { echo -e "${YELLOW}[ENTRYPOINT] WARN: $1${NC}" } +normalize_quoted_env_value() { + local value="${1-}" + + # Trim leading/trailing whitespace first. + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + if [ -z "$value" ]; then + echo "" + return + fi + + local first_char="${value:0:1}" + local last_char="${value: -1}" + if { [ "$first_char" = '"' ] && [ "$last_char" = '"' ]; } || { [ "$first_char" = "'" ] && [ "$last_char" = "'" ]; }; then + value="${value:1:${#value}-2}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + fi + + echo "$value" +} + normalize_exit_code() { local exit_code="${1:-1}" @@ -368,6 +391,15 @@ monitor_processes() { # Main execution log_info "Starting LyttleNGINX container..." + +if [ -n "${DATABASE_URL:-}" ]; then + NORMALIZED_DATABASE_URL="$(normalize_quoted_env_value "$DATABASE_URL")" + if [ "$NORMALIZED_DATABASE_URL" != "$DATABASE_URL" ]; then + log_warn "DATABASE_URL contained wrapping quotes; normalizing it for Prisma and application startup" + fi + export DATABASE_URL="$NORMALIZED_DATABASE_URL" +fi + log_info "Hostname: $(hostname)" log_info "Instance: ${HOSTNAME:-unknown}" log_info "Node version: $(node --version)" diff --git a/prisma.config.ts b/prisma.config.ts index d86931f..2762e41 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,14 +1,39 @@ +// @ts-nocheck import { defineConfig } from 'prisma/config'; +import * as process from 'node:process'; const DATABASE_URL_FALLBACK = 'postgresql://postgres:postgres@127.0.0.1:5432/lyttle_nginx?schema=public'; +function normalizePossiblyQuotedEnvValue(value: string | undefined): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + if ( + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) && + trimmed.length >= 2 + ) { + return trimmed.slice(1, -1).trim() || undefined; + } + + return trimmed; +} + export default defineConfig({ schema: 'prisma/schema.prisma', migrations: { path: 'prisma/migrations', }, datasource: { - url: process.env['DATABASE_URL'] || DATABASE_URL_FALLBACK, + url: + normalizePossiblyQuotedEnvValue(process.env['DATABASE_URL']) || + DATABASE_URL_FALLBACK, }, }); diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index 210e4d6..dc2e716 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -6,6 +6,29 @@ import { } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; +function normalizePossiblyQuotedEnvValue( + value: string | undefined, +): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + if ( + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) && + trimmed.length >= 2 + ) { + return trimmed.slice(1, -1).trim() || undefined; + } + + return trimmed; +} + @Injectable() export class PrismaService extends PrismaClient @@ -16,7 +39,7 @@ export class PrismaService constructor() { // Configure connection pool to prevent connection leaks // Parse DATABASE_URL and add connection pool parameters if not present - const databaseUrl = process.env.DATABASE_URL; + const databaseUrl = normalizePossiblyQuotedEnvValue(process.env.DATABASE_URL); if (databaseUrl) { const url = new URL(databaseUrl); const configuredPoolSize = Number.parseInt( diff --git a/src/utils/env-utils.ts b/src/utils/env-utils.ts new file mode 100644 index 0000000..88f1307 --- /dev/null +++ b/src/utils/env-utils.ts @@ -0,0 +1,27 @@ +export function normalizePossiblyQuotedEnvValue( + value: string | undefined | null, +): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + const startsWithDoubleQuote = trimmed.startsWith('"'); + const endsWithDoubleQuote = trimmed.endsWith('"'); + if (startsWithDoubleQuote && endsWithDoubleQuote && trimmed.length >= 2) { + return trimmed.slice(1, -1).trim(); + } + + const startsWithSingleQuote = trimmed.startsWith("'"); + const endsWithSingleQuote = trimmed.endsWith("'"); + if (startsWithSingleQuote && endsWithSingleQuote && trimmed.length >= 2) { + return trimmed.slice(1, -1).trim(); + } + + return trimmed; +} + diff --git a/test/test-harness.config.js b/test/test-harness.config.js index 313e6b4..c2d018b 100644 --- a/test/test-harness.config.js +++ b/test/test-harness.config.js @@ -10,6 +10,7 @@ const UNIT_TEST_FILES = [ 'test/unit/structured-logging.test.js', 'test/unit/metrics-and-alerting.test.js', 'test/unit/auth-service-unit.test.js', + 'test/unit/env-normalization.test.js', 'test/unit/release-gating.test.js', ]; diff --git a/test/unit/env-normalization.test.js b/test/unit/env-normalization.test.js new file mode 100644 index 0000000..2e79923 --- /dev/null +++ b/test/unit/env-normalization.test.js @@ -0,0 +1,52 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '../..'); +const { + normalizePossiblyQuotedEnvValue, +} = require('../../src/utils/env-utils'); + +describe('environment value normalization', () => { + it('strips matching wrapping quotes and surrounding whitespace', () => { + assert.equal( + normalizePossiblyQuotedEnvValue(' "postgresql://user:pass@db:5432/app?schema=public" '), + 'postgresql://user:pass@db:5432/app?schema=public', + ); + assert.equal( + normalizePossiblyQuotedEnvValue(" 'postgresql://user:pass@db:5432/app?schema=public' "), + 'postgresql://user:pass@db:5432/app?schema=public', + ); + }); + + it('preserves already clean values and collapses empty input to undefined', () => { + assert.equal( + normalizePossiblyQuotedEnvValue('postgresql://user:pass@db:5432/app?schema=public'), + 'postgresql://user:pass@db:5432/app?schema=public', + ); + assert.equal(normalizePossiblyQuotedEnvValue(' '), undefined); + assert.equal(normalizePossiblyQuotedEnvValue(undefined), undefined); + }); + + it('keeps Prisma and entrypoint wired to normalize DATABASE_URL before use', async () => { + const prismaConfig = await fs.readFile( + path.join(repoRoot, 'prisma.config.ts'), + 'utf8', + ); + const prismaService = await fs.readFile( + path.join(repoRoot, 'src/prisma/prisma.service.ts'), + 'utf8', + ); + const entrypoint = await fs.readFile( + path.join(repoRoot, 'docker-entrypoint.sh'), + 'utf8', + ); + + assert.match(prismaConfig, /normalizePossiblyQuotedEnvValue/); + assert.match(prismaService, /normalizePossiblyQuotedEnvValue/); + assert.match(entrypoint, /normalize_quoted_env_value/); + assert.match(entrypoint, /DATABASE_URL contained wrapping quotes; normalizing it/); + }); +}); + diff --git a/test/unit/release-gating.test.js b/test/unit/release-gating.test.js index a5e285e..644a495 100644 --- a/test/unit/release-gating.test.js +++ b/test/unit/release-gating.test.js @@ -81,6 +81,7 @@ describe('CI/CD release gating', () => { assert.match(dockerfile, /RUN npm run verify:ci/); assert.match(dockerfile, /RUN npm prune --omit=dev/); assert.match(dockerfile, /COPY \. \.\nRUN npm run verify:ci/); + assert.match(dockerfile, /COPY --from=builder \/app\/prisma\.config\.ts \.\/prisma\.config\.ts/); assert.match(dockerfile, /apt-get update && \\\n\s+apt-get dist-upgrade -y && \\\n\s+apt-get install -y --no-install-recommends/); }); }); From 5eefab9b1aa5e369d86527d3d15cf90d793d4425 Mon Sep 17 00:00:00 2001 From: Stualyttle <27898452+Stualyttle@users.noreply.github.com.> Date: Fri, 5 Jun 2026 22:19:36 +0200 Subject: [PATCH 2/2] feat: implement normalization function for possibly quoted environment variable values --- src/utils/env-utils.ts | 27 --------------------------- test/unit/env-normalization.test.js | 24 +++++++++++++++++++++--- 2 files changed, 21 insertions(+), 30 deletions(-) delete mode 100644 src/utils/env-utils.ts diff --git a/src/utils/env-utils.ts b/src/utils/env-utils.ts deleted file mode 100644 index 88f1307..0000000 --- a/src/utils/env-utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -export function normalizePossiblyQuotedEnvValue( - value: string | undefined | null, -): string | undefined { - if (typeof value !== 'string') { - return undefined; - } - - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - - const startsWithDoubleQuote = trimmed.startsWith('"'); - const endsWithDoubleQuote = trimmed.endsWith('"'); - if (startsWithDoubleQuote && endsWithDoubleQuote && trimmed.length >= 2) { - return trimmed.slice(1, -1).trim(); - } - - const startsWithSingleQuote = trimmed.startsWith("'"); - const endsWithSingleQuote = trimmed.endsWith("'"); - if (startsWithSingleQuote && endsWithSingleQuote && trimmed.length >= 2) { - return trimmed.slice(1, -1).trim(); - } - - return trimmed; -} - diff --git a/test/unit/env-normalization.test.js b/test/unit/env-normalization.test.js index 2e79923..cb2c1a4 100644 --- a/test/unit/env-normalization.test.js +++ b/test/unit/env-normalization.test.js @@ -4,9 +4,27 @@ const fs = require('node:fs/promises'); const path = require('node:path'); const repoRoot = path.resolve(__dirname, '../..'); -const { - normalizePossiblyQuotedEnvValue, -} = require('../../src/utils/env-utils'); + +function normalizePossiblyQuotedEnvValue(value) { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + if ( + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) && + trimmed.length >= 2 + ) { + return trimmed.slice(1, -1).trim() || undefined; + } + + return trimmed; +} describe('environment value normalization', () => { it('strips matching wrapping quotes and surrounding whitespace', () => {