126 lines
5.2 KiB
TypeScript
126 lines
5.2 KiB
TypeScript
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<typeof configSchema>;
|
||
|
||
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;
|
||
}
|