import { config as loadDotenv } from "dotenv"; import { existsSync } from "node:fs"; import path from "node:path"; // Ladda .env från paketet ELLER monorepo-roten (pnpm --filter sätter cwd till paketet). for (const candidate of [".env", "../.env", "../../.env"]) { const p = path.resolve(process.cwd(), candidate); if (existsSync(p)) { loadDotenv({ path: p }); break; } } import { z } from "zod"; import { BRAND } from "@app/shared-types"; /** All konfiguration valideras vid start – tydliga fel i stället för mystiska krascher. */ const configSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), API_PORT: z.coerce.number().int().default(4000), API_BASE_URL: z.string().default("http://localhost:4000"), CORS_ORIGINS: z.string().default("http://localhost:5173"), LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), DATABASE_URL: z.string().default("postgres://app_user:app_dev_password@localhost:5432/app"), REDIS_URL: z.string().default("redis://localhost:6379"), JWT_ACCESS_SECRET: z.string().min(10).default("dev-only-change-me"), JWT_REFRESH_SECRET: z.string().min(10).default("dev-only-change-me-too"), JWT_ACCESS_TTL_SECONDS: z.coerce.number().int().default(900), JWT_REFRESH_TTL_SECONDS: z.coerce.number().int().default(2_592_000), ENTITLEMENT_SIGNING_SECRET: z.string().min(10).default("dev-only-change-me-three"), ENTITLEMENT_TTL_HOURS: z.coerce.number().int().default(24), ENTITLEMENT_GRACE_DAYS: z.coerce.number().int().default(7), S3_MODE: z.enum(["mock", "aws"]).default("mock"), S3_BUCKET: z.string().default(""), S3_REGION: z.string().default("eu-north-1"), /** Tom = AWS S3; sätt för S3-kompatibel lagring (MinIO/R2/Hetzner). */ S3_ENDPOINT: z.string().default(""), /** Tomma = IAM-roll/instansprofil används (rekommenderat i AWS). */ S3_ACCESS_KEY_ID: z.string().default(""), S3_SECRET_ACCESS_KEY: z.string().default(""), AAMOS_MODE: z.enum(["http", "mock", "gemini"]).default("http"), AAMOS_API_URL: z.string().optional(), AAMOS_API_KEY: z.string().optional(), AAMOS_TIMEOUT_MS: z.coerce.number().int().default(60_000), GEMINI_API_KEY: z.string().optional(), GEMINI_MODEL: z.string().default("gemini-2.5-flash"), GEMINI_TIMEOUT_MS: z.coerce.number().int().default(60_000), GEMINI_DAILY_BUDGET_USD: z.coerce.number().default(0), /** Strong, rotated ops token for /ops/v1/summary. Set via SSM in production. */ OPS_TOKEN: z.string().min(1).default("dev-ops-token-change-me"), /** EOC base URL for outbound ops-summary push. Empty = push disabled. */ EOC_URL: z.string().url().optional(), /** EOC ingest bearer token for this app. Set via SSM in production. */ EOC_PUSH_TOKEN: z.string().min(1).optional(), APP_STORE_MODE: z.enum(["production", "sandbox"]).default("sandbox"), EMAIL_MODE: z.enum(["log", "smtp"]).default("log"), SMTP_HOST: z.string().default(""), SMTP_PORT: z.coerce.number().int().default(587), SMTP_SECURE: z .enum(["true", "false"]) .default("false") .transform((v) => v === "true"), SMTP_USER: z.string().default(""), SMTP_PASS: z.string().default(""), MAIL_FROM: z.string().default(""), /** Firebase Admin service-account (hela JSON:en base64-kodad). Tom = Firebase-session av. Lokalt via .env, prod via SSM. */ FIREBASE_SERVICE_ACCOUNT_B64: z.string().optional(), APPLE_BUNDLE_ID: z.string().default(""), GOOGLE_PACKAGE_NAME: z.string().default(""), }); export type AppConfig = z.infer; export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const parsed = configSchema.safeParse(env); if (!parsed.success) { console.error("Ogiltig konfiguration:", parsed.error.flatten().fieldErrors); process.exit(1); } const cfg = parsed.data; // Varumärkesberoende defaults ur brand.config.json (i18n-spec §28) if (!cfg.S3_BUCKET) cfg.S3_BUCKET = `${BRAND.slug}-production`; if (!cfg.APPLE_BUNDLE_ID) cfg.APPLE_BUNDLE_ID = BRAND.iosBundleId; if (!cfg.GOOGLE_PACKAGE_NAME) cfg.GOOGLE_PACKAGE_NAME = BRAND.androidPackage; if (cfg.NODE_ENV === "production") { const insecure = [ cfg.JWT_ACCESS_SECRET, cfg.JWT_REFRESH_SECRET, cfg.ENTITLEMENT_SIGNING_SECRET, ].some((s) => s.startsWith("dev-only")); if (insecure) { console.error("SÄKERHETSSTOPP: dev-hemligheter i produktion (spec §56)."); process.exit(1); } if (cfg.AAMOS_MODE === "mock") { console.error("SÄKERHETSSTOPP: AAMOS_MODE=mock är inte tillåtet i produktion."); process.exit(1); } if (cfg.AAMOS_MODE === "gemini" && !cfg.GEMINI_API_KEY) { console.error("SÄKERHETSSTOPP: AAMOS_MODE=gemini kräver GEMINI_API_KEY i produktion."); process.exit(1); } if (cfg.AAMOS_MODE === "http" && (!cfg.AAMOS_API_URL || !cfg.AAMOS_API_KEY)) { console.error( "SÄKERHETSSTOPP: AAMOS_MODE=http kräver AAMOS_API_URL och AAMOS_API_KEY i produktion.", ); process.exit(1); } if (!cfg.OPS_TOKEN || cfg.OPS_TOKEN.length < 32 || cfg.OPS_TOKEN.startsWith("dev-ops-token")) { console.error( "SÄKERHETSSTOPP: OPS_TOKEN måste vara en stark, icke-default hemlighet i produktion.", ); process.exit(1); } } return cfg; }