From 083f6d26955fc320dbc3345d74528acaa09f9086 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sun, 14 Jun 2026 14:50:17 +0100 Subject: [PATCH] fix: require server env vars (#129) --- .../src/google-maps/provider.test.ts | 2 +- .../freya-backend/src/google-maps/provider.ts | 2 +- apps/freya-backend/src/lib/env.test.ts | 98 +++++++++++++++++++ apps/freya-backend/src/lib/env.ts | 69 +++++++++++++ apps/freya-backend/src/server.ts | 52 ++++------ 5 files changed, 187 insertions(+), 36 deletions(-) create mode 100644 apps/freya-backend/src/lib/env.test.ts create mode 100644 apps/freya-backend/src/lib/env.ts diff --git a/apps/freya-backend/src/google-maps/provider.test.ts b/apps/freya-backend/src/google-maps/provider.test.ts index 97f5b51..d67adb7 100644 --- a/apps/freya-backend/src/google-maps/provider.test.ts +++ b/apps/freya-backend/src/google-maps/provider.test.ts @@ -30,7 +30,7 @@ describe("GoogleMapsSourceProvider", () => { test("throws when service API key is empty", () => { expect(() => new GoogleMapsSourceProvider({ apiKey: "" })).toThrow( - "Google Maps MCP API key must be configured", + "Google Maps API key must be configured", ) }) diff --git a/apps/freya-backend/src/google-maps/provider.ts b/apps/freya-backend/src/google-maps/provider.ts index 535922f..ec37164 100644 --- a/apps/freya-backend/src/google-maps/provider.ts +++ b/apps/freya-backend/src/google-maps/provider.ts @@ -15,7 +15,7 @@ export class GoogleMapsSourceProvider implements FeedSourceProvider { constructor(options: GoogleMapsSourceProviderOptions) { if (!nonEmptyString(options.apiKey)) { - throw new Error("Google Maps MCP API key must be configured") + throw new Error("Google Maps API key must be configured") } this.apiKey = options.apiKey diff --git a/apps/freya-backend/src/lib/env.test.ts b/apps/freya-backend/src/lib/env.test.ts new file mode 100644 index 0000000..ded8ac5 --- /dev/null +++ b/apps/freya-backend/src/lib/env.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" + +import { ensureEnv } from "./env.ts" + +describe("ensureEnv", () => { + test("returns trimmed required env values", () => { + const env = ensureEnv({ + BETTER_AUTH_SECRET: " auth-secret ", + CREDENTIAL_ENCRYPTION_KEY: " credential-key ", + DATABASE_URL: " postgres://example ", + EXA_API_KEY: " exa-key ", + GOOGLE_MAPS_API_KEY: " google-maps-key ", + OPENROUTER_API_KEY: " openrouter-key ", + OPENROUTER_MODEL: " model-name ", + TFL_API_KEY: " tfl-key ", + WEATHERKIT_KEY_ID: " weather-key-id ", + WEATHERKIT_PRIVATE_KEY: " weather-private-key ", + WEATHERKIT_SERVICE_ID: " weather-service-id ", + WEATHERKIT_TEAM_ID: " weather-team-id ", + }) + + expect(env).toEqual({ + betterAuthSecret: "auth-secret", + credentialEncryptionKey: "credential-key", + databaseUrl: "postgres://example", + exaApiKey: "exa-key", + googleMapsApiKey: "google-maps-key", + openrouterApiKey: "openrouter-key", + openrouterModel: "model-name", + tflApiKey: "tfl-key", + weatherkitKeyId: "weather-key-id", + weatherkitPrivateKey: "weather-private-key", + weatherkitServiceId: "weather-service-id", + weatherkitTeamId: "weather-team-id", + }) + }) + + test("does not allow the old Google Maps MCP fallback key", () => { + expect(() => + ensureEnv({ + BETTER_AUTH_SECRET: "auth-secret", + CREDENTIAL_ENCRYPTION_KEY: "credential-key", + DATABASE_URL: "postgres://example", + EXA_API_KEY: "exa-key", + GOOGLE_MAPS_MCP_API_KEY: "google-maps-mcp-key", + OPENROUTER_API_KEY: "openrouter-key", + TFL_API_KEY: "tfl-key", + WEATHERKIT_KEY_ID: "weather-key-id", + WEATHERKIT_PRIVATE_KEY: "weather-private-key", + WEATHERKIT_SERVICE_ID: "weather-service-id", + WEATHERKIT_TEAM_ID: "weather-team-id", + }), + ).toThrow("Missing required environment variables: GOOGLE_MAPS_API_KEY") + }) + + test("allows openrouter model to be omitted", () => { + const env = ensureEnv({ + BETTER_AUTH_SECRET: "auth-secret", + CREDENTIAL_ENCRYPTION_KEY: "credential-key", + DATABASE_URL: "postgres://example", + EXA_API_KEY: "exa-key", + GOOGLE_MAPS_API_KEY: "google-maps-key", + OPENROUTER_API_KEY: "openrouter-key", + TFL_API_KEY: "tfl-key", + WEATHERKIT_KEY_ID: "weather-key-id", + WEATHERKIT_PRIVATE_KEY: "weather-private-key", + WEATHERKIT_SERVICE_ID: "weather-service-id", + WEATHERKIT_TEAM_ID: "weather-team-id", + }) + + expect(env.googleMapsApiKey).toBe("google-maps-key") + expect(env.openrouterModel).toBeUndefined() + }) + + test("throws with all missing required env names", () => { + expect(() => ensureEnv({})).toThrow( + "Missing required environment variables: BETTER_AUTH_SECRET, CREDENTIAL_ENCRYPTION_KEY, DATABASE_URL, EXA_API_KEY, OPENROUTER_API_KEY, TFL_API_KEY, WEATHERKIT_PRIVATE_KEY, WEATHERKIT_KEY_ID, WEATHERKIT_TEAM_ID, WEATHERKIT_SERVICE_ID, GOOGLE_MAPS_API_KEY", + ) + }) + + test("treats whitespace-only values as missing", () => { + expect(() => + ensureEnv({ + BETTER_AUTH_SECRET: "auth-secret", + CREDENTIAL_ENCRYPTION_KEY: "credential-key", + DATABASE_URL: "postgres://example", + EXA_API_KEY: " ", + GOOGLE_MAPS_API_KEY: "google-maps-key", + OPENROUTER_API_KEY: "openrouter-key", + TFL_API_KEY: "tfl-key", + WEATHERKIT_KEY_ID: "weather-key-id", + WEATHERKIT_PRIVATE_KEY: "weather-private-key", + WEATHERKIT_SERVICE_ID: "weather-service-id", + WEATHERKIT_TEAM_ID: "weather-team-id", + }), + ).toThrow("Missing required environment variables: EXA_API_KEY") + }) +}) diff --git a/apps/freya-backend/src/lib/env.ts b/apps/freya-backend/src/lib/env.ts new file mode 100644 index 0000000..59e485f --- /dev/null +++ b/apps/freya-backend/src/lib/env.ts @@ -0,0 +1,69 @@ +export interface ServerEnv { + betterAuthSecret: string + credentialEncryptionKey: string + databaseUrl: string + exaApiKey: string + googleMapsApiKey: string + openrouterApiKey: string + openrouterModel: string | undefined + tflApiKey: string + weatherkitKeyId: string + weatherkitPrivateKey: string + weatherkitServiceId: string + weatherkitTeamId: string +} + +export function ensureEnv(env: Record): ServerEnv { + const missing: string[] = [] + + const betterAuthSecret = readRequiredEnv(env, "BETTER_AUTH_SECRET", missing) + const credentialEncryptionKey = readRequiredEnv(env, "CREDENTIAL_ENCRYPTION_KEY", missing) + const databaseUrl = readRequiredEnv(env, "DATABASE_URL", missing) + const exaApiKey = readRequiredEnv(env, "EXA_API_KEY", missing) + const openrouterApiKey = readRequiredEnv(env, "OPENROUTER_API_KEY", missing) + const tflApiKey = readRequiredEnv(env, "TFL_API_KEY", missing) + const weatherkitPrivateKey = readRequiredEnv(env, "WEATHERKIT_PRIVATE_KEY", missing) + const weatherkitKeyId = readRequiredEnv(env, "WEATHERKIT_KEY_ID", missing) + const weatherkitTeamId = readRequiredEnv(env, "WEATHERKIT_TEAM_ID", missing) + const weatherkitServiceId = readRequiredEnv(env, "WEATHERKIT_SERVICE_ID", missing) + const googleMapsApiKey = readRequiredEnv(env, "GOOGLE_MAPS_API_KEY", missing) + + if (missing.length > 0) { + throw new Error(`Missing required environment variables: ${missing.join(", ")}`) + } + + return { + betterAuthSecret, + credentialEncryptionKey, + databaseUrl, + exaApiKey, + googleMapsApiKey, + openrouterApiKey, + openrouterModel: readOptionalEnv(env, "OPENROUTER_MODEL"), + tflApiKey, + weatherkitKeyId, + weatherkitPrivateKey, + weatherkitServiceId, + weatherkitTeamId, + } +} + +function readRequiredEnv( + env: Record, + name: string, + missing: string[], +): string { + const value = readOptionalEnv(env, name) + if (!value) { + missing.push(name) + } + return value ?? "" +} + +function readOptionalEnv( + env: Record, + name: string, +): string | undefined { + const value = env[name]?.trim() + return value ? value : undefined +} diff --git a/apps/freya-backend/src/server.ts b/apps/freya-backend/src/server.ts index e5394ed..bd69284 100644 --- a/apps/freya-backend/src/server.ts +++ b/apps/freya-backend/src/server.ts @@ -13,6 +13,7 @@ import { createFeedEnhancer } from "./enhancement/enhance-feed.ts" import { createLlmClient } from "./enhancement/llm-client.ts" import { GoogleMapsSourceProvider } from "./google-maps/provider.ts" import { CredentialEncryptor } from "./lib/crypto.ts" +import { ensureEnv } from "./lib/env.ts" import { registerLocationHttpHandlers } from "./location/http.ts" import { LocationSourceProvider } from "./location/provider.ts" import { ReminderSourceProvider } from "./reminders/provider.ts" @@ -23,36 +24,19 @@ import { WeatherSourceProvider } from "./weather/provider.ts" import { WebSearchSourceProvider } from "./web-search/provider.ts" function main() { - const { db, close: closeDb } = createDatabase(process.env.DATABASE_URL!) + const env = ensureEnv(process.env) + + const { db, close: closeDb } = createDatabase(env.databaseUrl) const auth = createAuth(db) - const openrouterApiKey = process.env.OPENROUTER_API_KEY - const feedEnhancer = openrouterApiKey - ? createFeedEnhancer({ - client: createLlmClient({ - apiKey: openrouterApiKey, - model: process.env.OPENROUTER_MODEL || undefined, - }), - }) - : null - if (!feedEnhancer) { - console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled") - } + const feedEnhancer = createFeedEnhancer({ + client: createLlmClient({ + apiKey: env.openrouterApiKey, + model: env.openrouterModel, + }), + }) - const credentialEncryptionKey = process.env.CREDENTIAL_ENCRYPTION_KEY - const credentialEncryptor = credentialEncryptionKey - ? new CredentialEncryptor(credentialEncryptionKey) - : null - if (!credentialEncryptor) { - console.warn( - "[credentials] CREDENTIAL_ENCRYPTION_KEY not set — per-user credential storage disabled", - ) - } - - const googleMapsApiKey = process.env.GOOGLE_MAPS_API_KEY ?? process.env.GOOGLE_MAPS_MCP_API_KEY - if (!googleMapsApiKey) { - throw new Error("GOOGLE_MAPS_API_KEY or GOOGLE_MAPS_MCP_API_KEY must be set") - } + const credentialEncryptor = new CredentialEncryptor(env.credentialEncryptionKey) const sessionManager = new UserSessionManager({ db, @@ -62,16 +46,16 @@ function main() { new ReminderSourceProvider({ db }), new WeatherSourceProvider({ credentials: { - privateKey: process.env.WEATHERKIT_PRIVATE_KEY!, - keyId: process.env.WEATHERKIT_KEY_ID!, - teamId: process.env.WEATHERKIT_TEAM_ID!, - serviceId: process.env.WEATHERKIT_SERVICE_ID!, + privateKey: env.weatherkitPrivateKey, + keyId: env.weatherkitKeyId, + teamId: env.weatherkitTeamId, + serviceId: env.weatherkitServiceId, }, }), - new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }), - new WebSearchSourceProvider({ apiKey: process.env.EXA_API_KEY }), + new TflSourceProvider({ apiKey: env.tflApiKey }), + new WebSearchSourceProvider({ apiKey: env.exaApiKey }), new GoogleMapsSourceProvider({ - apiKey: googleMapsApiKey, + apiKey: env.googleMapsApiKey, }), ], feedEnhancer,