diff --git a/apps/journal/app/lib/auth/session.server.ts b/apps/journal/app/lib/auth/session.server.ts index 2230bee..5c6edbd 100644 --- a/apps/journal/app/lib/auth/session.server.ts +++ b/apps/journal/app/lib/auth/session.server.ts @@ -9,8 +9,9 @@ import { createCookieSessionStorage, redirect } from "react-router"; import { eq } from "drizzle-orm"; import { users } from "@trails-cool/db/schema/journal"; import { getDb } from "../db.ts"; +import { requireSecret } from "../config.server.ts"; -const sessionSecret = process.env.SESSION_SECRET ?? "dev-secret-change-in-production"; +const sessionSecret = requireSecret("SESSION_SECRET", "dev-secret-change-in-production"); export const sessionStorage = createCookieSessionStorage({ cookie: { diff --git a/apps/journal/app/lib/config.server.test.ts b/apps/journal/app/lib/config.server.test.ts index 7c3993f..37b9d11 100644 --- a/apps/journal/app/lib/config.server.test.ts +++ b/apps/journal/app/lib/config.server.test.ts @@ -17,3 +17,38 @@ describe("getOrigin", () => { expect(getOrigin()).toBe("http://localhost:3000"); }); }); + +describe("requireSecret", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("returns the env value when set in any environment", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("MY_SECRET", "real-secret"); + const { requireSecret } = await import("./config.server.ts"); + expect(requireSecret("MY_SECRET", "dev-fallback")).toBe("real-secret"); + }); + + it("returns the dev fallback when unset in development", async () => { + vi.stubEnv("NODE_ENV", "development"); + delete process.env.MY_SECRET; + const { requireSecret } = await import("./config.server.ts"); + expect(requireSecret("MY_SECRET", "dev-fallback")).toBe("dev-fallback"); + }); + + it("throws in production when the secret is unset", async () => { + vi.stubEnv("NODE_ENV", "production"); + delete process.env.MY_SECRET; + const { requireSecret } = await import("./config.server.ts"); + expect(() => requireSecret("MY_SECRET", "dev-fallback")).toThrow(/MY_SECRET/); + }); + + it("throws in production when the secret matches the dev fallback", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("MY_SECRET", "dev-fallback"); + const { requireSecret } = await import("./config.server.ts"); + expect(() => requireSecret("MY_SECRET", "dev-fallback")).toThrow(/dev fallback/); + }); +}); diff --git a/apps/journal/app/lib/config.server.ts b/apps/journal/app/lib/config.server.ts index 3796b8e..df7c5ed 100644 --- a/apps/journal/app/lib/config.server.ts +++ b/apps/journal/app/lib/config.server.ts @@ -5,3 +5,28 @@ export function getOrigin(): string { return process.env.ORIGIN ?? "http://localhost:3000"; } + +/** + * Read a required secret from the environment. Returns the env value when + * set. In production, throws if the env var is missing or matches the + * known-public dev fallback — silently shipping a default secret to prod + * is a credential leak. In dev/test, returns the supplied fallback so the + * local loop keeps working without ceremony. + * + * Use this for any value where a leaked default would be a security + * incident: signing keys, session secrets, database credentials. + */ +export function requireSecret(name: string, devFallback: string): string { + const value = process.env[name]; + const isProd = process.env.NODE_ENV === "production"; + if (isProd) { + if (!value || value === devFallback) { + throw new Error( + `Refusing to start: ${name} is unset or matches the known-public dev fallback. ` + + `Set ${name} to a strong, unique value in production.`, + ); + } + return value; + } + return value ?? devFallback; +} diff --git a/apps/journal/app/lib/jwt.server.ts b/apps/journal/app/lib/jwt.server.ts index c49e79c..333b8fb 100644 --- a/apps/journal/app/lib/jwt.server.ts +++ b/apps/journal/app/lib/jwt.server.ts @@ -1,8 +1,8 @@ import { SignJWT, jwtVerify } from "jose"; -import { getOrigin } from "./config.server.ts"; +import { getOrigin, requireSecret } from "./config.server.ts"; const JWT_SECRET = new TextEncoder().encode( - process.env.JWT_SECRET ?? "dev-jwt-secret-change-in-production", + requireSecret("JWT_SECRET", "dev-jwt-secret-change-in-production"), ); const ISSUER = getOrigin(); diff --git a/apps/journal/server.ts b/apps/journal/server.ts index 05a50c2..5e4f980 100644 --- a/apps/journal/server.ts +++ b/apps/journal/server.ts @@ -7,6 +7,7 @@ import { join, extname, resolve } from "node:path"; import { logger } from "./app/lib/logger.server.ts"; import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts"; import { createBoss, startWorker } from "@trails-cool/jobs"; +import { getDatabaseUrl } from "@trails-cool/db"; import postgres from "postgres"; Sentry.init({ @@ -66,7 +67,7 @@ async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promis const version = process.env.SENTRY_RELEASE ?? "dev"; async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise { - const client = postgres(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails", { max: 1 }); + const client = postgres(getDatabaseUrl(), { max: 1 }); try { await client`SELECT 1`; res.writeHead(200, { "Content-Type": "application/json" }); @@ -149,7 +150,7 @@ server.listen(port, async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any jobs.push(notificationsFanoutJob, notificationsPurgeJob, komootBulkImportJob as any, importBatchesSweepJob, sendWelcomeEmailJob); - const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails"); + const boss = createBoss(getDatabaseUrl()); await startWorker(boss, jobs); // Register the started boss so feature code can enqueue jobs against // the same instance via getBoss() / enqueueOptional(). diff --git a/packages/db/src/get-database-url.test.ts b/packages/db/src/get-database-url.test.ts new file mode 100644 index 0000000..a456ed0 --- /dev/null +++ b/packages/db/src/get-database-url.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const DEV = "postgres://trails:trails@localhost:5432/trails"; + +describe("getDatabaseUrl", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("uses the override argument when provided", async () => { + vi.stubEnv("NODE_ENV", "production"); + const { getDatabaseUrl } = await import("./index.ts"); + expect(getDatabaseUrl("postgres://override/db")).toBe("postgres://override/db"); + }); + + it("returns DATABASE_URL when set", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("DATABASE_URL", "postgres://real-prod/db"); + const { getDatabaseUrl } = await import("./index.ts"); + expect(getDatabaseUrl()).toBe("postgres://real-prod/db"); + }); + + it("falls back to the dev URL in development", async () => { + vi.stubEnv("NODE_ENV", "development"); + delete process.env.DATABASE_URL; + const { getDatabaseUrl } = await import("./index.ts"); + expect(getDatabaseUrl()).toBe(DEV); + }); + + it("throws in production when DATABASE_URL is unset", async () => { + vi.stubEnv("NODE_ENV", "production"); + delete process.env.DATABASE_URL; + const { getDatabaseUrl } = await import("./index.ts"); + expect(() => getDatabaseUrl()).toThrow(/DATABASE_URL/); + }); + + it("throws in production when DATABASE_URL matches the dev default", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("DATABASE_URL", DEV); + const { getDatabaseUrl } = await import("./index.ts"); + expect(() => getDatabaseUrl()).toThrow(/dev default/); + }); +}); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 8141386..2c6ee2b 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -3,10 +3,32 @@ import postgres from "postgres"; import * as plannerSchema from "./schema/planner.ts"; import * as journalSchema from "./schema/journal.ts"; +const DEV_DB_URL = "postgres://trails:trails@localhost:5432/trails"; + +/** + * Resolve the database URL with fail-loud semantics in production. + * In dev/test we silently fall back to the local Compose URL so the + * loop keeps working; in prod we refuse to start rather than + * silently pointing at localhost (which either won't resolve, or + * worse, will connect to an unintended database on the host). + */ +export function getDatabaseUrl(override?: string): string { + if (override) return override; + const url = process.env.DATABASE_URL; + if (process.env.NODE_ENV === "production") { + if (!url || url === DEV_DB_URL) { + throw new Error( + "Refusing to start: DATABASE_URL is unset or matches the dev default. " + + "Set DATABASE_URL to the production connection string.", + ); + } + return url; + } + return url ?? DEV_DB_URL; +} + export function createDb(connectionString?: string) { - const client = postgres( - connectionString ?? process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails", - ); + const client = postgres(getDatabaseUrl(connectionString)); return drizzle(client, { schema: { ...plannerSchema, ...journalSchema }, }); diff --git a/packages/db/src/migrate-data.ts b/packages/db/src/migrate-data.ts index d8299ec..b482fe5 100644 --- a/packages/db/src/migrate-data.ts +++ b/packages/db/src/migrate-data.ts @@ -10,13 +10,13 @@ import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import postgres from "postgres"; +import { getDatabaseUrl } from "./index.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const migrationsDir = path.resolve(__dirname, "..", "migrations"); async function main() { - const url = process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails"; - const sql = postgres(url); + const sql = postgres(getDatabaseUrl()); try { const files = readdirSync(migrationsDir) .filter((f) => f.endsWith(".sql"))