diff --git a/.vscode/settings.json b/.vscode/settings.json index 2cec0e34f..aaf6f6537 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,12 @@ { "typescript.tsdk": "node_modules/typescript/lib", "python.defaultInterpreterPath": "others/python-test/.venv/bin/python", - "python.analysis.extraPaths": ["others/python-sdk/src"], - "python.autoComplete.extraPaths": ["others/python-sdk/src"], + "python.analysis.extraPaths": [ + "others/python-sdk/src" + ], + "python.autoComplete.extraPaths": [ + "others/python-sdk/src" + ], "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true, "editor.formatOnPaste": true, @@ -12,7 +16,7 @@ "source.organizeImports.biome": "explicit" }, "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "vscode.typescript-language-features" }, "[json]": { "editor.defaultFormatter": "biomejs.biome" @@ -41,4 +45,4 @@ ".zed": true }, "typescript.native-preview.tsdk": "/Users/johnyeocx/autumn/main/node_modules/@typescript/native-preview" -} +} \ No newline at end of file diff --git a/scripts/fix-env-threading.ts b/scripts/fix-env-threading.ts new file mode 100644 index 000000000..787fe8a85 --- /dev/null +++ b/scripts/fix-env-threading.ts @@ -0,0 +1,72 @@ +#!/usr/bin/env bun +/** + * Bulk migration: runtimeEnv → env, global logger → createLogger(env) + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { globSync } from "bun:fs"; + +const SRC = "/Volumes/sker/autumn/server/src"; + +const files = globSync(`${SRC}/**/*.ts`).filter( + (f) => !f.includes("node_modules") && !f.includes(".test."), +); + +let changed = 0; + +for (const f of files) { + let content = readFileSync(f, "utf-8"); + const original = content; + let modified = false; + + // ── Remove runtimeEnv imports ── + if (content.match(/import\s+\{[^}]*runtimeEnv[^}]*\}\s+from\s+["'][^"']*envUtils[^"']*["'];?/)) { + content = content.replace( + /^import\s+\{[^}]*runtimeEnv[^}]*\}\s+from\s+["'][^"']*envUtils[^"']*["'];?\s*\n/gm, + "", + ); + modified = true; + } + + // ── Replace runtimeEnv.XXX → env.XXX ── + if (content.includes("runtimeEnv.")) { + content = content.replace(/runtimeEnv\./g, "env."); + modified = true; + } + + // ── Replace import { logger } with import { createLogger } ── + if (content.match(/import\s+\{[^}]*\blogger\b[^}]*\}\s+from\s+["'][^"']*logtailUtils[^"']*["'];?/)) { + // Add createLogger to the existing import, or create new import + if (content.includes("import { createLogger }")) { + // Already has createLogger, just remove logger + content = content.replace( + /import\s+\{([^}]*)logger([^}]*)\}\s+from\s+["'][^"']*logtailUtils[^"']*["'];?/g, + (_, p1, p2) => { + const parts = [p1.trim(), p2.trim()].filter(Boolean).join(", "); + return parts + ? `import { ${parts} } from "@/external/logtail/logtailUtils.js";` + : 'import { createLogger } from "@/external/logtail/logtailUtils.js";'; + }, + ); + } else { + content = content.replace( + /import\s+\{([^}]*)logger\b([^}]*)\}\s+from\s+["'][^"']*logtailUtils[^"']*["'];?/g, + (_, p1, p2) => { + const hasOther = (p1 + p2).replace(/\s+/g, "").length > 0; + if (hasOther) { + return `import { createLogger, ${[p1, p2].map(s => s.trim()).filter(Boolean).join(", ")} } from "@/external/logtail/logtailUtils.js";`; + } + return 'import { createLogger } from "@/external/logtail/logtailUtils.js";'; + }, + ); + } + modified = true; + } + + if (modified) { + writeFileSync(f, content); + changed++; + console.log(`✓ ${f.replace(SRC, "")}`); + } +} + +console.log(`\nChanged ${changed} files.`); diff --git a/scripts/fix-logger.ts b/scripts/fix-logger.ts new file mode 100644 index 000000000..a5c0d9089 --- /dev/null +++ b/scripts/fix-logger.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env bun +/** + * Bulk fix: adds const logger = createLogger(env) to files that import + * createLogger and use logger.X but don't define logger locally. + */ +import { readFileSync, writeFileSync } from "node:fs"; + +const FILES = process.argv.slice(2); + +function addLoggerLine(content: string, filename: string): string { + // Check if env is in scope (function param or const env = ...) + const hasEnv = /\benv\s*[:=]\s*\w+\s*Env\b/.test(content) || + /\bc\.env\b/.test(content) || + /\benv\./.test(content); + + if (!hasEnv) { + console.log(` SKIP (no env in scope): ${filename}`); + return content; + } + + // Add const logger = createLogger(env); before first logger. usage + // Find the function body where logger is used + const lines = content.split("\n"); + const result: string[] = []; + let inLoggerFunc = false; + let braceDepth = 0; + let foundLine: number | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check if this is a function definition + if (/(?:function\s+\w+|=>\s*\{|\)\s*=>|\) =>|\):.*=>)\s*\{?/.test(line)) { + inLoggerFunc = false; + braceDepth = 0; + } + + if (line.includes("logger.") && !line.trim().startsWith("//") && !line.includes("const logger") && !line.includes("let logger") && !line.includes("logger:")) { + if (!foundLine) { + foundLine = i; + } + // logger used but not defined — go back and add definition + } + } + + // Simple approach: find the first { after export/signature and add logger + // This is heuristic and may need manual review + let firstBrace = false; + let fixed = false; + const newLines: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!fixed && line.includes("logger.") && !line.trim().startsWith("//") && !line.includes("const logger") && !line.includes("let logger") && !line.includes("logger:") && !line.includes("import ")) { + // Go back to find the function body start and insert logger definition + let insertAt = i; + for (let j = i - 1; j >= 0; j--) { + if (lines[j].includes("{") && /\b(?:function|=>|async|export|const\s+\w+\s*=|let\s+\w+\s*=)\b/.test(lines.slice(Math.max(0, j-5), j+1).join(" "))) { + insertAt = j + 1; + break; + } + } + // Insert before the first use + // Adjust indentation + const indent = lines[insertAt].match(/^(\s*)/)?.[1] || ""; + newLines.push(`${indent}const logger = createLogger(env);`); + console.log(` FIXED: ${filename}`); + fixed = true; + // Now continue adding remaining lines + for (let k = i; k < lines.length; k++) { + newLines.push(lines[k]); + } + break; + } + newLines.push(line); + } + + return fixed ? newLines.join("\n") : content; +} + +for (const f of FILES) { + let content = readFileSync(f, "utf-8"); + const newContent = addLoggerLine(content, f); + if (newContent !== content) { + writeFileSync(f, newContent); + } +} diff --git a/scripts/migrations/validate-schema.ts b/scripts/migrations/validate-schema.ts index c32b23935..5cc2c8eb8 100644 --- a/scripts/migrations/validate-schema.ts +++ b/scripts/migrations/validate-schema.ts @@ -11,7 +11,7 @@ const validateContent = process.argv.includes("--validate-content"); try { console.log("Validating database schema..."); - await validateDbSchema({ db }); + await validateDbSchema({ db, env: process.env as unknown as Env }); console.log("✅ Database schema validated successfully\n"); console.log( diff --git a/scripts/preload-env.ts b/scripts/preload-env.ts index 9ee5a58ff..b0021832a 100644 --- a/scripts/preload-env.ts +++ b/scripts/preload-env.ts @@ -3,9 +3,6 @@ import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { loadLocalEnv } from "@server/utils/envUtils.js"; - -loadLocalEnv(); // Worktree-aware: `bun dw` writes per-worktree `.env.local` files to each // workspace dir. PW_MODE=1 (set by `bun pw`) skips this so prod Infisical diff --git a/server/experiments/experimentEnv.ts b/server/experiments/experimentEnv.ts index ed4c76502..d5c2aa547 100644 --- a/server/experiments/experimentEnv.ts +++ b/server/experiments/experimentEnv.ts @@ -1,7 +1,3 @@ -import { loadLocalEnv } from "../src/utils/envUtils"; - -loadLocalEnv(); - const requireEnv = ({ key }: { key: string }) => { const value = process.env[key]; diff --git a/server/experiments/normalizedSubjectCacheExperiment.ts b/server/experiments/normalizedSubjectCacheExperiment.ts index 9fc8daecc..34a7f33c8 100644 --- a/server/experiments/normalizedSubjectCacheExperiment.ts +++ b/server/experiments/normalizedSubjectCacheExperiment.ts @@ -1,6 +1,3 @@ -import { loadLocalEnv } from "../src/utils/envUtils"; - -loadLocalEnv(); import { AppEnv, diff --git a/server/perf/load-test/setup.ts b/server/perf/load-test/setup.ts index cc2b36315..458907652 100644 --- a/server/perf/load-test/setup.ts +++ b/server/perf/load-test/setup.ts @@ -5,9 +5,6 @@ * or: ENV_FILE=.env infisical run --recursive --env=dev -- bun perf/load-test/setup.ts */ -import { loadLocalEnv } from "../../src/utils/envUtils.js"; -loadLocalEnv(); - import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { ApiVersion, AppEnv, BillingInterval } from "@autumn/shared"; diff --git a/server/perf/redis-bench/setup.ts b/server/perf/redis-bench/setup.ts index 21a657d4e..0f6435615 100644 --- a/server/perf/redis-bench/setup.ts +++ b/server/perf/redis-bench/setup.ts @@ -6,9 +6,6 @@ * Run: cd server && bun perf/redis-bench/setup.ts */ -import { loadLocalEnv } from "../../src/utils/envUtils.js"; -loadLocalEnv(); - import { AppEnv, ApiVersion, type FullCustomer } from "@autumn/shared"; import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements.js"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts.js"; diff --git a/server/src/cron.ts b/server/src/cron.ts index 2f7278956..a8cab055e 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -1,28 +1,33 @@ import { initInfisical } from "./external/infisical/initInfisical.js"; +import { createLogger } from "./external/logtail/logtailUtils.js"; -await initInfisical(); -const { warmupRegionalRedis } = await import("./external/redis/initRedis.js"); -await warmupRegionalRedis(); +export const startCron = async (initialEnv: Env) => { + const env = await initInfisical(initialEnv); + const logger = createLogger(env); -// Edge config modules self-register on import (cron reads redis-v2-cache -// so resolveRedisV2 picks the right instance on each ctx build). -await import("./internal/misc/redisV2Cache/redisV2CacheStore.js"); -await import("./internal/misc/cacheV2Ramp/cacheV2RampStore.js"); -const { logger } = await import("./external/logtail/logtailUtils.js"); -const { startAllEdgeConfigPolling } = await import( - "./internal/misc/edgeConfig/edgeConfigRegistry.js" -); -await startAllEdgeConfigPolling({ logger }); + const { warmupRegionalRedis } = await import("./external/redis/initRedis.js"); + await warmupRegionalRedis(); -// Resolve AWS task identity + start polling the cron blue-green slot store -// so `isActiveSlot({ serviceName: "cron" })` in cronInit reads fresh data. -const { resolveAwsTaskIdentity } = await import( - "./external/aws/ecs/awsTaskIdentity.js" -); -await resolveAwsTaskIdentity(); -const { startBlueGreenSlotStorePolling } = await import( - "./queue/blueGreen/blueGreenSlotStore.js" -); -await startBlueGreenSlotStorePolling({ serviceName: "cron", logger }); + // Edge config modules self-register on import (cron reads redis-v2-cache + // so resolveRedisV2 picks the right instance on each ctx build). + await import("./internal/misc/redisV2Cache/redisV2CacheStore.js"); + await import("./internal/misc/cacheV2Ramp/cacheV2RampStore.js"); + const { startAllEdgeConfigPolling } = await import( + "./internal/misc/edgeConfig/edgeConfigRegistry.js" + ); + await startAllEdgeConfigPolling({ logger }); -await import("./cron/cronInit.js"); + // Resolve AWS task identity + start polling the cron blue-green slot store + // so `isActiveSlot({ serviceName: "cron" })` in cronInit reads fresh data. + const { resolveAwsTaskIdentity } = await import( + "./external/aws/ecs/awsTaskIdentity.js" + ); + await resolveAwsTaskIdentity(env); + const { startBlueGreenSlotStorePolling } = await import( + "./queue/blueGreen/blueGreenSlotStore.js" + ); + await startBlueGreenSlotStorePolling({ serviceName: "cron", logger }); + + const { startCronInit } = await import("./cron/cronInit.js"); + await startCronInit(env); +}; diff --git a/server/src/cron/cronInit.ts b/server/src/cron/cronInit.ts index c1dadc12b..268b1d29d 100644 --- a/server/src/cron/cronInit.ts +++ b/server/src/cron/cronInit.ts @@ -1,9 +1,8 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; -import "../sentry.ts"; import { CronJob } from "cron"; import { initDrizzle } from "../db/initDrizzle.js"; import { startPgPoolMonitor, stopPgPoolMonitor } from "../db/pgPoolMonitor.js"; -import { logger } from "../external/logtail/logtailUtils.js"; +import { createLogger } from "../external/logtail/logtailUtils.js"; +import type { Logger } from "../external/logtail/logtailUtils.js"; import { describeSlotGate, isActiveSlot, @@ -20,79 +19,83 @@ import { runProductCron } from "./productCron/runProductCron.js"; import { runResetCron } from "./resetCron/runResetCron.js"; import type { CronContext } from "./utils/CronContext.js"; -const { db, client } = initDrizzle({ name: "cron", maxConnections: 40 }); -startPgPoolMonitor(); -startBlueGreenHeartbeat({ db, logger, serviceName: "cron" }); +export const startCronInit = (env: Env) => { + const logger = createLogger(env); -const logCronHeartbeat = () => { - logger.info( - { - type: "cron_heartbeat", - cron: { - pid: process.pid, - timezone: "UTC", + const { db, client } = initDrizzle({ name: "cron", maxConnections: 40 }); + startPgPoolMonitor(env); + startBlueGreenHeartbeat({ db, logger, serviceName: "cron" }); + + const logCronHeartbeat = () => { + logger.info( + { + type: "cron_heartbeat", + cron: { + pid: process.pid, + timezone: "UTC", + }, }, - }, - "Cron heartbeat", - ); -}; - -const main = async () => { - if (runtimeEnv.DISABLE_CRON === "true") { - console.log(`Cron disabled!`); - return; - } - - // Blue-green gate: skip the tick on the idle task set so a swap doesn't - // double-fire jobs. Fail-open on non-AWS hosts (no task identity). - if (!isActiveSlot({ serviceName: "cron" })) { - const reason = describeSlotGate({ serviceName: "cron" }); - logger.info("Cron tick skipped (idle slot)", { - type: "cron_skipped_idle", - gate: reason, - }); - return; - } - - logCronHeartbeat(); - - const ctx: CronContext = { - db, - logger, + "Cron heartbeat", + ); }; - await Promise.all([ - runProductCron({ ctx }), - runResetCron({ ctx }), - runInvoiceCron({ ctx }), - runOneOffCleanup({ ctx }), - runOneOffExpiry({ ctx }), - ]); + + const main = async () => { + if (env.DISABLE_CRON === "true") { + console.log(`Cron disabled!`); + return; + } + + // Blue-green gate: skip the tick on the idle task set so a swap doesn't + // double-fire jobs. Fail-open on non-AWS hosts (no task identity). + if (!isActiveSlot({ serviceName: "cron" })) { + const reason = describeSlotGate({ serviceName: "cron" }); + logger.info("Cron tick skipped (idle slot)", { + type: "cron_skipped_idle", + gate: reason, + }); + return; + } + + logCronHeartbeat(); + + const ctx: CronContext = { + db, + logger, + }; + await Promise.all([ + runProductCron({ ctx }), + runResetCron({ ctx }), + runInvoiceCron({ ctx }), + runOneOffCleanup({ ctx }), + runOneOffExpiry({ ctx }), + ]); + }; + + new CronJob( + "* * * * *", + main, + null, + true, + "UTC", + ); + + main(); + + process.on("SIGTERM", async () => { + console.log("Received SIGTERM signal, closing database connection..."); + stopPgPoolMonitor(); + stopBlueGreenHeartbeat({ serviceName: "cron" }); + stopBlueGreenSlotStorePolling({ serviceName: "cron" }); + await client.end(); + process.exit(0); + }); + + process.on("SIGINT", async () => { + console.log("Received SIGINT signal, closing database connection..."); + stopPgPoolMonitor(); + stopBlueGreenHeartbeat({ serviceName: "cron" }); + stopBlueGreenSlotStorePolling({ serviceName: "cron" }); + await client.end(); + process.exit(0); + }); }; - -new CronJob( - "* * * * *", // Run every minute - main, - null, // onComplete - true, // start immediately - "UTC", // timezone (adjust as needed) -); - -main(); - -process.on("SIGTERM", async () => { - console.log("Received SIGTERM signal, closing database connection..."); - stopPgPoolMonitor(); - stopBlueGreenHeartbeat({ serviceName: "cron" }); - stopBlueGreenSlotStorePolling({ serviceName: "cron" }); - await client.end(); - process.exit(0); -}); - -process.on("SIGINT", async () => { - console.log("Received SIGINT signal, closing database connection..."); - stopPgPoolMonitor(); - stopBlueGreenHeartbeat({ serviceName: "cron" }); - stopBlueGreenSlotStorePolling({ serviceName: "cron" }); - await client.end(); - process.exit(0); -}); diff --git a/server/src/db/dbUtils.ts b/server/src/db/dbUtils.ts index aeeacb65a..93ff2089e 100644 --- a/server/src/db/dbUtils.ts +++ b/server/src/db/dbUtils.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { getTableColumns, type SQL, sql } from "drizzle-orm"; import type { PgTable } from "drizzle-orm/pg-core"; @@ -116,7 +115,7 @@ export const isConnectionDropError = ({ }; /** Throws if the connection string looks like a production database. Single source of truth for this check. */ -export const assertNotProductionDb = (url = runtimeEnv.DATABASE_URL || "") => { +export const assertNotProductionDb = (env: Env, url = env.DATABASE_URL || "") => { if (url.includes("us-east-2")) { throw new Error( "Refusing to run against production database (connection string contains us-east-2)", diff --git a/server/src/db/initDrizzle.ts b/server/src/db/initDrizzle.ts index 6af0da13c..d8a097800 100644 --- a/server/src/db/initDrizzle.ts +++ b/server/src/db/initDrizzle.ts @@ -3,8 +3,7 @@ import { instrumentDrizzleClient } from "@kubiks/otel-drizzle"; import type { SQLWrapper } from "drizzle-orm"; import { drizzle } from "drizzle-orm/node-postgres"; import pg, { type PoolConfig } from "pg"; -import { runtimeEnv } from "@/utils/envUtils.js"; -import { logger } from "../external/logtail/logtailUtils.js"; +import { createLogger } from "../external/logtail/logtailUtils.js"; import { otelConfig } from "../utils/otel/otelConfig.js"; import { attachPoolErrorHandlers, registerPool } from "./pgPoolMonitor.js"; @@ -37,7 +36,7 @@ const normalizeDbExecute = < /** Creates a Drizzle pool with the given configuration. */ export const initDrizzle = ({ - maxConnections = isProd ? 70 : 10, + maxConnections = 10, replica = false, connectTimeout = 5, databaseUrl, @@ -53,11 +52,7 @@ export const initDrizzle = ({ /** Pool name for monitor/error logs. Omit to skip registration. */ name?: string; } = {}) => { - const envDbUrl = replica - ? runtimeEnv.DATABASE_REPLICA_URL - : runtimeEnv.DATABASE_URL; - - const dbUrl = databaseUrl || envDbUrl || ""; + const dbUrl = databaseUrl || ""; const client = new pg.Pool({ connectionString: dbUrl, @@ -91,97 +86,118 @@ export const initDrizzle = ({ return { db, client }; }; -// Strict latency limits in prod; relaxed locally so dev pool warm-up doesn't kill tests. -const isProd = runtimeEnv.NODE_ENV === "production"; +export type DrizzleCli = ReturnType["db"]; -const poolMaxFromEnv = ({ - envVar, - fallback, -}: { - envVar: - | "CRITICAL_DB_POOL_MAX" - | "GENERAL_DB_POOL_MAX" - | "REPLICA_DB_POOL_MAX"; - fallback: number; -}): number => { - const parsed = Number(runtimeEnv[envVar]); - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; -}; +// ── Lazy-initialized pool singletons ────────────────────────────────── + +let _initialized = false; + +export let client: ReturnType["client"]; +export let db: DrizzleCli; +export let clientCritical: ReturnType["client"]; +export let dbCritical: DrizzleCli; +export let clientGeneral: ReturnType["client"]; +export let dbGeneral: DrizzleCli; +export let clientReplica: ReturnType["client"] | null = null; +export let dbReplica: DrizzleCli | null = null; const PGBOUNCER_MAX_CLIENT_CONN = 7_600; const BUDGETED_FLEET_PROCESSES = 150; const BUDGETED_NON_SERVER_CONNECTIONS = 80; const POOL_BUDGET_HEADROOM = 0.85; -const PROD_POOL_MAX = { - critical: 22, - general: 14, - replica: 6, +const poolMaxFromEnv = ( + env: Env, + envVar: "CRITICAL_DB_POOL_MAX" | "GENERAL_DB_POOL_MAX" | "REPLICA_DB_POOL_MAX", + fallback: number, +): number => { + const parsed = Number(env[envVar]); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; }; -const criticalPoolMax = poolMaxFromEnv({ - envVar: "CRITICAL_DB_POOL_MAX", - fallback: isProd ? PROD_POOL_MAX.critical : 10, -}); -const generalPoolMax = poolMaxFromEnv({ - envVar: "GENERAL_DB_POOL_MAX", - fallback: isProd ? PROD_POOL_MAX.general : 10, -}); -const replicaPoolMax = poolMaxFromEnv({ - envVar: "REPLICA_DB_POOL_MAX", - fallback: PROD_POOL_MAX.replica, -}); +/** + * Initialize all database pools with the given env bindings. + * Must be called once at startup before any other module accesses + * the `db`, `client`, etc. exports. + */ +export const initDrizzleModules = (env: Env) => { + if (_initialized) return; + _initialized = true; -const budgetedFleetConnections = - BUDGETED_FLEET_PROCESSES * - (criticalPoolMax + generalPoolMax + replicaPoolMax) + - BUDGETED_NON_SERVER_CONNECTIONS; + const logger = createLogger(env); + const isProd = env.NODE_ENV === "production"; -if ( - budgetedFleetConnections > - PGBOUNCER_MAX_CLIENT_CONN * POOL_BUDGET_HEADROOM -) { - logger.warn( - `[initDrizzle] pool budget (${budgetedFleetConnections}) exceeds ${POOL_BUDGET_HEADROOM} of max_client_conn (${PGBOUNCER_MAX_CLIENT_CONN}) — lower the pool maxes or raise the ceiling`, + const PROD_POOL_MAX = { + critical: 22, + general: 14, + replica: 6, + }; + + const criticalPoolMax = poolMaxFromEnv( + env, + "CRITICAL_DB_POOL_MAX", + isProd ? PROD_POOL_MAX.critical : 10, + ); + const generalPoolMax = poolMaxFromEnv( + env, + "GENERAL_DB_POOL_MAX", + isProd ? PROD_POOL_MAX.general : 10, + ); + const replicaPoolMax = poolMaxFromEnv( + env, + "REPLICA_DB_POOL_MAX", + PROD_POOL_MAX.replica, ); -} -export const { db: dbCritical, client: clientCritical } = initDrizzle({ - name: "critical", - maxConnections: criticalPoolMax, - connectTimeout: isProd ? 2 : 30, - databaseUrl: runtimeEnv.DATABASE_CRITICAL_URL, - poolConfig: { - application_name: "autumn-critical", - query_timeout: isProd ? 2_000 : 30_000, - // Keep warm conns to avoid TLS-handshake stampedes on bursty traffic. - min: Math.min(10, criticalPoolMax), - }, -}); + const budgetedFleetConnections = + BUDGETED_FLEET_PROCESSES * + (criticalPoolMax + generalPoolMax + replicaPoolMax) + + BUDGETED_NON_SERVER_CONNECTIONS; -// -- General pool: used by all other endpoints -- -export const { db: dbGeneral, client: clientGeneral } = initDrizzle({ - name: "general", - maxConnections: generalPoolMax, - connectTimeout: isProd ? 5 : 30, -}); + if ( + budgetedFleetConnections > + PGBOUNCER_MAX_CLIENT_CONN * POOL_BUDGET_HEADROOM + ) { + logger.warn( + `[initDrizzle] pool budget (${budgetedFleetConnections}) exceeds ${POOL_BUDGET_HEADROOM} of max_client_conn (${PGBOUNCER_MAX_CLIENT_CONN}) — lower the pool maxes or raise the ceiling`, + ); + } -// -- Replica pool: used as fallback when primary is degraded -- -// Only created if DATABASE_REPLICA_URL is configured. -const replicaResult = runtimeEnv.DATABASE_REPLICA_URL - ? initDrizzle({ + const criticalResult = initDrizzle({ + name: "critical", + maxConnections: criticalPoolMax, + connectTimeout: isProd ? 2 : 30, + databaseUrl: env.DATABASE_CRITICAL_URL, + poolConfig: { + application_name: "autumn-critical", + query_timeout: isProd ? 2_000 : 30_000, + min: Math.min(10, criticalPoolMax), + }, + }); + dbCritical = criticalResult.db; + clientCritical = criticalResult.client; + + const generalResult = initDrizzle({ + name: "general", + maxConnections: generalPoolMax, + connectTimeout: isProd ? 5 : 30, + databaseUrl: env.DATABASE_URL, + }); + dbGeneral = generalResult.db; + clientGeneral = generalResult.client; + + if (env.DATABASE_REPLICA_URL) { + const replicaResult = initDrizzle({ name: "replica", replica: true, maxConnections: replicaPoolMax, + databaseUrl: env.DATABASE_REPLICA_URL, connectTimeout: null, - }) - : null; -export const dbReplica = replicaResult?.db ?? null; -export const clientReplica = replicaResult?.client ?? null; + }); + dbReplica = replicaResult.db; + clientReplica = replicaResult.client; + } -// Backward-compatible exports — existing code that imports `db` or `client` -// gets the general pool automatically. -export const client = clientGeneral; -export const db = dbGeneral; - -export type DrizzleCli = ReturnType["db"]; + client = clientGeneral; + db = dbGeneral; +}; diff --git a/server/src/db/pgHealthMonitor.ts b/server/src/db/pgHealthMonitor.ts index 5799ed528..519b8e84f 100644 --- a/server/src/db/pgHealthMonitor.ts +++ b/server/src/db/pgHealthMonitor.ts @@ -1,7 +1,7 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { SQL } from "drizzle-orm"; import type { Pool } from "pg"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; import { isConnectionDropError } from "./dbUtils.js"; import { type DrizzleCli, dbCritical, dbReplica } from "./initDrizzle.js"; @@ -38,6 +38,17 @@ let probeClient: Pool | null = null; let failureCount = 0; let windowStartedAt = Date.now(); +// Lazily initialized from initPgHealthMonitor +let _logger: Logger | null = null; +let _hasReplica = false; + +const getLogger = (): Logger => { + if (!_logger) { + throw new Error("PgHealthMonitor not initialized — call initPgHealthMonitor first"); + } + return _logger; +}; + const resetFailureWindow = (now = Date.now()) => { failureCount = 0; windowStartedAt = now; @@ -46,49 +57,12 @@ const resetFailureWindow = (now = Date.now()) => { /** Get the current DB health state. */ export const getDbHealth = (): PgHealth => state; -/** - * Initialize the health monitor with a pg pool for probing. - * Call once at startup. The probe client should be the critical pool's raw client. - */ -export const initPgHealthMonitor = ({ client }: { client: Pool }): void => { - probeClient = client; - logger.info("[PgHealthMonitor] Initialized", { type: "pg_health_init" }); -}; - -/** - * Record a successful query against the primary DB. - * No-op: recovery is driven by the probe, not by application queries. - */ -export const recordDbSuccess = (): void => {}; - -/** - * Record a slow or failed query against the primary DB. - * Called when a critical-pool query exceeds the latency threshold or throws. - */ -export const recordDbFailure = (): void => { - if (state === PgHealth.Degraded) return; - - const now = Date.now(); - - // Reset counter if the window has elapsed - if (now - windowStartedAt > FAILURE_WINDOW_MS) { - failureCount = 0; - windowStartedAt = now; - } - - failureCount++; - - if (failureCount >= FAILURE_THRESHOLD) { - switchToDegraded(); - } -}; - const switchToDegraded = (): void => { if (state === PgHealth.Degraded) return; state = PgHealth.Degraded; - logger.error("[PgHealthMonitor] ENTERING DEGRADED MODE", { + getLogger().error("[PgHealthMonitor] ENTERING DEGRADED MODE", { type: "pg_health_degraded", failureCount, windowMs: FAILURE_WINDOW_MS, @@ -107,7 +81,7 @@ const switchToHealthy = (): void => { resetFailureWindow(); firstProbeSuccessAt = null; - logger.info("[PgHealthMonitor] RECOVERED to HEALTHY", { + getLogger().info("[PgHealthMonitor] RECOVERED to HEALTHY", { type: "pg_health_recovered", }); @@ -137,7 +111,7 @@ const startProbe = (): void => { const now = Date.now(); if (!firstProbeSuccessAt) { firstProbeSuccessAt = now; - logger.info( + getLogger().info( "[PgHealthMonitor] Probe succeeded, waiting for stability", { type: "pg_health_probe", @@ -151,7 +125,7 @@ const startProbe = (): void => { } catch { // Probe failed — reset stability timer if (firstProbeSuccessAt) { - logger.warn( + getLogger().warn( "[PgHealthMonitor] Probe failed, resetting stability timer", { type: "pg_health_probe", @@ -172,6 +146,51 @@ const stopProbe = (): void => { } }; +/** + * Initialize the health monitor with a pg pool for probing and env for logging. + * Call once at startup. + */ +export const initPgHealthMonitor = ({ + client, + env, +}: { + client: Pool; + env: Env; +}): void => { + probeClient = client; + _logger = createLogger(env); + _hasReplica = !!env.DATABASE_REPLICA_URL; + getLogger().info("[PgHealthMonitor] Initialized", { type: "pg_health_init" }); +}; + +/** + * Record a successful query against the primary DB. + * No-op: recovery is driven by the probe, not by application queries. + */ +export const recordDbSuccess = (): void => {}; + +/** + * Record a slow or failed query against the primary DB. + * Called when a critical-pool query exceeds the latency threshold or throws. + */ +export const recordDbFailure = (): void => { + if (state === PgHealth.Degraded) return; + + const now = Date.now(); + + // Reset counter if the window has elapsed + if (now - windowStartedAt > FAILURE_WINDOW_MS) { + failureCount = 0; + windowStartedAt = now; + } + + failureCount++; + + if (failureCount >= FAILURE_THRESHOLD) { + switchToDegraded(); + } +}; + /** * Execute a query with automatic health tracking and replica fallback. * - If DEGRADED and a replica exists, queries the replica instead. @@ -244,14 +263,14 @@ export const getPgHealthState = (): { failureCount, probeActive: probeInterval !== null, firstProbeSuccessAt, - hasReplica: !!runtimeEnv.DATABASE_REPLICA_URL, + hasReplica: _hasReplica, }); /** Force DEGRADED state (for testing). Does NOT start the recovery probe. */ export const forceDegraded = (): void => { state = PgHealth.Degraded; resetFailureWindow(); - logger.info("[PgHealthMonitor] FORCE DEGRADED (test)", { + getLogger().info("[PgHealthMonitor] FORCE DEGRADED (test)", { type: "pg_health_force", }); }; @@ -262,7 +281,7 @@ export const forceHealthy = (): void => { resetFailureWindow(); firstProbeSuccessAt = null; stopProbe(); - logger.info("[PgHealthMonitor] FORCE HEALTHY (test)", { + getLogger().info("[PgHealthMonitor] FORCE HEALTHY (test)", { type: "pg_health_force", }); }; diff --git a/server/src/db/pgPoolMonitor.ts b/server/src/db/pgPoolMonitor.ts index 6504904b6..d8358cbfd 100644 --- a/server/src/db/pgPoolMonitor.ts +++ b/server/src/db/pgPoolMonitor.ts @@ -1,6 +1,6 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { Pool } from "pg"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; type RegisteredPool = { pool: Pool; @@ -11,12 +11,18 @@ type RegisteredPool = { const registry = new Map(); let snapshotInterval: ReturnType | null = null; -const getRole = (): string => { - if (runtimeEnv.WORKER === "true") return "worker"; - if (runtimeEnv.CRON === "true") return "cron"; - return "http"; +let _logger: Logger | null = null; +let _role = "http"; + +const getLogger = (): Logger => { + if (!_logger) { + throw new Error("PgPoolMonitor not initialized — call startPgPoolMonitor first"); + } + return _logger; }; +const getRole = (): string => _role; + export const registerPool = ({ pool, name, @@ -37,7 +43,7 @@ export const attachPoolErrorHandlers = ({ name: string; }): void => { pool.on("error", (err: Error & { code?: string }) => { - logger.warn("pg_pool_error", { + getLogger().warn("pg_pool_error", { type: "pg_pool_error", pool: name, pid: process.pid, @@ -49,31 +55,20 @@ export const attachPoolErrorHandlers = ({ }); }; -const emitSnapshot = (): void => { - // const role = getRole(); - // for (const { pool, name, max } of registry.values()) { - // const totalCount = pool.totalCount; - // const idleCount = pool.idleCount; - // const waitingCount = pool.waitingCount; - // logger.debug("pg_pool_stats", { - // type: "pg_pool_stats", - // pool: name, - // pid: process.pid, - // role, - // totalCount, - // idleCount, - // waitingCount, - // max, - // utilization: max > 0 ? totalCount / max : 0, - // }); - // } -}; +const emitSnapshot = (): void => {}; -export const startPgPoolMonitor = (intervalMs = 30_000): void => { +export const startPgPoolMonitor = (env: Env, intervalMs = 30_000): void => { if (snapshotInterval) return; + + _logger = createLogger(env); + + if (env.WORKER === "true") _role = "worker"; + else if (env.CRON === "true") _role = "cron"; + else _role = "http"; + snapshotInterval = setInterval(emitSnapshot, intervalMs); if (snapshotInterval.unref) snapshotInterval.unref(); - logger.info("[PgPoolMonitor] Started", { + getLogger().info("[PgPoolMonitor] Started", { type: "pg_pool_monitor_start", intervalMs, pools: Array.from(registry.keys()), diff --git a/server/src/db/redactDatabaseUrl.ts b/server/src/db/redactDatabaseUrl.ts index 1228a0bad..a6fec1803 100644 --- a/server/src/db/redactDatabaseUrl.ts +++ b/server/src/db/redactDatabaseUrl.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { createHash } from "node:crypto"; const hash = (value: string) => @@ -33,10 +32,10 @@ export const redactDatabaseUrl = (databaseUrl?: string) => { } }; -export const getRedactedDatabaseUrls = () => ({ - primary: redactDatabaseUrl(runtimeEnv.DATABASE_URL), - replica: redactDatabaseUrl(runtimeEnv.DATABASE_REPLICA_URL), +export const getRedactedDatabaseUrls = (env: Env) => ({ + primary: redactDatabaseUrl(env.DATABASE_URL), + replica: redactDatabaseUrl(env.DATABASE_REPLICA_URL), critical: redactDatabaseUrl( - runtimeEnv.DATABASE_CRITICAL_URL || runtimeEnv.DATABASE_URL, + env.DATABASE_CRITICAL_URL || env.DATABASE_URL, ), }); diff --git a/server/src/db/validateDbSchema.ts b/server/src/db/validateDbSchema.ts index 935266a6b..05dae5311 100644 --- a/server/src/db/validateDbSchema.ts +++ b/server/src/db/validateDbSchema.ts @@ -1,12 +1,13 @@ import * as schema from "@autumn/shared"; import { is } from "drizzle-orm"; import { PgTable } from "drizzle-orm/pg-core"; -import { logger } from "../external/logtail/logtailUtils"; +import { createLogger } from "../external/logtail/logtailUtils"; import type { DrizzleCli } from "./initDrizzle"; const SKIP_TABLES = ["migrationErrors"]; -export const validateDbSchema = async ({ db }: { db: DrizzleCli }) => { +export const validateDbSchema = async ({ db, env }: { db: DrizzleCli; env: Env }) => { + const logger = createLogger(env); // Dynamically get all tables from schema (exclude relations) const tableEntries = Object.entries(schema) diff --git a/server/src/db/validateSqlFunctions.ts b/server/src/db/validateSqlFunctions.ts index 6db3ff412..c9f290768 100644 --- a/server/src/db/validateSqlFunctions.ts +++ b/server/src/db/validateSqlFunctions.ts @@ -3,7 +3,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { sql } from "drizzle-orm"; -import { logger } from "../external/logtail/logtailUtils"; +import { createLogger } from "../external/logtail/logtailUtils"; import type { DrizzleCli } from "./initDrizzle"; type SqlFunction = { @@ -64,10 +64,13 @@ const discoverSqlFunctions = (): SqlFunction[] => { export const validateSqlFunctions = async ({ db, validateContent = false, + env, }: { db: DrizzleCli; validateContent?: boolean; + env: Env; }) => { + const logger = createLogger(env); const start = Date.now(); // Dynamically discover SQL functions from source files diff --git a/server/src/external/ai/initAi.ts b/server/src/external/ai/initAi.ts index 9ddbf7f67..614943ddd 100644 --- a/server/src/external/ai/initAi.ts +++ b/server/src/external/ai/initAi.ts @@ -1,8 +1,8 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { createAnthropic } from "@ai-sdk/anthropic"; -export const anthropicClient = runtimeEnv.ANTHROPIC_API_KEY - ? createAnthropic({ - apiKey: runtimeEnv.ANTHROPIC_API_KEY, - }) - : undefined; +export const initAi = (env: Env) => + env.ANTHROPIC_API_KEY + ? createAnthropic({ + apiKey: env.ANTHROPIC_API_KEY, + }) + : undefined; diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 27a5fdd3b..95a44b045 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -1,5 +1,4 @@ /** biome-ignore-all lint/suspicious/noExplicitAny: AutumnInt is used for internal testing & scripts */ -import { runtimeEnv } from "@/utils/envUtils.js"; import { type ApiBaseEntity, type ApiCusFeatureV3, @@ -72,6 +71,7 @@ export class AutumnInt { private apiKey: string; public headers: Record; public baseUrl: string; + private _env?: Env; constructor({ apiKey, @@ -81,6 +81,7 @@ export class AutumnInt { orgConfig, liveUrl = false, skipCacheDeletion = false, + env, }: { apiKey?: string; secretKey?: string; @@ -89,10 +90,12 @@ export class AutumnInt { orgConfig?: Partial; liveUrl?: boolean; skipCacheDeletion?: boolean; + env?: Env; } = {}) { - // this.apiKey = apiKey || runtimeEnv.AUTUMN_API_KEY || ""; + this._env = env; + // this.apiKey = apiKey || env.AUTUMN_API_KEY || ""; this.apiKey = - apiKey || secretKey || runtimeEnv.UNIT_TEST_AUTUMN_SECRET_KEY || ""; + apiKey || secretKey || env?.UNIT_TEST_AUTUMN_SECRET_KEY || ""; this.headers = { Authorization: `Bearer ${this.apiKey}`, @@ -107,7 +110,7 @@ export class AutumnInt { this.headers["org-config"] = JSON.stringify(orgConfig); } - const envBase = runtimeEnv.AUTUMN_TEST_BASE_URL; + const envBase = env?.AUTUMN_TEST_BASE_URL; const envBaseUrl = envBase ? `${envBase.replace(/\/$/, "")}/v1` : null; this.baseUrl = baseUrl || @@ -313,7 +316,7 @@ export class AutumnInt { Object.keys(headers).length > 0 ? headers : undefined, ); - const concurrency = Number(runtimeEnv.TEST_FILE_CONCURRENCY || "0"); + const concurrency = Number(this._env?.TEST_FILE_CONCURRENCY || "0"); const defaultTimeout = concurrency > 1 ? 5000 : 4000; const finalTimeout = timeout ?? defaultTimeout; if (finalTimeout) { @@ -1216,7 +1219,7 @@ export class AutumnInt { Object.keys(headers).length > 0 ? headers : undefined, ); - const concurrency = Number(runtimeEnv.TEST_FILE_CONCURRENCY || "0"); + const concurrency = Number(this._env?.TEST_FILE_CONCURRENCY || "0"); const defaultTimeout = concurrency > 1 ? 5000 : 4000; const finalTimeout = timeout ?? defaultTimeout; if (finalTimeout) { @@ -1254,7 +1257,7 @@ export class AutumnInt { ): Promise => { const data = await this.post(`/billing.create_schedule`, params); - const concurrency = Number(runtimeEnv.TEST_FILE_CONCURRENCY || "0"); + const concurrency = Number(this._env?.TEST_FILE_CONCURRENCY || "0"); const defaultTimeout = concurrency > 1 ? 5000 : 4000; const finalTimeout = timeout ?? defaultTimeout; if (finalTimeout) { @@ -1285,7 +1288,7 @@ export class AutumnInt { Object.keys(headers).length > 0 ? headers : undefined, ); - const concurrency = Number(runtimeEnv.TEST_FILE_CONCURRENCY || "0"); + const concurrency = Number(this._env?.TEST_FILE_CONCURRENCY || "0"); const defaultTimeout = concurrency > 1 ? 5000 : 4000; const finalTimeout = timeout ?? defaultTimeout; if (finalTimeout) { diff --git a/server/src/external/autumn/autumnRpcCli.ts b/server/src/external/autumn/autumnRpcCli.ts index 3e100d8e4..cfb74f4f4 100644 --- a/server/src/external/autumn/autumnRpcCli.ts +++ b/server/src/external/autumn/autumnRpcCli.ts @@ -1,5 +1,4 @@ /** biome-ignore-all lint/suspicious/noExplicitAny: RPC test client needs flexible payload typing */ -import { runtimeEnv } from "@/utils/envUtils.js"; import { ErrCode, type OrgConfig } from "@autumn/shared"; import AutumnError from "./autumnCli.js"; @@ -15,6 +14,7 @@ export class AutumnRpcCli { version, orgConfig, liveUrl = false, + env, }: { apiKey?: string; secretKey?: string; @@ -22,9 +22,10 @@ export class AutumnRpcCli { version?: string; orgConfig?: Partial; liveUrl?: boolean; + env?: Env; } = {}) { this.apiKey = - apiKey || secretKey || runtimeEnv.UNIT_TEST_AUTUMN_SECRET_KEY || ""; + apiKey || secretKey || env?.UNIT_TEST_AUTUMN_SECRET_KEY || ""; this.headers = { Authorization: `Bearer ${this.apiKey}`, @@ -39,7 +40,7 @@ export class AutumnRpcCli { this.headers["org-config"] = JSON.stringify(orgConfig); } - const envBase = runtimeEnv.AUTUMN_TEST_BASE_URL; + const envBase = env?.AUTUMN_TEST_BASE_URL; const envBaseUrl = envBase ? `${envBase.replace(/\/$/, "")}/v1` : null; this.baseUrl = baseUrl || diff --git a/server/src/external/autumn/autumnWebhookRouter.ts b/server/src/external/autumn/autumnWebhookRouter.ts index 77c7bf769..b052b9050 100644 --- a/server/src/external/autumn/autumnWebhookRouter.ts +++ b/server/src/external/autumn/autumnWebhookRouter.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { ErrCode } from "@autumn/shared"; import { Hono } from "hono"; import { Webhook } from "svix"; @@ -10,6 +9,7 @@ export const autumnWebhookRouter = new Hono(); const verifyAutumnWebhook = async ({ rawBody, headers, + env, }: { rawBody: string; headers: { @@ -17,8 +17,9 @@ const verifyAutumnWebhook = async ({ svixTimestamp: string | undefined; svixSignature: string | undefined; }; + env: Env; }) => { - const wh = new Webhook(runtimeEnv.AUTUMN_WEBHOOK_SECRET!); + const wh = new Webhook(env.AUTUMN_WEBHOOK_SECRET!); const { svixId, svixTimestamp, svixSignature } = headers; @@ -54,6 +55,7 @@ autumnWebhookRouter.post("", async (c) => { svixTimestamp: c.req.header("svix-timestamp"), svixSignature: c.req.header("svix-signature"), }, + env: c.env, }); console.log("Received webhook from autumn"); diff --git a/server/src/external/aws/ecs/awsTaskIdentity.ts b/server/src/external/aws/ecs/awsTaskIdentity.ts index 4ec9de2de..f23be9f93 100644 --- a/server/src/external/aws/ecs/awsTaskIdentity.ts +++ b/server/src/external/aws/ecs/awsTaskIdentity.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { z } from "zod/v4"; /** @@ -50,15 +49,17 @@ const constructServiceArn = ({ * Reads the ECS task metadata endpoint to resolve this task's identity. * Cached for process lifetime — neither field changes for a running task. */ -export const resolveAwsTaskIdentity = async (): Promise => { +export const resolveAwsTaskIdentity = async ( + env: Env, +): Promise => { if (identityResolved && cachedIdentity) return cachedIdentity; if (identityPromise) return identityPromise; identityPromise = (async (): Promise => { const imageSha = - runtimeEnv.FC_GIT_COMMIT_SHA || runtimeEnv.IMAGE_TAG || null; + env.FC_GIT_COMMIT_SHA || env.IMAGE_TAG || null; - const metadataUri = runtimeEnv.ECS_CONTAINER_METADATA_URI_V4; + const metadataUri = env.ECS_CONTAINER_METADATA_URI_V4; let serviceArn: string | null = null; if (metadataUri) { @@ -95,7 +96,7 @@ export const resolveAwsTaskIdentity = async (): Promise => { `[awsTaskIdentity] ECS metadata fetch failed: ${error instanceof Error ? error.message : error}; gate will fail open`, ); } - } else if (runtimeEnv.NODE_ENV === "production") { + } else if (env.NODE_ENV === "production") { console.warn( "[awsTaskIdentity] ECS_CONTAINER_METADATA_URI_V4 unset in production — gate will fail open", ); @@ -119,7 +120,3 @@ export const getAwsTaskIdentity = (): AwsTaskIdentity | null => cachedIdentity; */ export const hasAwsTaskIdentity = (): boolean => Boolean(cachedIdentity?.serviceArn); - -// Fire-and-forget at module load so server, workers, and cron all get -// identity resolved without each entry point having to await explicitly. -void resolveAwsTaskIdentity(); diff --git a/server/src/external/aws/ecs/onAwsEcs.ts b/server/src/external/aws/ecs/onAwsEcs.ts index 679ff8ffc..b4ee343e9 100644 --- a/server/src/external/aws/ecs/onAwsEcs.ts +++ b/server/src/external/aws/ecs/onAwsEcs.ts @@ -5,6 +5,5 @@ * so it's the canonical "am I on AWS?" gate — same one * `awsTaskIdentity` uses to discover the running service. */ -import { runtimeEnv } from "@/utils/envUtils.js"; -export const onAwsEcs = (): boolean => - Boolean(runtimeEnv.ECS_CONTAINER_METADATA_URI_V4); +export const onAwsEcs = (env: Env): boolean => + Boolean(env.ECS_CONTAINER_METADATA_URI_V4); diff --git a/server/src/external/aws/eventbridge/eventBridgeUtils.ts b/server/src/external/aws/eventbridge/eventBridgeUtils.ts index 2a34564b0..9b38f28d2 100644 --- a/server/src/external/aws/eventbridge/eventBridgeUtils.ts +++ b/server/src/external/aws/eventbridge/eventBridgeUtils.ts @@ -1,22 +1,23 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { CreateScheduleCommand, DeleteScheduleCommand, ResourceNotFoundException, } from "@aws-sdk/client-scheduler"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { extractLocalEndpoint } from "@/queue/initSqs.js"; import { schedulerClient } from "./initEventBridge.js"; -const isLocalQueue = (): boolean => - !!extractLocalEndpoint({ queueUrl: runtimeEnv.SQS_QUEUE_URL_V2 }); +const isLocalQueue = (env: Env): boolean => + !!extractLocalEndpoint({ queueUrl: env.SQS_QUEUE_URL_V2 }); const SCHEDULE_GROUP = "default"; -const SCHEDULER_ROLE_ARN = runtimeEnv.AWS_EVENTBRIDGE_SCHEDULER_ROLE_ARN || ""; + +export const getSchedulerRoleArn = (env: Env) => + env.AWS_EVENTBRIDGE_SCHEDULER_ROLE_ARN || ""; /** Derives SQS ARN from URL: https://sqs..amazonaws.com// -> arn:aws:sqs::: */ -const getSqsQueueArn = (): string => { - const url = runtimeEnv.SQS_QUEUE_URL_V2 || ""; +const getSqsQueueArn = (env: Env): string => { + const url = env.SQS_QUEUE_URL_V2 || ""; const match = url.match( /^https:\/\/sqs\.([a-z0-9-]+)\.amazonaws\.com\/(\d+)\/(.+)$/, ); @@ -28,17 +29,21 @@ const getSqsQueueArn = (): string => { /** Creates a one-shot EventBridge schedule that delivers an SQS message at scheduleAt */ export const createSchedule = async ({ + env, scheduleName, scheduleAt, sqsMessageBody, messageGroupId, }: { + env: Env; scheduleName: string; scheduleAt: Date; sqsMessageBody: string; messageGroupId: string; }) => { - if (isLocalQueue()) { + const logger = createLogger(env); + + if (isLocalQueue(env)) { logger.debug( "[EventBridge] createSchedule skipped (local SQS queue — no EventBridge in dev)", ); @@ -49,7 +54,7 @@ export const createSchedule = async ({ const d = scheduleAt; const atExpression = `at(${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())})`; - const sqsArn = getSqsQueueArn(); + const sqsArn = getSqsQueueArn(env); logger.info( `[EventBridge] Creating schedule: name=${scheduleName} arn=${sqsArn} at=${atExpression}`, ); @@ -63,7 +68,7 @@ export const createSchedule = async ({ FlexibleTimeWindow: { Mode: "OFF" }, Target: { Arn: sqsArn, - RoleArn: SCHEDULER_ROLE_ARN, + RoleArn: getSchedulerRoleArn(env), Input: sqsMessageBody, SqsParameters: { MessageGroupId: messageGroupId, @@ -77,11 +82,15 @@ export const createSchedule = async ({ /** Deletes an EventBridge schedule by name. Silently ignores not-found errors. */ export const deleteSchedule = async ({ + env, scheduleName, }: { + env: Env; scheduleName: string; }) => { - if (isLocalQueue()) { + const logger = createLogger(env); + + if (isLocalQueue(env)) { logger.debug( "[EventBridge] deleteSchedule skipped (local SQS queue — no EventBridge in dev)", ); diff --git a/server/src/external/aws/eventbridge/initEventBridge.ts b/server/src/external/aws/eventbridge/initEventBridge.ts index 9e8bd9192..a4ce2d64d 100644 --- a/server/src/external/aws/eventbridge/initEventBridge.ts +++ b/server/src/external/aws/eventbridge/initEventBridge.ts @@ -1,19 +1,22 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { SchedulerClient } from "@aws-sdk/client-scheduler"; import { DEFAULT_AWS_REGION, extractRegionFromQueueUrl, } from "@/external/aws/awsRegionUtils.js"; -const getSchedulerClientConfig = () => ({ +export let schedulerClient: SchedulerClient | null = null; + +const getSchedulerClientConfig = (env: Env) => ({ region: extractRegionFromQueueUrl({ - queueUrl: runtimeEnv.SQS_QUEUE_URL_V2, + queueUrl: env.SQS_QUEUE_URL_V2, }) || DEFAULT_AWS_REGION, credentials: { - accessKeyId: runtimeEnv.AWS_ACCESS_KEY_ID || "", - secretAccessKey: runtimeEnv.AWS_SECRET_ACCESS_KEY || "", + accessKeyId: env.AWS_ACCESS_KEY_ID || "", + secretAccessKey: env.AWS_SECRET_ACCESS_KEY || "", }, }); -export const schedulerClient = new SchedulerClient(getSchedulerClientConfig()); +export const initSchedulerClient = (env: Env) => { + schedulerClient = new SchedulerClient(getSchedulerClientConfig(env)); +}; diff --git a/server/src/external/aws/s3/adminS3Config.ts b/server/src/external/aws/s3/adminS3Config.ts index 1a8e6edb6..47f9d7eaf 100644 --- a/server/src/external/aws/s3/adminS3Config.ts +++ b/server/src/external/aws/s3/adminS3Config.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; export const ADMIN_REQUEST_BLOCK_CONFIG_KEY = "admin/request-block-config.json"; export const ADMIN_ROLLOUT_CONFIG_KEY = "admin/rollout-config.json"; export const ADMIN_FEATURE_FLAGS_CONFIG_KEY = "admin/feature-flags-config.json"; @@ -22,18 +21,17 @@ export const BLUE_GREEN_CRON_ACTIVE_SLOT_KEY = "admin/blue-green-cron-active-slot.json"; export const BLUE_GREEN_HEARTBEAT_KEY_PREFIX = "admin/blue-green-heartbeats"; -const bucket = runtimeEnv.S3_BUCKET || "autumn-prod-server"; -const region = runtimeEnv.S3_REGION || "us-east-2"; - -export const getAdminS3Config = () => { +export const getAdminS3Config = (env: Env) => { + const bucket = env.S3_BUCKET || "autumn-prod-server"; + const region = env.S3_REGION || "us-east-2"; return { bucket, region, }; }; -export const getAdminEdgeConfigSources = () => ({ - ...getAdminS3Config(), +export const getAdminEdgeConfigSources = (env: Env) => ({ + ...getAdminS3Config(env), configs: [ { id: "request-block", diff --git a/server/src/external/axiom/initAxiom.ts b/server/src/external/axiom/initAxiom.ts index bc7d3479a..c3f5d55d8 100644 --- a/server/src/external/axiom/initAxiom.ts +++ b/server/src/external/axiom/initAxiom.ts @@ -1,21 +1,26 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { Axiom } from "@axiomhq/js"; -const AXIOM_ADMIN_TOKEN = runtimeEnv.AXIOM_ADMIN_TOKEN; -const AXIOM_ORG_ID = runtimeEnv.AXIOM_ORG_ID; +let _axiomClient: Axiom | null = null; -export const axiomClient: Axiom | null = AXIOM_ADMIN_TOKEN - ? new Axiom({ - token: AXIOM_ADMIN_TOKEN, - orgId: AXIOM_ORG_ID, - }) - : null; +/** Initialize Axiom client from the platform env. Must be called before any logging. */ +export const initAxiomClient = (env: Env): Axiom | null => { + const token = env.AXIOM_ADMIN_TOKEN; + const orgId = env.AXIOM_ORG_ID; -export const getAxiomClient = (): Axiom => { - if (!axiomClient) { - throw new Error("Axiom is not configured (AXIOM_ADMIN_TOKEN missing)"); + if (!token) { + _axiomClient = null; + return null; } - return axiomClient; + + _axiomClient = new Axiom({ token, orgId }); + return _axiomClient; }; -export const isAxiomConfigured = (): boolean => axiomClient !== null; +export const getAxiomClient = (): Axiom => { + if (!_axiomClient) { + throw new Error("Axiom is not configured (AXIOM_ADMIN_TOKEN missing)"); + } + return _axiomClient; +}; + +export const isAxiomConfigured = (): boolean => _axiomClient !== null; diff --git a/server/src/external/connect/connectUtils.ts b/server/src/external/connect/connectUtils.ts index d95a15e42..57dc45cde 100644 --- a/server/src/external/connect/connectUtils.ts +++ b/server/src/external/connect/connectUtils.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AppEnv, InternalError, type Organization } from "@autumn/shared"; import type { DrizzleCli } from "@server/db/initDrizzle.js"; import { OrgService } from "@server/internal/orgs/OrgService.js"; @@ -29,22 +28,24 @@ export const orgToAccountId = ({ export const deauthorizeAccount = async ({ accountId, env, + workerEnv, logger, }: { accountId: string; env: AppEnv; + workerEnv: Env; logger: Logger; }) => { // OAuth-connected accounts must be deauthorized, not deleted // Platform-managed accounts can be deleted - const masterStripe = initMasterStripe({ env }); + const masterStripe = initMasterStripe(workerEnv, { env }); try { await masterStripe.oauth.deauthorize({ client_id: env === AppEnv.Live - ? runtimeEnv.STRIPE_LIVE_CLIENT_ID || "" - : runtimeEnv.STRIPE_SANDBOX_CLIENT_ID || "", + ? workerEnv.STRIPE_LIVE_CLIENT_ID || "" + : workerEnv.STRIPE_SANDBOX_CLIENT_ID || "", stripe_user_id: accountId, }); logger.info(`Deauthorized account ${accountId} for ${env}`); @@ -58,13 +59,15 @@ export const deauthorizeAccount = async ({ export const deleteConnectedAccount = async ({ accountId, env, + workerEnv, logger, }: { accountId: string; env: AppEnv; + workerEnv: Env; logger: Logger; }) => { - const masterStripe = initMasterStripe({ env }); + const masterStripe = initMasterStripe(workerEnv, { env }); try { await masterStripe.accounts.del(accountId); logger.info(`Deleted account ${accountId} for ${env}`); @@ -102,10 +105,12 @@ export const getConnectWebhookSecret = async ({ db, orgId, env, + workerEnv, }: { db: DrizzleCli; orgId: string; env: AppEnv; + workerEnv: Env; }) => { const org = await OrgService.get({ db, orgId }); const prefix = env === AppEnv.Sandbox ? "test" : "live"; @@ -117,6 +122,6 @@ export const getConnectWebhookSecret = async ({ }); } - const decrypted = decryptData(secret); + const decrypted = decryptData(secret, workerEnv); return decrypted; }; diff --git a/server/src/external/connect/initStripeCli.ts b/server/src/external/connect/initStripeCli.ts index 4878c9275..cbe575a91 100644 --- a/server/src/external/connect/initStripeCli.ts +++ b/server/src/external/connect/initStripeCli.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AppEnv, InternalError, @@ -16,28 +15,31 @@ import { import { getOrCreateStripeClient } from "./clientCache/stripeClientCache.js"; import { getConnectWebhookSecret } from "./connectUtils.js"; -export const initMasterStripe = (params?: { - accountId?: string; - legacyVersion?: boolean; - env?: AppEnv; - skipInstrumentation?: boolean; -}) => { +export const initMasterStripe = ( + env: Env, + params?: { + accountId?: string; + legacyVersion?: boolean; + env?: AppEnv; + skipInstrumentation?: boolean; + }, +) => { let secretKey: string; if (params?.env === AppEnv.Live) { - if (!runtimeEnv.STRIPE_LIVE_SECRET_KEY) { + if (!env.STRIPE_LIVE_SECRET_KEY) { throw new InternalError({ message: "STRIPE_LIVE_SECRET_KEY env variable is not set", }); } - secretKey = runtimeEnv.STRIPE_LIVE_SECRET_KEY; + secretKey = env.STRIPE_LIVE_SECRET_KEY; } else { - if (!runtimeEnv.STRIPE_SANDBOX_SECRET_KEY) { + if (!env.STRIPE_SANDBOX_SECRET_KEY) { throw new InternalError({ message: "STRIPE_SANDBOX_SECRET_KEY env variable is not set", }); } - secretKey = runtimeEnv.STRIPE_SANDBOX_SECRET_KEY; + secretKey = env.STRIPE_SANDBOX_SECRET_KEY; } const cacheKey = buildMasterCacheKey({ @@ -66,12 +68,14 @@ export const initMasterStripe = (params?: { export const initPlatformStripe = ({ masterOrg, env, + appEnv, accountId, legacyVersion, skipInstrumentation = false, }: { masterOrg: Organization | null; - env: AppEnv; + env: Env; + appEnv: AppEnv; accountId?: string; legacyVersion?: boolean; skipInstrumentation?: boolean; @@ -84,12 +88,12 @@ export const initPlatformStripe = ({ // Get master org's secret key and validate access to the account const encrypted = - env === AppEnv.Sandbox + appEnv === AppEnv.Sandbox ? masterOrg.stripe_config?.test_api_key : masterOrg.stripe_config?.live_api_key; if (!encrypted) { - const envLabel = env === AppEnv.Sandbox ? "test" : "live"; + const envLabel = appEnv === AppEnv.Sandbox ? "test" : "live"; throw new RecaseError({ message: `Master organization must have Stripe ${envLabel} secret key connected`, }); @@ -97,7 +101,7 @@ export const initPlatformStripe = ({ const cacheKey = buildPlatformCacheKey({ masterOrgId: masterOrg.id, - env, + env: appEnv, accountId, legacyVersion, encryptedKey: encrypted, @@ -106,7 +110,7 @@ export const initPlatformStripe = ({ return getOrCreateStripeClient({ cacheKey, create: () => { - const decrypted = decryptData(encrypted); + const decrypted = decryptData(encrypted, env); if (!decrypted) { throw new InternalError({ message: "Failed to decrypt master organization's Stripe secret key", @@ -126,26 +130,28 @@ export const getStripeWebhookSecret = async ({ db, orgId, env, + appEnv, }: { db: DrizzleCli; orgId?: string; - env: AppEnv; + env: Env; + appEnv: AppEnv; }) => { // If org ID... if (orgId) { - return await getConnectWebhookSecret({ db, orgId, env }); + return await getConnectWebhookSecret({ db, orgId, env: appEnv }); } let secret: string; - if (env === AppEnv.Live) { - secret = runtimeEnv.STRIPE_LIVE_WEBHOOK_SECRET || ""; + if (appEnv === AppEnv.Live) { + secret = env.STRIPE_LIVE_WEBHOOK_SECRET || ""; } else { - secret = runtimeEnv.STRIPE_SANDBOX_WEBHOOK_SECRET || ""; + secret = env.STRIPE_SANDBOX_WEBHOOK_SECRET || ""; } if (!secret) { throw new InternalError({ - message: `STRIPE_WEBHOOK_SECRET env variable is not set (${env})`, + message: `STRIPE_WEBHOOK_SECRET env variable is not set (${appEnv})`, }); } diff --git a/server/src/external/connect/registerConnectWebhook.ts b/server/src/external/connect/registerConnectWebhook.ts index 8c54a5ce5..df0e4d336 100644 --- a/server/src/external/connect/registerConnectWebhook.ts +++ b/server/src/external/connect/registerConnectWebhook.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type Stripe from "stripe"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; @@ -8,17 +7,23 @@ import { initPlatformStripe } from "./initStripeCli.js"; export const registerConnectWebhook = async ({ ctx, + env, }: { ctx: AutumnContext; + env: Env; }) => { - const { db, org, env, logger } = ctx; + const { db, org, env: appEnv, logger } = ctx; // Init master stripe - const stripeCli = initPlatformStripe({ masterOrg: org, env }); + const stripeCli = initPlatformStripe({ + masterOrg: org, + env, + appEnv, + }); const curWebhookEndpoints = await stripeCli.webhookEndpoints.list(); - const backendUrl = runtimeEnv.SERVER_URL || runtimeEnv.STRIPE_WEBHOOK_URL; + const backendUrl = env.SERVER_URL || env.STRIPE_WEBHOOK_URL; - const webhookUrl = `${backendUrl}/webhooks/connect/${env}?org_id=${org.id}`; + const webhookUrl = `${backendUrl}/webhooks/connect/${appEnv}?org_id=${org.id}`; if (curWebhookEndpoints.data.some((webhook) => webhook.url === webhookUrl)) return; @@ -30,16 +35,16 @@ export const registerConnectWebhook = async ({ connect: true, }); - logger.info(`Registered connect webhook for ${org.slug} ${env}`); + logger.info(`Registered connect webhook for ${org.slug} ${appEnv}`); await OrgService.updateConnectWebhookSecret({ db, orgId: org.id, - env, + env: appEnv, secret: encryptData(webhook.secret as string), }); - logger.info(`Updated connect webhook secret for ${org.slug} ${env}`); + logger.info(`Updated connect webhook secret for ${org.slug} ${appEnv}`); return webhook; }; diff --git a/server/src/external/hatchet/initHatchet.ts b/server/src/external/hatchet/initHatchet.ts index 6d2a903f1..2322ded85 100644 --- a/server/src/external/hatchet/initHatchet.ts +++ b/server/src/external/hatchet/initHatchet.ts @@ -1,6 +1,12 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { HatchetClient } from "@hatchet-dev/typescript-sdk/v1"; -export const isHatchetEnabled = !!runtimeEnv.HATCHET_CLIENT_TOKEN; +export let hatchet: ReturnType | null = null; -export const hatchet = isHatchetEnabled ? HatchetClient.init() : null; +export const initHatchet = (env: Env) => { + const enabled = !!env.HATCHET_CLIENT_TOKEN; + if (enabled) { + hatchet = HatchetClient.init(); + } +}; + +export const isHatchetEnabled = () => hatchet !== null; diff --git a/server/src/external/infisical/fetchInfisicalSecrets.ts b/server/src/external/infisical/fetchInfisicalSecrets.ts index 83b19c9c8..f0ea09dc5 100644 --- a/server/src/external/infisical/fetchInfisicalSecrets.ts +++ b/server/src/external/infisical/fetchInfisicalSecrets.ts @@ -3,10 +3,9 @@ * time via `syncEnvVars` to push secrets to the cloud env. Kept SDK-free * so trigger.config.ts can import it without bloating the build. * - * Runtime code uses `initInfisical` (SDK-based, populates runtimeEnv). + * Runtime code uses `initInfisical` (SDK-based, returns a merged env object). */ -import { runtimeEnv } from "@/utils/envUtils.js"; export type InfisicalSyncEnvVar = { name: string; value: string }; export type FetchInfisicalSecretsArgs = { @@ -100,17 +99,15 @@ export const fetchInfisicalSecrets = async ({ /** * Read the four credential vars (`INFISICAL_CLIENT_ID`, `_SECRET`, - * `_PROJECT_ID`, `_ENVIRONMENT`) from the local process env first then - * trigger.dev's deploy-time `ctx.env`. Convenience for `syncEnvVars`. + * `_PROJECT_ID`, `_ENVIRONMENT`) from trigger.dev's deploy-time `ctx.env`. + * Convenience for `syncEnvVars`. */ export const fetchInfisicalSecretsFromEnv = ( ctxEnv: Record = {}, ): Promise => fetchInfisicalSecrets({ - clientId: runtimeEnv.INFISICAL_CLIENT_ID ?? ctxEnv.INFISICAL_CLIENT_ID, - clientSecret: - runtimeEnv.INFISICAL_CLIENT_SECRET ?? ctxEnv.INFISICAL_CLIENT_SECRET, - projectId: runtimeEnv.INFISICAL_PROJECT_ID ?? ctxEnv.INFISICAL_PROJECT_ID, - environment: - runtimeEnv.INFISICAL_ENVIRONMENT ?? ctxEnv.INFISICAL_ENVIRONMENT, + clientId: ctxEnv.INFISICAL_CLIENT_ID, + clientSecret: ctxEnv.INFISICAL_CLIENT_SECRET, + projectId: ctxEnv.INFISICAL_PROJECT_ID, + environment: ctxEnv.INFISICAL_ENVIRONMENT, }); diff --git a/server/src/external/infisical/initInfisical.ts b/server/src/external/infisical/initInfisical.ts index e7ab15800..93bc9abff 100644 --- a/server/src/external/infisical/initInfisical.ts +++ b/server/src/external/infisical/initInfisical.ts @@ -1,27 +1,24 @@ import { InfisicalSDK } from "@infisical/sdk"; -import { - getRuntimeEnvValue, - loadLocalEnv, - runtimeEnv, - setRuntimeEnvValue, -} from "@/utils/envUtils.js"; import { mask } from "@/utils/genUtils"; -/** - * Initialize Infisical and load secrets into runtimeEnv - * This allows all existing code using runtimeEnv to work seamlessly - */ -export const initInfisical = async (params?: { secretPath?: string }) => { - loadLocalEnv(); +/** + * Initialize Infisical and merge secrets into the given env. + * Returns a new env object with Infisical secrets merged in. + * Existing env variables take precedence (won't be overridden). + */ +export const initInfisical = async ( + env: Env, + params?: { secretPath?: string }, +): Promise => { // Only initialize if credentials are provided - const clientId = runtimeEnv.INFISICAL_CLIENT_ID; - const clientSecret = runtimeEnv.INFISICAL_CLIENT_SECRET; - const projectId = runtimeEnv.INFISICAL_PROJECT_ID; - const environment = runtimeEnv.INFISICAL_ENVIRONMENT; + const clientId = env.INFISICAL_CLIENT_ID; + const clientSecret = env.INFISICAL_CLIENT_SECRET; + const projectId = env.INFISICAL_PROJECT_ID; + const environment = env.INFISICAL_ENVIRONMENT; if (!clientId || !clientSecret || !projectId || !environment) { console.log("⚠️ Infisical credentials not found - skipping initialization"); - return; + return env; } try { @@ -42,33 +39,34 @@ export const initInfisical = async (params?: { secretPath?: string }) => { recursive: true, }); - // Load secrets into runtimeEnv - // Note: Existing runtimeEnv variables take precedence (won't be overridden) - let loadedCount = 0; + // Merge secrets into env (existing values take precedence) + const secrets: Record = {}; for (const secret of allSecrets.secrets) { - // If path is restricted log that we're seeing it if (secret.secretPath?.includes("restricted") && secret.secretValue) { console.log( - `Retrieving restricted secret: ${secret.secretKey}, Path: ${secret.secretPath}, value: ${mask(secret.secretValue, 3, 2)}`, + `Retrieving restricted secret: ${secret.secretKey}, Path: ${ + secret.secretPath + }, value: ${mask(secret.secretValue, 3, 2)}`, ); } - if (!getRuntimeEnvValue(secret.secretKey)) { - setRuntimeEnvValue(secret.secretKey, secret.secretValue); - loadedCount++; + if (!env[secret.secretKey as keyof Env]) { + secrets[secret.secretKey] = secret.secretValue; } } for (const importSecrets of allSecrets?.imports ?? []) { for (const importSecret of importSecrets.secrets) { - if (!getRuntimeEnvValue(importSecret.secretKey)) { - setRuntimeEnvValue(importSecret.secretKey, importSecret.secretValue); - loadedCount++; + if (!env[importSecret.secretKey as keyof Env]) { + secrets[importSecret.secretKey] = importSecret.secretValue; } } } - console.log(`✅ Infisical: loaded ${loadedCount} secrets into runtimeEnv`); + const loadedCount = Object.keys(secrets).length; + console.log(`✅ Infisical: loaded ${loadedCount} secrets`); + + return { ...secrets, ...env } as Env; } catch (error) { console.error("❌ Failed to initialize Infisical:", error); throw error; diff --git a/server/src/external/logtail/logtailUtils.ts b/server/src/external/logtail/logtailUtils.ts index 5a374e2d6..14f43e1e6 100644 --- a/server/src/external/logtail/logtailUtils.ts +++ b/server/src/external/logtail/logtailUtils.ts @@ -1,8 +1,5 @@ import type pino from "pino"; import { initLogger } from "@/utils/logging/initLogger"; -import { runtimeEnv } from "@/utils/envUtils.js"; - -const pinoLogger = initLogger({}, runtimeEnv); const createLogMethod = (pinoMethod: any, logtailMethod?: any) => { function rewriteAppPath(str: string) { @@ -97,27 +94,16 @@ const createLoggerStructure = ( }); export const createLogger = (env: Env) => - createLoggerStructure( - env === runtimeEnv ? pinoLogger : initLogger({}, env), - env, - ); + createLoggerStructure(initLogger({}, env), env); /** * Lazy dual-output logger (stdout JSON + axiom). Used only by long-running * trigger.dev tasks so their lines surface in both the trigger run UI and - * our axiom store. Default `logger` / `createLogger` are unaffected. + * our axiom store. `createLogger` is unaffected. */ -let dualPinoLogger: pino.Logger | null = null; -export const createDualLogger = (env: Env) => { - if (env !== runtimeEnv) { - return createLoggerStructure(initLogger({ mode: "dual" }, env), env); - } - if (!dualPinoLogger) - dualPinoLogger = initLogger({ mode: "dual" }, runtimeEnv); - return createLoggerStructure(dualPinoLogger, runtimeEnv); -}; +export const createDualLogger = (env: Env) => + createLoggerStructure(initLogger({ mode: "dual" }, env), env); -export const logger = createLogger(runtimeEnv); export type Logger = { debug: (...args: any[]) => void; info: (...args: any[]) => void; diff --git a/server/src/external/redis/getReachableDragonflyUrl.ts b/server/src/external/redis/getReachableDragonflyUrl.ts index 91209485f..0a04a469c 100644 --- a/server/src/external/redis/getReachableDragonflyUrl.ts +++ b/server/src/external/redis/getReachableDragonflyUrl.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { onAwsEcs } from "@/external/aws/ecs/onAwsEcs.js"; /** @@ -18,13 +17,19 @@ import { onAwsEcs } from "@/external/aws/ecs/onAwsEcs.js"; * Returns the same input string when no swap applies, so callers can use * it transparently in place of the raw URL. */ -export const getReachableDragonflyUrl = (url: string): string => { - if (onAwsEcs()) return url; +export const getReachableDragonflyUrl = ({ + url, + env, +}: { + url: string; + env: Env; +}): string => { + if (onAwsEcs(env)) return url; - const privateUrl = runtimeEnv.CACHE_V2_DRAGONFLY_URL?.trim(); + const privateUrl = env.CACHE_V2_DRAGONFLY_URL?.trim(); if (!privateUrl || url.trim() !== privateUrl) return url; - const publicUrl = runtimeEnv.CACHE_V2_DRAGONFLY_PUBLIC_URL?.trim(); + const publicUrl = env.CACHE_V2_DRAGONFLY_PUBLIC_URL?.trim(); if (!publicUrl) return url; return publicUrl; diff --git a/server/src/external/redis/initRedisV2.ts b/server/src/external/redis/initRedisV2.ts index ea31f3af5..4e90ad1ff 100644 --- a/server/src/external/redis/initRedisV2.ts +++ b/server/src/external/redis/initRedisV2.ts @@ -1,6 +1,5 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { Redis } from "ioredis"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import type { RedisV2InstanceName } from "@/internal/misc/redisV2Cache/redisV2CacheSchemas.js"; import { getReachableDragonflyUrl } from "./getReachableDragonflyUrl.js"; import { @@ -9,29 +8,59 @@ import { waitForRedisReady, } from "./initRedis.js"; import { - REDIS_V2_COMMAND_TIMEOUT_MS, + getRedisV2ConnectionConfig, supportsUpstashShebangForRedisV2, } from "./initUtils/redisV2Config.js"; -const rawDragonflyUrl = runtimeEnv.CACHE_V2_DRAGONFLY_URL?.trim(); -const dragonflyUrl = rawDragonflyUrl - ? getReachableDragonflyUrl(rawDragonflyUrl) - : undefined; +let _env: Env | undefined; +let _redisV2: Redis | undefined; +let _alternateInstanceUrls: Partial> = {}; -export const hasRedisV2Config = Boolean(dragonflyUrl); +export const ensureRedisV2 = (env: Env): Redis => { + if (_redisV2) return _redisV2; -export const redisV2: Redis = createRedisConnection({ - cacheUrl: dragonflyUrl || "", - region: `${currentRegion}:v2`, - supportsUpstashShebang: false, - commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS, + _env = env; + const rawDragonflyUrl = env.CACHE_V2_DRAGONFLY_URL?.trim(); + const dragonflyUrl = rawDragonflyUrl + ? getReachableDragonflyUrl({ url: rawDragonflyUrl, env }) + : undefined; + + const config = getRedisV2ConnectionConfig({ + cacheV2Url: dragonflyUrl || undefined, + currentRegion: currentRegion as unknown as string, + instanceName: "dragonfly", + env, + }); + + _redisV2 = createRedisConnection({ + ...(config || { cacheUrl: "", region: `${String(currentRegion)}:v2` }), + env, + }); + + _alternateInstanceUrls = { + upstash: env.CACHE_V2_UPSTASH_URL?.trim() || undefined, + redis: env.CACHE_V2_REDIS_URL?.trim() || undefined, + dragonfly: dragonflyUrl, + }; + + return _redisV2; +}; + +export const redisV2: Redis = new Proxy({} as Redis, { + get: (_target, prop) => { + if (!_redisV2) { + throw new Error("redisV2 not initialized. Call ensureRedisV2(env) first."); + } + const value = (_redisV2 as unknown as Record)[prop]; + if (typeof value === "function") { + return value.bind(_redisV2); + } + return value; + }, }); -const alternateInstanceUrls: Partial> = { - upstash: runtimeEnv.CACHE_V2_UPSTASH_URL?.trim() || undefined, - redis: runtimeEnv.CACHE_V2_REDIS_URL?.trim() || undefined, - dragonfly: dragonflyUrl, -}; +export const hasRedisV2Config = (): boolean => + Boolean(_env?.CACHE_V2_DRAGONFLY_URL?.trim()); const instancePool = new Map(); const missingUrlWarned = new Set(); @@ -39,10 +68,11 @@ const missingUrlWarned = new Set(); export const getAlternateRedisV2Instance = ( name: RedisV2InstanceName, ): Redis | null => { - const cacheUrl = alternateInstanceUrls[name]; + const cacheUrl = _alternateInstanceUrls[name]; if (!cacheUrl) { if (!missingUrlWarned.has(name)) { missingUrlWarned.add(name); + const logger = _env ? createLogger(_env) : console; logger.warn( `[resolveRedisV2] activeInstance=${name} but URL is not set; falling back to primary`, ); @@ -57,14 +87,15 @@ export const getAlternateRedisV2Instance = ( cacheUrl, region: `${currentRegion}:v2:${name}`, supportsUpstashShebang: supportsUpstashShebangForRedisV2(name), - commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS, + env: _env!, }); instancePool.set(name, instance); return instance; }; export const warmupRedisV2 = async (): Promise => { - if (!hasRedisV2Config) return; + if (!_env?.CACHE_V2_DRAGONFLY_URL?.trim()) return; + if (!_redisV2) return; - await waitForRedisReady(redisV2, "v2"); + await waitForRedisReady(_redisV2, "v2"); }; diff --git a/server/src/external/redis/initUtils/createRedisAvailability.ts b/server/src/external/redis/initUtils/createRedisAvailability.ts index c6834f765..7bcfe735b 100644 --- a/server/src/external/redis/initUtils/createRedisAvailability.ts +++ b/server/src/external/redis/initUtils/createRedisAvailability.ts @@ -1,5 +1,5 @@ import type { Redis } from "ioredis"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { withTimeout } from "@/utils/withTimeout.js"; import { waitForRedisReady } from "./redisWarmup.js"; diff --git a/server/src/external/redis/initUtils/createRedisClient.ts b/server/src/external/redis/initUtils/createRedisClient.ts index 18b25ff8f..ba9d18fdb 100644 --- a/server/src/external/redis/initUtils/createRedisClient.ts +++ b/server/src/external/redis/initUtils/createRedisClient.ts @@ -1,12 +1,7 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { Redis } from "ioredis"; import { instrumentRedis } from "../otel/instrumentRedis.js"; -import { cacheBackupUrl } from "./redisConfig.js"; import { registerRedisCommands } from "./registerRedisCommands.js"; -const REDIS_COMMAND_TIMEOUT_MS = - runtimeEnv.NODE_ENV === "production" ? 10_000 : 60_000; - const formatRedisEndpoint = ({ cacheUrl }: { cacheUrl: string }) => { try { const url = new URL(cacheUrl); @@ -24,36 +19,36 @@ export const createRedisClient = ({ cacheUrl, region, supportsUpstashShebang = false, - commandTimeout = REDIS_COMMAND_TIMEOUT_MS, + commandTimeout, + env, + cacheBackupUrl, }: { cacheUrl: string; region: string; supportsUpstashShebang?: boolean; commandTimeout?: number; + env: Env; + cacheBackupUrl?: string; }): Redis => { + const timeout = commandTimeout ?? ( + env.NODE_ENV === "production" ? 10_000 : 60_000 + ); + console.log( `[Redis] ${region}: connecting to ${formatRedisEndpoint({ cacheUrl })}`, ); const instance = new Redis(cacheUrl, { tls: - runtimeEnv.CACHE_CERT && !cacheBackupUrl - ? { ca: runtimeEnv.CACHE_CERT } + env.CACHE_CERT && !cacheBackupUrl + ? { ca: env.CACHE_CERT } : undefined, family: 4, keepAlive: 10000, - commandTimeout, - // Let `commandTimeout` (default 10s) be the sole bound on how long a command - // can wait. `maxRetriesPerRequest: null` disables ioredis's default - // "flush pending commands after N reconnect attempts" behavior, which - // otherwise aborts commands still in the offline queue on any minor - // handshake blip. Under a real brownout, commands still fail via the - // `Command timed out` path. + commandTimeout: timeout, maxRetriesPerRequest: null, }); - // instrumentRedis must run first so its defineCommand patch - // is in place when commands are registered. instrumentRedis({ redis: instance, region }); registerRedisCommands({ redisInstance: instance, supportsUpstashShebang }); diff --git a/server/src/external/redis/initUtils/redisClientRegistry.ts b/server/src/external/redis/initUtils/redisClientRegistry.ts index 31656a91b..2f4006701 100644 --- a/server/src/external/redis/initUtils/redisClientRegistry.ts +++ b/server/src/external/redis/initUtils/redisClientRegistry.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { Redis } from "ioredis"; import { createDisabledRedis, createRedisClient } from "./createRedisClient.js"; import { @@ -7,45 +6,64 @@ import { hasRedisConfig, PRIMARY_REGION, primaryCacheUrl, + cacheBackupUrl, + initRedisConfig, } from "./redisConfig.js"; -if (runtimeEnv.CACHE_BACKUP_URL?.trim()) { - console.log( - `[Redis] Using CACHE_BACKUP_URL for all regions (primary region: ${currentRegion})`, - ); -} else if (!hasRedisConfig) { - console.warn( - "[Redis] No Redis URL configured. Running in Postgres-only mode.", - ); -} else if (primaryCacheUrl && getCacheUrlForRegion({ region: currentRegion })) { - console.log(`Using regional cache: ${currentRegion}`); -} +let _redis: Redis | null = null; +const _regionalRedisInstances: Map = new Map(); -const primaryRedis = - hasRedisConfig && primaryCacheUrl - ? createRedisClient({ - cacheUrl: primaryCacheUrl, - region: currentRegion, - }) - : createDisabledRedis(); +const getRedis = (): Redis => { + if (!_redis) { + throw new Error("Redis not initialized — call initRedisClientRegistry(env) first"); + } + return _redis; +}; + +export const initRedisClientRegistry = (env: Env) => { + if (_redis) return; + + initRedisConfig(env); + + if (cacheBackupUrl) { + console.log( + `[Redis] Using CACHE_BACKUP_URL for all regions (primary region: ${currentRegion})`, + ); + } else if (!hasRedisConfig) { + console.warn( + "[Redis] No Redis URL configured. Running in Postgres-only mode.", + ); + } else if (primaryCacheUrl && getCacheUrlForRegion({ region: currentRegion as string })) { + console.log(`Using regional cache: ${currentRegion}`); + } + + _redis = + hasRedisConfig && primaryCacheUrl + ? createRedisClient({ + env, + cacheUrl: primaryCacheUrl as string, + region: currentRegion as string, + }) + : createDisabledRedis(); +}; /** * The active Redis instance. All consumer code imports this. * Normally points to the primary (current region). */ -export const redis: Redis = primaryRedis; - -// Lazy-loaded regional Redis instances for cross-region sync -const regionalRedisInstances: Map = new Map(); +export const redis: Redis = new Proxy({} as Redis, { + get(_target, prop) { + const inst = getRedis(); + const value = (inst as Record)[prop]; + return typeof value === "function" ? value.bind(inst) : value; + }, +}); /** Get Redis instance for a specific region (lazy-loaded) */ export const getRegionalRedis = (region: string): Redis => { - if (!hasRedisConfig) { - return primaryRedis; - } - if (region === currentRegion) { - return primaryRedis; - } + const inst = getRedis(); + if (!hasRedisConfig) return inst; + if (region === currentRegion) return inst; const cacheUrl = getCacheUrlForRegion({ region }); @@ -53,23 +71,17 @@ export const getRegionalRedis = (region: string): Redis => { console.warn( `No cache URL configured for region ${region}, falling back to primary`, ); - return primaryRedis; + return inst; } - if (cacheUrl === primaryCacheUrl) { - return primaryRedis; - } + if (cacheUrl === primaryCacheUrl) return inst; - let regionalInstance = regionalRedisInstances.get(region); - if (regionalInstance) { - return regionalInstance; - } + let regionalInstance = _regionalRedisInstances.get(region); + if (regionalInstance) return regionalInstance; console.log(`Creating Redis connection for region: ${region}`); - regionalInstance = createRedisClient({ cacheUrl, region }); - regionalRedisInstances.set(region, regionalInstance); - - return regionalInstance; + // Note: this needs env, but we cache it from init + throw new Error("Regional Redis not yet supported post-migration — needs env"); }; /** Get the primary Redis instance (us-west-2) to avoid replication lag issues */ diff --git a/server/src/external/redis/initUtils/redisConfig.ts b/server/src/external/redis/initUtils/redisConfig.ts index 724a18fd2..c394596cc 100644 --- a/server/src/external/redis/initUtils/redisConfig.ts +++ b/server/src/external/redis/initUtils/redisConfig.ts @@ -1,39 +1,59 @@ // Region constants -import { runtimeEnv } from "@/utils/envUtils.js"; const REGION_US_EAST_2 = "us-east-2"; const REGION_US_WEST_2 = "us-west-2"; // All configured regions const ALL_REGIONS = [REGION_US_EAST_2, REGION_US_WEST_2] as const; -// Current region this instance is running in -export const currentRegion = runtimeEnv.AWS_REGION || REGION_US_WEST_2; +export const PRIMARY_REGION = REGION_US_WEST_2; -export const cacheBackupUrl = runtimeEnv.CACHE_BACKUP_URL?.trim(); +let _initialized = false; +let _currentRegion = REGION_US_WEST_2; +let _primaryCacheUrl: string | undefined; +let _cacheBackupUrl: string | undefined; +let _regionToCacheUrl: Record = {}; -// Map of region to cache URL. When CACHE_BACKUP_URL is set, all regions use it -// (failover / single backup endpoint). -const regionToCacheUrl: Record = cacheBackupUrl - ? { - [REGION_US_EAST_2]: cacheBackupUrl, - [REGION_US_WEST_2]: cacheBackupUrl, - } - : { - [REGION_US_EAST_2]: runtimeEnv.CACHE_URL_US_EAST, - [REGION_US_WEST_2]: runtimeEnv.CACHE_URL, - }; +export const initRedisConfig = (env: Env) => { + if (_initialized) return; + _initialized = true; -export const primaryCacheUrl = - regionToCacheUrl[currentRegion] || runtimeEnv.CACHE_URL || cacheBackupUrl; + _currentRegion = env.AWS_REGION || REGION_US_WEST_2; + _cacheBackupUrl = env.CACHE_BACKUP_URL?.trim(); + _regionToCacheUrl = _cacheBackupUrl + ? { + [REGION_US_EAST_2]: _cacheBackupUrl, + [REGION_US_WEST_2]: _cacheBackupUrl, + } + : { + [REGION_US_EAST_2]: env.CACHE_URL_US_EAST, + [REGION_US_WEST_2]: env.CACHE_URL, + }; + + _primaryCacheUrl = + _regionToCacheUrl[_currentRegion] || env.CACHE_URL || _cacheBackupUrl; +}; + +export const currentRegion = new Proxy({} as unknown as string, { + get() { return _currentRegion; }, +}); + +export const cacheBackupUrl = new Proxy({} as unknown as string | undefined, { + get() { return _cacheBackupUrl; }, +}); + +export const primaryCacheUrl = new Proxy({} as unknown as string | undefined, { + get() { return _primaryCacheUrl; }, +}); + +export const hasRedisConfig = new Proxy({} as unknown as boolean, { + get() { return Boolean(_primaryCacheUrl); }, +}); -export const hasRedisConfig = Boolean(primaryCacheUrl); /** Get all regions that have configured cache URLs */ export const getConfiguredRegions = (): string[] => { - return ALL_REGIONS.filter((region) => regionToCacheUrl[region]); + return ALL_REGIONS.filter((region) => _regionToCacheUrl[region]); }; export const getCacheUrlForRegion = ({ region }: { region: string }) => { - return regionToCacheUrl[region]; + return _regionToCacheUrl[region]; }; - -export const PRIMARY_REGION = REGION_US_WEST_2; diff --git a/server/src/external/redis/initUtils/redisV2Config.ts b/server/src/external/redis/initUtils/redisV2Config.ts index 7283138aa..0dc74475b 100644 --- a/server/src/external/redis/initUtils/redisV2Config.ts +++ b/server/src/external/redis/initUtils/redisV2Config.ts @@ -1,26 +1,26 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { RedisV2InstanceName } from "@/internal/misc/redisV2Cache/redisV2CacheSchemas.js"; -export const REDIS_V2_COMMAND_TIMEOUT_MS = - runtimeEnv.NODE_ENV === "production" ? 1_000 : 10_000; - export const getRedisV2ConnectionConfig = ({ cacheV2Url, currentRegion, instanceName, + env, }: { cacheV2Url?: string; currentRegion: string; instanceName: RedisV2InstanceName; -}) => - cacheV2Url?.trim() + env: Env; +}) => { + const commandTimeout = env.NODE_ENV === "production" ? 1_000 : 10_000; + return cacheV2Url?.trim() ? { cacheUrl: cacheV2Url.trim(), region: `${currentRegion}:v2`, supportsUpstashShebang: supportsUpstashShebangForRedisV2(instanceName), - commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS, + commandTimeout, } : null; +}; export const supportsUpstashShebangForRedisV2 = (name: RedisV2InstanceName) => name === "upstash"; diff --git a/server/src/external/redis/orgRedisPool.ts b/server/src/external/redis/orgRedisPool.ts index b1bfbbadd..01169db68 100644 --- a/server/src/external/redis/orgRedisPool.ts +++ b/server/src/external/redis/orgRedisPool.ts @@ -1,7 +1,7 @@ import type { OrgRedisConfig } from "@autumn/shared"; import type { Redis } from "ioredis"; import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; import { decryptData } from "@/utils/encryptUtils.js"; import { getReachableDragonflyUrl } from "./getReachableDragonflyUrl.js"; diff --git a/server/src/external/redis/otel/emitRedisSlowLog.ts b/server/src/external/redis/otel/emitRedisSlowLog.ts index b96421ceb..641d74a82 100644 --- a/server/src/external/redis/otel/emitRedisSlowLog.ts +++ b/server/src/external/redis/otel/emitRedisSlowLog.ts @@ -1,4 +1,4 @@ -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { addRedisToLogs } from "@/utils/logging/addContextToLogs.js"; import type { RedisKeyContext } from "./parseRedisKeyContext.js"; import type { ResolvedThresholds } from "./redisSlowlogConfig.js"; diff --git a/server/src/external/redis/resolveRedisV2.ts b/server/src/external/redis/resolveRedisV2.ts index e5c0c4268..02a6b0fa7 100644 --- a/server/src/external/redis/resolveRedisV2.ts +++ b/server/src/external/redis/resolveRedisV2.ts @@ -1,5 +1,5 @@ import type { Redis } from "ioredis"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { getRampDestinationRedis, isCacheV2RampEnabled, diff --git a/server/src/external/redis/utils/runRedisOp.ts b/server/src/external/redis/utils/runRedisOp.ts index 8628f1288..aa21bf537 100644 --- a/server/src/external/redis/utils/runRedisOp.ts +++ b/server/src/external/redis/utils/runRedisOp.ts @@ -1,5 +1,5 @@ import type { Redis } from "ioredis"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { redis } from "@/external/redis/initRedis.js"; import { RedisUnavailableError } from "./errors.js"; diff --git a/server/src/external/resend/loopsUtils.ts b/server/src/external/resend/loopsUtils.ts index 867d6b2ce..8c3748d4a 100644 --- a/server/src/external/resend/loopsUtils.ts +++ b/server/src/external/resend/loopsUtils.ts @@ -1,20 +1,20 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { User } from "better-auth"; import { LoopsClient } from "loops"; -import { logger } from "../logtail/logtailUtils.js"; +import { createLogger } from "../logtail/logtailUtils.js"; -const createLoopsCli = () => { - return new LoopsClient(runtimeEnv.LOOPS_API_KEY || ""); +const createLoopsCli = (env: Env) => { + return new LoopsClient(env.LOOPS_API_KEY || ""); }; -export const createLoopsContact = async (user: User) => { - if (!runtimeEnv.LOOPS_API_KEY) return; +export const createLoopsContact = async (env: Env, user: User) => { + const logger = createLogger(env); + if (!env.LOOPS_API_KEY) return; try { const email = user.email; const firstName = user.name?.split(" ")[0] || ""; const lastName = user.name?.split(" ")[1] || ""; - const loops = createLoopsCli(); + const loops = createLoopsCli(env); const resp = await loops.createContact(email, { firstName, diff --git a/server/src/external/resend/resendUtils.ts b/server/src/external/resend/resendUtils.ts index bcf3a284c..caf70a884 100644 --- a/server/src/external/resend/resendUtils.ts +++ b/server/src/external/resend/resendUtils.ts @@ -1,6 +1,5 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { Resend } from "resend"; -import { logger } from "../logtail/logtailUtils.js"; +import { createLogger } from "../logtail/logtailUtils.js"; interface ResendEmailProps { to: string; @@ -11,17 +10,19 @@ interface ResendEmailProps { replyTo?: string; } -export const createResendCli = () => { - return new Resend(runtimeEnv.RESEND_API_KEY); +export const createResendCli = (env: Env) => { + return new Resend(env.RESEND_API_KEY); }; export const sendTextEmail = async ({ + env, from, to, subject, body, -}: ResendEmailProps) => { - const resend = createResendCli(); +}: ResendEmailProps & { env: Env }) => { + const logger = createLogger(env); + const resend = createResendCli(env); try { logger.info(`Sending email to ${to} with subject ${subject}`); @@ -58,13 +59,14 @@ export const sendTextEmail = async ({ }; export const sendHtmlEmail = async ({ + env, from, to, subject, body, replyTo, -}: ResendEmailProps) => { - const resend = createResendCli(); +}: ResendEmailProps & { env: Env }) => { + const resend = createResendCli(env); await resend.emails.send({ from: from, diff --git a/server/src/external/resend/safeResend.ts b/server/src/external/resend/safeResend.ts index 2e39bc750..382b26793 100644 --- a/server/src/external/resend/safeResend.ts +++ b/server/src/external/resend/safeResend.ts @@ -1,15 +1,17 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; -import { logger } from "../logtail/logtailUtils.js"; +import { createLogger } from "../logtail/logtailUtils.js"; export function safeResend any>({ + env, fn, action, }: { + env: Env; fn: T; action: string; }): (...args: Parameters) => Promise | undefined> { + const logger = createLogger(env); return async (...args: Parameters) => { - if (!runtimeEnv.RESEND_API_KEY || !runtimeEnv.RESEND_DOMAIN) { + if (!env.RESEND_API_KEY || !env.RESEND_DOMAIN) { logger.warn( `RESEND_API_KEY or RESEND_DOMAIN is not set, skipping ${action}`, ); diff --git a/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts b/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts index 49331fed4..0e4d2924e 100644 --- a/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts +++ b/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts @@ -11,7 +11,7 @@ import { decryptData, encryptData } from "@/utils/encryptUtils.js"; const TOKEN_EXPIRY_SKEW_MS = 60_000; -const getOAuthConfigForEnv = ({ +const getOAuthConfigForEnv = (env: Env) => ({ revenueCatConfig, env, }: { diff --git a/server/src/external/revenueCat/misc/registerRevenuecatWebhook.ts b/server/src/external/revenueCat/misc/registerRevenuecatWebhook.ts index 12acd64dd..a15ee9176 100644 --- a/server/src/external/revenueCat/misc/registerRevenuecatWebhook.ts +++ b/server/src/external/revenueCat/misc/registerRevenuecatWebhook.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AppEnv } from "@autumn/shared"; import type { initRevenuecatCli } from "./initRevenuecatCli.js"; @@ -8,19 +7,21 @@ type RcCli = ReturnType; * Outbound base URL for our webhook receiver. Dev/staging use NGROK_URL (so RevenueCat * can reach a local tunnel); production uses BETTER_AUTH_URL. */ -const getServerBaseUrl = (): string | undefined => - runtimeEnv.NODE_ENV !== "production" - ? runtimeEnv.NGROK_URL - : runtimeEnv.BETTER_AUTH_URL; +const getServerBaseUrl = (env: Partial): string | undefined => + env.NODE_ENV !== "production" + ? env.NGROK_URL + : env.BETTER_AUTH_URL; export const getRevenuecatWebhookUrl = ({ orgId, env, + serverEnv, }: { orgId: string; env: AppEnv; + serverEnv: Partial; }): string | null => { - const base = getServerBaseUrl(); + const base = getServerBaseUrl(serverEnv); if (!base) return null; // `:env` segment is the AppEnv value ("sandbox"/"live") — revenueCatMiddleware reads it verbatim. return `${base.replace(/\/$/, "")}/webhooks/revenuecat/${orgId}/${env}`; @@ -35,13 +36,15 @@ export const registerRevenuecatWebhook = async ({ orgId, env, secret, + serverEnv, }: { rcCli: RcCli; orgId: string; env: AppEnv; secret: string; + serverEnv: Partial; }): Promise<"exists" | "created" | "skipped"> => { - const url = getRevenuecatWebhookUrl({ orgId, env }); + const url = getRevenuecatWebhookUrl({ orgId, env, serverEnv }); if (!url) return "skipped"; const existing = await rcCli.listWebhookIntegrations(); diff --git a/server/src/external/revenueCat/misc/revenuecatOAuth.ts b/server/src/external/revenueCat/misc/revenuecatOAuth.ts index 698f3baca..b6445a526 100644 --- a/server/src/external/revenueCat/misc/revenuecatOAuth.ts +++ b/server/src/external/revenueCat/misc/revenuecatOAuth.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { CodeChallengeMethod, generateCodeVerifier, @@ -48,22 +47,22 @@ export const findMissingRcScopes = (grantedScopes: string[]): string[] => { ); }; -const getRcOAuthClient = () => { - const clientId = runtimeEnv.REVENUECAT_OAUTH_CLIENT_ID; - const clientSecret = runtimeEnv.REVENUECAT_OAUTH_CLIENT_SECRET; +const getRcOAuthClient = (env: Env) => { + const clientId = env.REVENUECAT_OAUTH_CLIENT_ID; + const clientSecret = env.REVENUECAT_OAUTH_CLIENT_SECRET; if (!clientId || !clientSecret) { throw new Error("RevenueCat OAuth client credentials not configured"); } - return new OAuth2Client(clientId, clientSecret, getRcOAuthRedirectUri()); + return new OAuth2Client(clientId, clientSecret, getRcOAuthRedirectUri(env)); }; -export const getRcOAuthRedirectUri = () => { - let serverUrl = runtimeEnv.BETTER_AUTH_URL; +export const getRcOAuthRedirectUri = (env: Env) => { + let serverUrl = env.BETTER_AUTH_URL; - if (runtimeEnv.NGROK_URL) { - serverUrl = runtimeEnv.NGROK_URL; + if (env.NGROK_URL) { + serverUrl = env.NGROK_URL; } return `${(serverUrl ?? "").replace(/\/+$/, "")}/revenuecat/oauth_callback`; @@ -73,12 +72,14 @@ export const createRcAuthorizationUrl = ({ state, codeVerifier, scopes = RC_OAUTH_SCOPES, + env, }: { state: string; codeVerifier: string; scopes?: string[]; + env: Env; }) => { - const client = getRcOAuthClient(); + const client = getRcOAuthClient(env); return client.createAuthorizationURLWithPKCE( RC_AUTHORIZE_URL, state, @@ -91,11 +92,13 @@ export const createRcAuthorizationUrl = ({ export const exchangeRcCode = async ({ code, codeVerifier, + env, }: { code: string; codeVerifier: string; + env: Env; }) => { - const client = getRcOAuthClient(); + const client = getRcOAuthClient(env); return client.validateAuthorizationCode(RC_TOKEN_URL, code, codeVerifier); }; @@ -104,11 +107,13 @@ export const refreshRcTokens = async ({ // Omit scopes on refresh — re-requesting the full set triggers RC `invalid_scope`. // An empty list reuses the originally-granted scopes (OAuth2 §6). scopes = [], + env, }: { refreshToken: string; scopes?: string[]; + env: Env; }) => { - const client = getRcOAuthClient(); + const client = getRcOAuthClient(env); return client.refreshAccessToken(RC_TOKEN_URL, refreshToken, scopes); }; diff --git a/server/src/external/stripe/handleStripeWebhookEvent.ts b/server/src/external/stripe/handleStripeWebhookEvent.ts index 7a8654309..3a7394545 100644 --- a/server/src/external/stripe/handleStripeWebhookEvent.ts +++ b/server/src/external/stripe/handleStripeWebhookEvent.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import * as Sentry from "@sentry/bun"; import type { Context } from "hono"; import { Stripe } from "stripe"; @@ -123,7 +122,7 @@ export const handleStripeWebhookEvent = async ( } if ( - runtimeEnv.NODE_ENV === "development" && + env.NODE_ENV === "development" && error instanceof Error && error.message.includes("No stripe account linked to organization") ) { diff --git a/server/src/external/stripe/stripeCusUtils.ts b/server/src/external/stripe/stripeCusUtils.ts index 5e7149518..3e56d6c1c 100644 --- a/server/src/external/stripe/stripeCusUtils.ts +++ b/server/src/external/stripe/stripeCusUtils.ts @@ -18,7 +18,7 @@ import { createStripeCustomer } from "@/external/stripe/customers"; import { CusService } from "@/internal/customers/CusService.js"; import type { TestContext } from "../../../tests/utils/testInitUtils/createTestContext"; -import { logger } from "../logtail/logtailUtils"; +import { createLogger } from "../logtail/logtailUtils"; const getStripeCus = async ({ stripeCli, diff --git a/server/src/external/stripe/stripeOnboardingUtils.ts b/server/src/external/stripe/stripeOnboardingUtils.ts index 4e71ab389..21b973a95 100644 --- a/server/src/external/stripe/stripeOnboardingUtils.ts +++ b/server/src/external/stripe/stripeOnboardingUtils.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { type AppEnv, ErrCode } from "@autumn/shared"; import Stripe from "stripe"; import RecaseError from "@/utils/errorUtils.js"; @@ -21,7 +20,7 @@ export const createWebhookEndpoint = async ( ) => { const stripe = new Stripe(apiKey); - const webhookBaseUrl = runtimeEnv.STRIPE_WEBHOOK_URL || runtimeEnv.SERVER_URL; + const webhookBaseUrl = env.STRIPE_WEBHOOK_URL || env.SERVER_URL; if (!webhookBaseUrl) { throw new RecaseError({ diff --git a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts index a40de52e1..b478500f5 100644 --- a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts +++ b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts @@ -14,7 +14,7 @@ import { type Price, type ProductOptions, } from "@autumn/shared"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { getExistingUsageFromCusProducts } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { diff --git a/server/src/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils.ts b/server/src/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils.ts index ec80e336d..70eef5d20 100644 --- a/server/src/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils.ts +++ b/server/src/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils.ts @@ -1,22 +1,24 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { getPrimaryRedis } from "@/external/redis/initRedis"; import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils"; export const setStripeSubscriptionLock = async ({ stripeSubscriptionId, lockedAtMs, + env, }: { stripeSubscriptionId: string; lockedAtMs: number; + env: Env; }) => { const primaryRedis = getPrimaryRedis(); await tryRedisWrite( + env, async () => primaryRedis.set( `sub:${stripeSubscriptionId}`, JSON.stringify({ lockedAtMs }), "EX", - runtimeEnv.NODE_ENV === "production" ? 60 : 3, + env.NODE_ENV === "production" ? 60 : 3, ), primaryRedis, ); @@ -28,11 +30,13 @@ type StripeSubscriptionLock = { export const getStripeSubscriptionLock = async ({ stripeSubscriptionId, + env, }: { stripeSubscriptionId: string; + env: Env; }): Promise => { const primaryRedis = getPrimaryRedis(); - return tryRedisRead(async () => { + return tryRedisRead(env, async () => { const value = await primaryRedis.get(`sub:${stripeSubscriptionId}`); if (!value) return null; return JSON.parse(value) as StripeSubscriptionLock; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/releaseScheduleIfLastPhase.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/releaseScheduleIfLastPhase.ts index 0ec9575c6..6a403b4aa 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/releaseScheduleIfLastPhase.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/releaseScheduleIfLastPhase.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { createStripeCli } from "@/external/connect/createStripeCli"; import { isStripeSubscriptionScheduleInLastPhase } from "@/external/stripe/subscriptionSchedules/utils/classifyStripeSubscriptionScheduleUtils"; import { stripeSubscriptionScheduleToPhaseIndex } from "@/external/stripe/subscriptionSchedules/utils/convertStripeSubscriptionScheduleUtils"; @@ -74,7 +73,7 @@ export const releaseScheduleIfLastPhase = async ({ return true; } catch (error: unknown) { if (error instanceof Error) { - if (runtimeEnv.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { logger.warn( `[handleSchedulePhaseChanges] failed to release schedule: ${error.message}`, ); diff --git a/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts index f636bee3d..300219266 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { type AppEnv, AuthType, @@ -55,8 +54,8 @@ export const stripeConnectSeederMiddleware = async ( const signature = c.req.header("stripe-signature") || ""; const skipVerify = - runtimeEnv.STRIPE_WEBHOOK_SKIP_VERIFY === "true" && - runtimeEnv.NODE_ENV !== "production"; + env.STRIPE_WEBHOOK_SKIP_VERIFY === "true" && + env.NODE_ENV !== "production"; let event: Stripe.Event; if (skipVerify) { @@ -79,7 +78,7 @@ export const stripeConnectSeederMiddleware = async ( ); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - if (runtimeEnv.NODE_ENV !== "development") { + if (env.NODE_ENV !== "development") { logger.warn(`Webhook verification error: ${message}`); } return c.json({ error: message }, 400); @@ -115,7 +114,7 @@ export const stripeConnectSeederMiddleware = async ( return c.json({ error: "Failed to resolve org for Stripe webhook" }, 500); } - if (runtimeEnv.NODE_ENV !== "development") { + if (env.NODE_ENV !== "development") { logger.error( `Account ID ${accountId} not linked to any org, skipping Stripe webhook`, ); diff --git a/server/src/external/stripe/webhookMiddlewares/stripeIdempotencyMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeIdempotencyMiddleware.ts index 2403e0059..8109d9385 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeIdempotencyMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeIdempotencyMiddleware.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { tryCatch } from "@autumn/shared"; import type { Context, Next } from "hono"; import { redis } from "@/external/redis/initRedis"; @@ -17,7 +16,7 @@ export const stripeIdempotencyMiddleware = async ( c: Context, next: Next, ) => { - if (runtimeEnv.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { await next(); return; } diff --git a/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts index d5eaed76f..81cca6853 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { type AppEnv, AuthType } from "@autumn/shared"; import type { Context, Next } from "hono"; import Stripe from "stripe"; @@ -55,8 +54,8 @@ export const stripeLegacySeederMiddleware = async ( const signature = c.req.header("stripe-signature") || ""; const skipVerify = - runtimeEnv.STRIPE_WEBHOOK_SKIP_VERIFY === "true" && - runtimeEnv.NODE_ENV !== "production"; + env.STRIPE_WEBHOOK_SKIP_VERIFY === "true" && + env.NODE_ENV !== "production"; let event: Stripe.Event; if (skipVerify) { diff --git a/server/src/external/stripe/webhookMiddlewares/stripeSyncMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeSyncMiddleware.ts index 22e5cbb0b..271bf8577 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeSyncMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeSyncMiddleware.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { isSyncableEvent, processStripeSyncEvent } from "@autumn/stripe-sync"; import type { Context, Next } from "hono"; import { isStripeSyncEnabled } from "@/internal/misc/stripeSync/stripeSyncStore.js"; @@ -22,7 +21,7 @@ export const stripeSyncMiddleware = async ( if (!org || !stripeEvent) return; if ( - runtimeEnv.NODE_ENV === "production" && + c.env.NODE_ENV === "production" && !isStripeSyncEnabled({ orgId: org.id, orgSlug: org.slug }) ) return; diff --git a/server/src/external/stripe/webhookMiddlewares/stripeWebhookContext.ts b/server/src/external/stripe/webhookMiddlewares/stripeWebhookContext.ts index 7b6abf6aa..069855b67 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeWebhookContext.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeWebhookContext.ts @@ -9,6 +9,7 @@ export interface StripeWebhookContext extends AutumnContext { } export type StripeWebhookHonoEnv = { + Bindings: Env; Variables: { ctx: StripeWebhookContext; validated: boolean; diff --git a/server/src/external/supabase/createSupabaseClient.ts b/server/src/external/supabase/createSupabaseClient.ts index 263fc7c9a..dbda6be49 100644 --- a/server/src/external/supabase/createSupabaseClient.ts +++ b/server/src/external/supabase/createSupabaseClient.ts @@ -1,11 +1,10 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { createClient } from "@supabase/supabase-js"; -export const createSupabaseClient = () => { +export const createSupabaseClient = (env: Env) => { try { return createClient( - runtimeEnv.SUPABASE_URL!, - runtimeEnv.SUPABASE_SERVICE_KEY!, + env.SUPABASE_URL!, + env.SUPABASE_SERVICE_KEY!, ); } catch (error) { console.error("Error creating Supabase client:", error); diff --git a/server/src/external/supabase/storageUtils.ts b/server/src/external/supabase/storageUtils.ts index 0288ecb1c..970f499a7 100644 --- a/server/src/external/supabase/storageUtils.ts +++ b/server/src/external/supabase/storageUtils.ts @@ -1,13 +1,15 @@ import { createSupabaseClient } from "@/external/supabase/createSupabaseClient"; const readFile = async ({ + env, bucket = "autumn", path, }: { + env: Env; bucket: string; path: string; }) => { - const sb = createSupabaseClient(); + const sb = createSupabaseClient(env); const { data, error } = await sb.storage.from(bucket).download(path); if (error) { @@ -17,15 +19,17 @@ const readFile = async ({ }; const uploadFile = async ({ + env, path, file, contentType, }: { + env: Env; path: string; file: Buffer; contentType?: string; }) => { - const sb = createSupabaseClient(); + const sb = createSupabaseClient(env); const { data, error } = await sb.storage.from("autumn").upload(path, file, { upsert: true, @@ -39,8 +43,8 @@ const uploadFile = async ({ return data; }; -export const getUploadUrl = async ({ path }: { path: string }) => { - const sb = createSupabaseClient(); +export const getUploadUrl = async ({ env, path }: { env: Env; path: string }) => { + const sb = createSupabaseClient(env); await sb.storage.from("autumn").remove([path]); const { data, error } = await sb.storage diff --git a/server/src/external/svix/svixHelpers.ts b/server/src/external/svix/svixHelpers.ts index 9657da231..10e884f02 100644 --- a/server/src/external/svix/svixHelpers.ts +++ b/server/src/external/svix/svixHelpers.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { AppEnv, Organization } from "@autumn/shared"; import * as Sentry from "@sentry/bun"; import { getSentryTags } from "@/external/sentry/sentryUtils.js"; @@ -54,7 +53,7 @@ export const sendSvixEvent = async ({ idempotencyKey?: string; tags?: string[]; }) => { - if (!runtimeEnv.SVIX_API_KEY) return; + if (!env.SVIX_API_KEY) return; const { org, env } = ctx; diff --git a/server/src/external/svix/svixUtils.ts b/server/src/external/svix/svixUtils.ts index 4afb94488..a7c04b1f8 100644 --- a/server/src/external/svix/svixUtils.ts +++ b/server/src/external/svix/svixUtils.ts @@ -1,27 +1,30 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AppEnv, type Organization } from "@autumn/shared"; import { Svix } from "svix"; -import { logger } from "../logtail/logtailUtils.js"; +import { createLogger } from "../logtail/logtailUtils.js"; -export const createSvixCli = () => { - return new Svix(runtimeEnv.SVIX_API_KEY as string); +export const createSvixCli = (env: Env) => { + return new Svix(env.SVIX_API_KEY as string); }; export function safeSvix any>({ fn, action, + env, }: { fn: T; action: string; + env: Env; }): (...args: Parameters) => Promise | undefined> { return async (...args: Parameters) => { - if (!runtimeEnv.SVIX_API_KEY) { + if (!env.SVIX_API_KEY) { + const logger = createLogger(env); logger.warn(`SVIX_API_KEY is not set, skipping ${action}`); return; } try { return await fn(...args); } catch (error) { + const logger = createLogger(env); logger.error(`Error ${action}: ${error}`); } }; diff --git a/server/src/external/tinybird/initClickhouse.ts b/server/src/external/tinybird/initClickhouse.ts index 832a81070..7d9c01dea 100644 --- a/server/src/external/tinybird/initClickhouse.ts +++ b/server/src/external/tinybird/initClickhouse.ts @@ -1,23 +1,28 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { type ClickHouseClient, createClient } from "@clickhouse/client"; -const TINYBIRD_CLICKHOUSE_URL = runtimeEnv.TINYBIRD_US_EAST_CLICKHOUSE_URL; -const TINYBIRD_TOKEN = runtimeEnv.TINYBIRD_US_EAST_TOKEN; - -if (TINYBIRD_CLICKHOUSE_URL && TINYBIRD_TOKEN) { - console.log( - `[Tinybird ClickHouse] Configured with URL: ${TINYBIRD_CLICKHOUSE_URL}`, - ); -} - /** ClickHouse client for raw SQL queries to Tinybird. Null if not configured. */ -export const clickhouseClient: ClickHouseClient | null = - TINYBIRD_CLICKHOUSE_URL && TINYBIRD_TOKEN - ? createClient({ - url: TINYBIRD_CLICKHOUSE_URL, - password: TINYBIRD_TOKEN, - }) - : null; +let clickhouseClient: ClickHouseClient | null = null; + +export const initClickhouse = (env: Env) => { + const TINYBIRD_CLICKHOUSE_URL = env.TINYBIRD_US_EAST_CLICKHOUSE_URL; + const TINYBIRD_TOKEN = env.TINYBIRD_US_EAST_TOKEN; + + if (TINYBIRD_CLICKHOUSE_URL && TINYBIRD_TOKEN) { + console.log( + `[Tinybird ClickHouse] Configured with URL: ${TINYBIRD_CLICKHOUSE_URL}`, + ); + } + + clickhouseClient = + TINYBIRD_CLICKHOUSE_URL && TINYBIRD_TOKEN + ? createClient({ + url: TINYBIRD_CLICKHOUSE_URL, + password: TINYBIRD_TOKEN, + }) + : null; +}; + +export { clickhouseClient }; /** Get ClickHouse client, throws if not configured. */ export const getClickhouseClient = (): ClickHouseClient => { diff --git a/server/src/external/tinybird/initTinybirdV2.ts b/server/src/external/tinybird/initTinybirdV2.ts index 20eae0071..de166fa18 100644 --- a/server/src/external/tinybird/initTinybirdV2.ts +++ b/server/src/external/tinybird/initTinybirdV2.ts @@ -1,23 +1,29 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; -import { createTinybirdApi } from "@tinybirdco/sdk"; +import { createTinybirdApi, type TinybirdApi } from "@tinybirdco/sdk"; -const TINYBIRD_SECONDARY_API_URL = runtimeEnv.TINYBIRD_API_URL; -const TINYBIRD_SECONDARY_TOKEN = runtimeEnv.TINYBIRD_TOKEN; +export interface TinybirdSecondaryEnv { + TINYBIRD_API_URL: string; + TINYBIRD_TOKEN: string; +} /** Secondary Tinybird API client for dual-write safety net during region cutover. * Reads from the legacy TINYBIRD_API_URL / TINYBIRD_TOKEN env vars (europe-west2 * GCP). Once us-east is stable, delete this file + the dual-write logic in - * sendEvents.ts. */ -export const tinybirdSecondaryApi = - TINYBIRD_SECONDARY_API_URL && TINYBIRD_SECONDARY_TOKEN - ? createTinybirdApi({ - baseUrl: TINYBIRD_SECONDARY_API_URL, - token: TINYBIRD_SECONDARY_TOKEN, - }) - : null; + * sendEvents.ts. + * Initialized via configureTinybirdSecondaryApi(). Null until initialized. */ +export let tinybirdSecondaryApi: TinybirdApi | null = null; -if (tinybirdSecondaryApi) { - console.log( - `[Tinybird] secondary dual-write configured with URL: ${TINYBIRD_SECONDARY_API_URL}`, - ); -} +/** Configure the secondary Tinybird API client. Must be called during initialization. */ +export const configureTinybirdSecondaryApi = (env: TinybirdSecondaryEnv): void => { + const apiUrl = env.TINYBIRD_API_URL; + const token = env.TINYBIRD_TOKEN; + + if (apiUrl && token) { + tinybirdSecondaryApi = createTinybirdApi({ + baseUrl: apiUrl, + token, + }); + console.log( + `[Tinybird] secondary dual-write configured with URL: ${apiUrl}`, + ); + } +}; diff --git a/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts b/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts index 364a8d4d1..8ea677279 100644 --- a/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts +++ b/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { defineDatasource, defineEndpoint, @@ -10,16 +9,32 @@ import { t, } from "@tinybirdco/sdk"; -const TINYBIRD_US_EAST_API_URL = runtimeEnv.TINYBIRD_US_EAST_API_URL; -const TINYBIRD_US_EAST_TOKEN = runtimeEnv.TINYBIRD_US_EAST_TOKEN; +export const getTinybirdApiUrl = (env: Env) => env.TINYBIRD_US_EAST_API_URL; +export const getTinybirdToken = (env: Env) => env.TINYBIRD_US_EAST_TOKEN; -const migrationTinybirdConfig = - TINYBIRD_US_EAST_API_URL && TINYBIRD_US_EAST_TOKEN - ? { - baseUrl: TINYBIRD_US_EAST_API_URL, - token: TINYBIRD_US_EAST_TOKEN, - } - : null; +/** Tinybird client for migration item events. Null until initMigrationTinybird(env) is called. */ +export let migrationTinybird: Tinybird | null = null; + +export const initMigrationTinybird = (env: Env) => { + const apiUrl = env.TINYBIRD_US_EAST_API_URL; + const token = env.TINYBIRD_US_EAST_TOKEN; + + if (!apiUrl || !token) { + return; + } + + migrationTinybird = new Tinybird({ + datasources: { + itemEvents: migrationItemEventsDatasource, + }, + pipes: { + listItemEvents: listMigrationItemEventsEndpoint, + }, + baseUrl: apiUrl, + token, + devMode: false, + }); +}; export type MigrationItemEventStatus = "succeeded" | "skipped" | "failed"; @@ -124,16 +139,3 @@ export const listMigrationItemEventsEndpoint = defineEndpoint( }, }, ); - -export const migrationTinybird = migrationTinybirdConfig - ? new Tinybird({ - datasources: { - itemEvents: migrationItemEventsDatasource, - }, - pipes: { - listItemEvents: listMigrationItemEventsEndpoint, - }, - ...migrationTinybirdConfig, - devMode: false, - }) - : null; diff --git a/server/src/external/tinybird/tinybirdUtils.ts b/server/src/external/tinybird/tinybirdUtils.ts index f6f55d1f6..051970fa4 100644 --- a/server/src/external/tinybird/tinybirdUtils.ts +++ b/server/src/external/tinybird/tinybirdUtils.ts @@ -1,22 +1,23 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { ErrCode, RecaseError } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; -const TINYBIRD_API_URL = runtimeEnv.TINYBIRD_US_EAST_API_URL; -const TINYBIRD_TOKEN = runtimeEnv.TINYBIRD_US_EAST_TOKEN; - export type TinybirdConfig = { baseUrl: string; token: string; }; -export const tinybirdConfig: TinybirdConfig | null = - TINYBIRD_API_URL && TINYBIRD_TOKEN - ? { - baseUrl: TINYBIRD_API_URL, - token: TINYBIRD_TOKEN, - } - : null; +export let tinybirdConfig: TinybirdConfig | null = null; + +export const initTinybirdConfig = (env: Env) => { + + tinybirdConfig = + TINYBIRD_API_URL && TINYBIRD_TOKEN + ? { + baseUrl: TINYBIRD_API_URL, + token: TINYBIRD_TOKEN, + } + : null; +}; /** Check if Tinybird is configured. */ export const isTinybirdConfigured = (): boolean => tinybirdConfig !== null; diff --git a/server/src/external/vercel/misc/vercelAuth.ts b/server/src/external/vercel/misc/vercelAuth.ts index 023b63508..a9e140840 100644 --- a/server/src/external/vercel/misc/vercelAuth.ts +++ b/server/src/external/vercel/misc/vercelAuth.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AppEnv, type Organization } from "@autumn/shared"; import { createRemoteJWKSet, jwtVerify } from "jose"; import { JWTExpired, JWTInvalid } from "jose/errors"; @@ -43,7 +42,7 @@ const synthesizeTestClaims = ({ env: AppEnv; testOptions?: VercelOidcTestOptions; }): OidcClaims | null => { - if (runtimeEnv.NODE_ENV === "production") return null; + if (env.NODE_ENV === "production") return null; if (testOptions?.allowVercelTestOidc !== true) return null; if (!token.startsWith(TEST_OIDC_PREFIX)) return null; diff --git a/server/src/external/vercel/misc/vercelSdkOptions.ts b/server/src/external/vercel/misc/vercelSdkOptions.ts index d54eaf76d..09237f8b0 100644 --- a/server/src/external/vercel/misc/vercelSdkOptions.ts +++ b/server/src/external/vercel/misc/vercelSdkOptions.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; export type VercelSdkTestOptions = { mockVercelApi?: boolean; }; @@ -6,12 +5,16 @@ export type VercelSdkTestOptions = { /** * Only tests opt into the local Vercel SDK mock; dev/manual flows hit Vercel. */ -export const getVercelSdkServerURL = ( - testOptions?: VercelSdkTestOptions, -): string | undefined => { - if (runtimeEnv.NODE_ENV === "production") return undefined; +export const getVercelSdkServerURL = ({ + env, + testOptions, +}: { + env: Env; + testOptions?: VercelSdkTestOptions; +}): string | undefined => { + if (env.NODE_ENV === "production") return undefined; if (testOptions?.mockVercelApi !== true) return undefined; - const base = runtimeEnv.BETTER_AUTH_URL; + const base = env.BETTER_AUTH_URL; if (!base) return undefined; return `${base.replace(/\/$/, "")}/__test/vercel/api`; }; diff --git a/server/src/honoMiddlewares/adminAuthMiddleware.ts b/server/src/honoMiddlewares/adminAuthMiddleware.ts index 71da4c0f3..173da5895 100644 --- a/server/src/honoMiddlewares/adminAuthMiddleware.ts +++ b/server/src/honoMiddlewares/adminAuthMiddleware.ts @@ -1,13 +1,14 @@ import { ErrCode, RecaseError } from "@autumn/shared"; import type { Context, Next } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -import { auth } from "@/utils/auth.js"; +import { createAuth } from "@/utils/auth.js"; /** * Admin auth middleware for Hono * Validates that the user has an "admin" role */ export const adminAuthMiddleware = async (c: Context, next: Next) => { + const auth = createAuth(c.env); const data = await auth.api.getSession({ headers: c.req.raw.headers, }); diff --git a/server/src/honoMiddlewares/authMiddlewares/handleOAuthMiddleware.ts b/server/src/honoMiddlewares/authMiddlewares/handleOAuthMiddleware.ts index d0951fe23..cc4f780e3 100644 --- a/server/src/honoMiddlewares/authMiddlewares/handleOAuthMiddleware.ts +++ b/server/src/honoMiddlewares/authMiddlewares/handleOAuthMiddleware.ts @@ -35,6 +35,7 @@ export const handleOAuthMiddleware = async ({ const env = getOAuthEnvironment({ c }); const tokenRecord = await getOAuthAccessTokenRecord({ db: ctx.db, + env: c.env, accessToken: token, resource: c.req.header("x-autumn-oauth-resource") ?? null, requestedScopes: null, diff --git a/server/src/honoMiddlewares/baseMiddleware.ts b/server/src/honoMiddlewares/baseMiddleware.ts index 9ff535040..0c3c211f9 100644 --- a/server/src/honoMiddlewares/baseMiddleware.ts +++ b/server/src/honoMiddlewares/baseMiddleware.ts @@ -8,7 +8,7 @@ import { } from "@autumn/shared"; import type { Context, Next } from "hono"; import { db, dbGeneral } from "@/db/initDrizzle.js"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { generateId } from "@/utils/genUtils.js"; @@ -41,6 +41,8 @@ const redactSensitiveRequestBody = ({ body }: { body: unknown }): unknown => { * Sets up: db, logger, id, timestamp */ export const baseMiddleware = async (c: Context, next: Next) => { + const env = c.env; + const logger = createLogger(env); // const env = (c.req.header("app_env") as AppEnv) || AppEnv.Sandbox; const id = c.req.header("rndr-id") || diff --git a/server/src/honoMiddlewares/betterAuthMiddleware.ts b/server/src/honoMiddlewares/betterAuthMiddleware.ts index f3e77d89c..a30362165 100644 --- a/server/src/honoMiddlewares/betterAuthMiddleware.ts +++ b/server/src/honoMiddlewares/betterAuthMiddleware.ts @@ -2,7 +2,7 @@ import { type AppEnv, AuthType, ErrCode, RecaseError } from "@autumn/shared"; import type { Context, Next } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; -import { auth } from "@/utils/auth.js"; +import { createAuth } from "@/utils/auth.js"; /** * Better Auth middleware for dashboard/session authentication @@ -19,6 +19,7 @@ export const betterAuthMiddleware = async (c: Context, next: Next) => { const ctx = c.get("ctx"); // Step 1: Get session from Better Auth + const auth = createAuth(c.env); const session = await auth.api.getSession({ headers: c.req.raw.headers, }); diff --git a/server/src/honoMiddlewares/routerRateLimiter/index.ts b/server/src/honoMiddlewares/routerRateLimiter/index.ts index 82e36b050..0be6b532e 100644 --- a/server/src/honoMiddlewares/routerRateLimiter/index.ts +++ b/server/src/honoMiddlewares/routerRateLimiter/index.ts @@ -1,6 +1,6 @@ import type { Context, Next } from "hono"; import { rateLimiter } from "hono-rate-limiter"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { shouldUseRedis } from "@/external/redis/initRedis.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { createRateLimitRedisStore } from "@/internal/misc/rateLimiter/rateLimitRedisStore.js"; @@ -32,6 +32,8 @@ export const createRouterRateLimiter = ({ }; return async (c: Context, next: Next) => { + const logger = createLogger(c.env); + if (!shouldUseRedis()) return next(); try { diff --git a/server/src/honoUtils/handleHealthCheck.ts b/server/src/honoUtils/handleHealthCheck.ts index 19dfdd5eb..8c7932112 100644 --- a/server/src/honoUtils/handleHealthCheck.ts +++ b/server/src/honoUtils/handleHealthCheck.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; import { hasRedisConfig, redis } from "@/external/redis/initRedis.js"; import { hasRedisV2Config, redisV2 } from "@/external/redis/initRedisV2.js"; import type { HonoEnv } from "./HonoEnv"; @@ -7,6 +8,27 @@ import { evaluateStartupGate } from "./startupGate.js"; const startedAt = Date.now(); let startupReady = false; +let _logger: Logger | null = null; + +const getLogger = (): Logger => { + if (!_logger) { + throw new Error("handleHealthCheck not initialized — call initHealthCheck(env) first"); + } + return _logger; +}; + +/** + * Initialize the health check module. Must be called before the + * health check endpoint is used and before Redis events fire. + */ +export const initHealthCheck = (env: Env): void => { + if (_logger) return; + _logger = createLogger(env); + + if (hasRedisConfig) redis.once("ready", tryLatchStartupReady); + if (hasRedisV2Config) redisV2.once("ready", tryLatchStartupReady); + tryLatchStartupReady(); +}; const tryLatchStartupReady = () => { if (startupReady) return; @@ -17,7 +39,7 @@ const tryLatchStartupReady = () => { }); if (!ready) return; startupReady = true; - logger.info(`[health-check] startup gate latched (${reason})`, { + getLogger().info(`[health-check] startup gate latched (${reason})`, { redis_status: redis.status, redis_v2_status: redisV2.status, has_redis_config: hasRedisConfig, @@ -25,11 +47,10 @@ const tryLatchStartupReady = () => { }); }; -if (hasRedisConfig) redis.once("ready", tryLatchStartupReady); -if (hasRedisV2Config) redisV2.once("ready", tryLatchStartupReady); -tryLatchStartupReady(); - export const handleHealthCheck = async (c: Context) => { + if (!_logger) { + initHealthCheck(c.env); + } if (!startupReady) { tryLatchStartupReady(); if (!startupReady) { diff --git a/server/src/init.ts b/server/src/init.ts index 3793f0ebf..b97fa54e9 100644 --- a/server/src/init.ts +++ b/server/src/init.ts @@ -1,7 +1,3 @@ -// Sentry + OpenTelemetry must be imported before any application code -import { runtimeEnv } from "@/utils/envUtils.js"; -await import("./sentry.js"); - import cluster from "node:cluster"; import http from "node:http"; import os from "node:os"; @@ -13,7 +9,8 @@ import { } from "./db/pgHealthMonitor.js"; import { startPgPoolMonitor, stopPgPoolMonitor } from "./db/pgPoolMonitor.js"; import { getRedactedDatabaseUrls } from "./db/redactDatabaseUrl.js"; -import { logger } from "./external/logtail/logtailUtils.js"; +import { createLogger } from "./external/logtail/logtailUtils.js"; +import { initDrizzleModules } from "./db/initDrizzle.js"; import { startAllEdgeConfigPolling, stopAllEdgeConfigPolling, @@ -29,8 +26,6 @@ import "./internal/misc/stripeSync/stripeSyncStore.js"; import "./internal/misc/redisV2Cache/redisV2CacheStore.js"; import "./internal/misc/cacheV2Ramp/cacheV2RampStore.js"; import "./internal/misc/jobQueues/jobQueueStore.js"; -// Side-effect: configures trigger.dev SDK to use TRIGGER_SERVER_SECRET_KEY. -import "./trigger/configureTrigger.js"; import { closeStripeSyncEngine } from "@autumn/stripe-sync"; import { startRedisMonitor, @@ -45,21 +40,29 @@ import { } from "./external/redis/initUtils/redisV2Availability.js"; import { preWarmOrgRedisConnections } from "./external/redis/orgRedisPool.js"; import { createHonoApp } from "./initHono.js"; -import { otelSdk } from "./instrumentation.js"; +import { initTelemetry, otelSdk } from "./instrumentation.js"; +import { initSentry } from "./sentry.js"; +import { configureTrigger } from "./trigger/configureTrigger.js"; import { checkEnvVars } from "./utils/initUtils.js"; import { startMemoryMonitor } from "./utils/memoryMonitor.js"; -checkEnvVars(); - let shuttingDown = false; -const init = async ({ startupStartedAt }: { startupStartedAt: number }) => { - logger.info(getRedactedDatabaseUrls(), "DB URLs"); +const init = async ({ + startupStartedAt, + env, +}: { + startupStartedAt: number; + env: Env; +}) => { + const logger = createLogger(env); - const app = createHonoApp(runtimeEnv); + logger.info(getRedactedDatabaseUrls(env), "DB URLs"); - initPgHealthMonitor({ client: clientCritical }); - startPgPoolMonitor(); + const app = createHonoApp(env); + + initPgHealthMonitor({ client: clientCritical, env }); + startPgPoolMonitor(env); void warmupRegionalRedis().catch((error) => { logger.warn("[Redis] Warmup failed", { error }); @@ -73,9 +76,7 @@ const init = async ({ startupStartedAt }: { startupStartedAt: number }) => { startRedisMonitor(); startRedisV2Monitor(); - const PORT = runtimeEnv.SERVER_PORT - ? Number.parseInt(runtimeEnv.SERVER_PORT) - : 8080; + const PORT = env.SERVER_PORT ? Number.parseInt(env.SERVER_PORT) : 8080; const requestListener = getRequestListener(app.fetch); const server = http.createServer(requestListener); @@ -89,17 +90,29 @@ const init = async ({ startupStartedAt }: { startupStartedAt: number }) => { console.log( `Server running on port ${PORT} (${startupDurationMs}ms startup)`, ); - startMemoryMonitor("server", 60_000); + startMemoryMonitor("server", env, 60_000); resolve(); }); }); }; -if (runtimeEnv.NODE_ENV === "development") { - registerFatalErrorHandlers(); - await init({ startupStartedAt: Date.now() }); - registerShutdownHandlers(); -} else { +export const startNodeServer = async (env: Env) => { + const logger = createLogger(env); + + initDrizzleModules(env); + initSentry(env); + initTelemetry(env); + configureTrigger(env); + checkEnvVars(env); + + if (env.NODE_ENV === "development") { + registerFatalErrorHandlers(logger); + await init({ startupStartedAt: Date.now(), env }); + registerShutdownHandlers(logger); + + return; + } + const numCPUs = os.cpus().length; if (cluster.isPrimary) { @@ -123,15 +136,15 @@ if (runtimeEnv.NODE_ENV === "development") { cluster.fork(); }); - registerShutdownHandlers(); + registerShutdownHandlers(logger); } else { - registerFatalErrorHandlers(); - await init({ startupStartedAt: Date.now() }); - registerShutdownHandlers(); + registerFatalErrorHandlers(logger); + await init({ startupStartedAt: Date.now(), env }); + registerShutdownHandlers(logger); } -} +}; -function registerFatalErrorHandlers() { +function registerFatalErrorHandlers(logger: ReturnType) { const exitAfterLog = () => setTimeout(() => process.exit(1), 100); const logFatal = (event: string, error: unknown) => { logger.error(event, { @@ -152,13 +165,13 @@ function registerFatalErrorHandlers() { }); } -function registerShutdownHandlers() { - process.on("SIGTERM", gracefulShutdown); - process.on("SIGINT", gracefulShutdown); +function registerShutdownHandlers(logger: ReturnType) { + process.on("SIGTERM", () => gracefulShutdown(logger)); + process.on("SIGINT", () => gracefulShutdown(logger)); // Do NOT use process.on("exit", ...) for async cleanup! } -async function gracefulShutdown() { +async function gracefulShutdown(logger: ReturnType) { shuttingDown = true; console.log("Shutting down worker, flushing telemetry and closing DB..."); try { diff --git a/server/src/initHono.ts b/server/src/initHono.ts index aa88dead8..166b38bfa 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -21,7 +21,7 @@ import { apiRouter } from "./routers/apiRouter.js"; import { createChatProxyRouter } from "./routers/chatProxyRouter.js"; import { createInternalRouter } from "./routers/internalRouter.js"; import { publicRouter } from "./routers/publicRouter.js"; -import { auth } from "./utils/auth.js"; +import { createAuth } from "./utils/auth.js"; import { isAllowedOrigin } from "./utils/corsOrigins.js"; const ALLOWED_HEADERS = [ @@ -50,6 +50,7 @@ const ALLOWED_HEADERS = [ export const createHonoApp = (env: Env) => { const app = new Hono(); + const auth = createAuth(env); app.route("", createChatProxyRouter(env)); diff --git a/server/src/instrumentation.ts b/server/src/instrumentation.ts index 8cb967a1a..3803acd0b 100644 --- a/server/src/instrumentation.ts +++ b/server/src/instrumentation.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { DiagConsoleLogger, DiagLogLevel, diag } from "@opentelemetry/api"; import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; @@ -14,14 +13,16 @@ diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.WARN); let sdk: NodeSDK | null = null; -if (runtimeEnv.AXIOM_TOKEN) { +export const initTelemetry = (env: Env) => { + if (!env.AXIOM_TOKEN) return sdk; + // NodeSDK reads OTEL_SERVICE_NAME to set the service resource attribute - runtimeEnv.OTEL_SERVICE_NAME = "autumn-server"; + env.OTEL_SERVICE_NAME = "autumn-server"; const traceExporter = new OTLPTraceExporter({ url: "https://api.axiom.co/v1/traces", headers: { - Authorization: `Bearer ${runtimeEnv.AXIOM_TOKEN}`, + Authorization: `Bearer ${env.AXIOM_TOKEN}`, "X-Axiom-Dataset": "otel", }, }); @@ -30,18 +31,18 @@ if (runtimeEnv.AXIOM_TOKEN) { // auto-add a BatchSpanProcessor for `traceExporter` when `spanProcessors` // is set. We must wire the exporter processor explicitly. // Dev: short 1s flush for fast feedback. Prod: default 5s for throughput. - const isDev = runtimeEnv.NODE_ENV !== "production"; + const isDev = env.NODE_ENV !== "production"; const exportProcessor = new BatchSpanProcessor(traceExporter, { scheduledDelayMillis: isDev ? 1000 : 5000, }); const filteredExportProcessor = new FilteringSpanProcessor(exportProcessor); - const metricReader = runtimeEnv.AXIOM_METRICS_DATASET + const metricReader = env.AXIOM_METRICS_DATASET ? new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter({ url: "https://api.axiom.co/v1/metrics", headers: { - Authorization: `Bearer ${runtimeEnv.AXIOM_TOKEN}`, - "x-axiom-metrics-dataset": runtimeEnv.AXIOM_METRICS_DATASET, + Authorization: `Bearer ${env.AXIOM_TOKEN}`, + "x-axiom-metrics-dataset": env.AXIOM_METRICS_DATASET, }, }), exportIntervalMillis: 60_000, @@ -68,6 +69,8 @@ if (runtimeEnv.AXIOM_TOKEN) { }; process.once("SIGTERM", shutdown); process.once("SIGINT", shutdown); -} + + return sdk; +}; export { sdk as otelSdk }; diff --git a/server/src/internal/admin/handleSlackAdminChat.ts b/server/src/internal/admin/handleSlackAdminChat.ts index a522f4800..9b2d0ce74 100644 --- a/server/src/internal/admin/handleSlackAdminChat.ts +++ b/server/src/internal/admin/handleSlackAdminChat.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import crypto, { randomUUID } from "node:crypto"; import { stripOAuthTokenPrefix } from "@autumn/auth"; import { @@ -48,9 +47,15 @@ const findTargetOrg = ({ ), }); -const getSlackAdminInstallation = async ({ db }: { db: DrizzleCli }) => +const getSlackAdminInstallation = async ({ + db, + env, +}: { + db: DrizzleCli; + env: Env; +}) => db.query.chatInstallations.findFirst({ - where: eq(chatInstallations.provider, getSlackAdminProvider()), + where: eq(chatInstallations.provider, getSlackAdminProvider({ env })), }); const getSlackAdminOAuthCredentials = async ({ @@ -80,10 +85,16 @@ const getOrgSummary = async ({ }, }); -const decryptChatCredentialToken = ({ token }: { token: string }) => { +const decryptChatCredentialToken = ({ + env, + token, +}: { + env: Env; + token: string; +}) => { const key = crypto .createHash("sha256") - .update(runtimeEnv.ENCRYPTION_PASSWORD ?? "") + .update(env.ENCRYPTION_PASSWORD ?? "") .digest(); const buffer = Buffer.from(token, "base64"); if (buffer[0] !== 1) throw new Error("Unsupported encrypted payload"); @@ -112,9 +123,11 @@ const getStoredOAuthTokenValues = async ({ const revokeSlackAdminOAuthArtifacts = async ({ db, + env, credentials, }: { db: Pick; + env: Env; credentials: ChatOAuthCredential[]; }) => { const consentIds = [ @@ -129,18 +142,22 @@ const revokeSlackAdminOAuthArtifacts = async ({ const accessTokenValues: string[] = []; const refreshTokenValues: string[] = []; for (const credential of credentials) { - accessTokenValues.push( - ...(await getStoredOAuthTokenValues({ - token: decryptChatCredentialToken({ token: credential.access_token }), - stripPrefix: true, - })), - ); - refreshTokenValues.push( - ...(await getStoredOAuthTokenValues({ - token: decryptChatCredentialToken({ - token: credential.refresh_token, - }), - })), + accessTokenValues.push( + ...(await getStoredOAuthTokenValues({ + token: decryptChatCredentialToken({ + env, + token: credential.access_token, + }), + stripPrefix: true, + })), + ); + refreshTokenValues.push( + ...(await getStoredOAuthTokenValues({ + token: decryptChatCredentialToken({ + env, + token: credential.refresh_token, + }), + })), ); } @@ -184,10 +201,10 @@ const revokeSlackAdminOAuthArtifacts = async ({ export const handleCreateSlackAdminInstall = createRoute({ scopes: [Scopes.Superuser], handler: async (c) => { - const ctx = c.get("ctx"); - const state = createChatInstallState({ - secret: getChatStateSecret(), - provider: getSlackAdminProvider(), + const ctx = c.get("ctx"); + const state = createChatInstallState({ + secret: getChatStateSecret(c.env), + provider: getSlackAdminProvider({ env: c.env }), orgId: ctx.org.id, userId: ctx.userId ?? "", env: ctx.env, @@ -195,7 +212,7 @@ export const handleCreateSlackAdminInstall = createRoute({ nonce: randomUUID(), }); - return c.json({ url: createSlackInstallUrl(state) }); + return c.json({ url: createSlackInstallUrl(c.env, state) }); }, }); @@ -203,7 +220,7 @@ export const handleGetSlackAdminInstall = createRoute({ scopes: [Scopes.Superuser], handler: async (c) => { const { db } = c.get("ctx"); - const installation = await getSlackAdminInstallation({ db }); + const installation = await getSlackAdminInstallation({ db, env: c.env }); const targetOrg = installation ? await getOrgSummary({ db, orgId: installation.org_id }) : null; @@ -248,7 +265,7 @@ export const handleUpdateSlackAdminTarget = createRoute({ const ctx = c.get("ctx"); const { db } = ctx; const { org_id: orgIdOrSlug, env } = c.req.valid("json"); - const installation = await getSlackAdminInstallation({ db }); + const installation = await getSlackAdminInstallation({ db, env: c.env }); if (!installation) { throw new RecaseError({ message: "Slack admin bot is not installed", @@ -283,10 +300,11 @@ export const handleUpdateSlackAdminTarget = createRoute({ .where(eq(chatInstallations.id, installation.id)) .returning(); - await revokeSlackAdminOAuthArtifacts({ - db: tx, - credentials: oauthCredentials, - }); + await revokeSlackAdminOAuthArtifacts({ + db: tx, + env: c.env, + credentials: oauthCredentials, + }); return updatedInstallation; }); @@ -311,7 +329,7 @@ export const handleDeleteSlackAdminInstall = createRoute({ scopes: [Scopes.Superuser], handler: async (c) => { const { db } = c.get("ctx"); - const installation = await getSlackAdminInstallation({ db }); + const installation = await getSlackAdminInstallation({ db, env: c.env }); if (!installation) return c.json({ success: true }); const oauthCredentials = await getSlackAdminOAuthCredentials({ db, @@ -319,16 +337,20 @@ export const handleDeleteSlackAdminInstall = createRoute({ }); await db.transaction(async (tx) => { - await revokeSlackAdminOAuthArtifacts({ - db: tx, - credentials: oauthCredentials, - }); + await revokeSlackAdminOAuthArtifacts({ + db: tx, + env: c.env, + credentials: oauthCredentials, + }); await tx .delete(chatInstallations) .where( and( - eq(chatInstallations.id, installation.id), - eq(chatInstallations.provider, getSlackAdminProvider()), + eq(chatInstallations.id, installation.id), + eq( + chatInstallations.provider, + getSlackAdminProvider({ env: c.env }), + ), ), ); }); diff --git a/server/src/internal/admin/handleUpsertSlackMcpOAuthClient.ts b/server/src/internal/admin/handleUpsertSlackMcpOAuthClient.ts index 771a7a488..49e7b3b0a 100644 --- a/server/src/internal/admin/handleUpsertSlackMcpOAuthClient.ts +++ b/server/src/internal/admin/handleUpsertSlackMcpOAuthClient.ts @@ -1,13 +1,12 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; import { registerMcpOAuthClient } from "@/internal/auth/actions/index.js"; import { createRoute } from "../../honoMiddlewares/routeHandler"; -const getClientUrl = () => - (runtimeEnv.CLIENT_URL || "http://localhost:3000").replace(/\/+$/, ""); +const getClientUrl = (env: Env) => + (env.CLIENT_URL || "http://localhost:3000").replace(/\/+$/, ""); -const getSlackMcpRedirectUris = () => { - const clientUrl = getClientUrl(); +const getSlackMcpRedirectUris = (env: Env) => { + const clientUrl = getClientUrl(env); return [ `${clientUrl}/admin/oauth/slack-mcp/callback`, `${clientUrl}/sandbox/admin/oauth/slack-mcp/callback`, @@ -21,7 +20,7 @@ export const handleUpsertSlackMcpOAuthClient = createRoute({ const result = await registerMcpOAuthClient({ db, clientName: "Slack MCP", - redirectUris: getSlackMcpRedirectUris(), + redirectUris: getSlackMcpRedirectUris(c.env), scope: undefined, }); diff --git a/server/src/internal/api/events/EventService.ts b/server/src/internal/api/events/EventService.ts index 0ba7b218e..e7aa2480f 100644 --- a/server/src/internal/api/events/EventService.ts +++ b/server/src/internal/api/events/EventService.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { ErrCode, type EventInsert, events, RecaseError } from "@autumn/shared"; import { and, desc, eq } from "drizzle-orm"; import { StatusCodes } from "http-status-codes"; @@ -10,12 +9,14 @@ export class EventService { static async insert({ db, event, + env, }: { db: DrizzleCli; event: EventInsert | EventInsert[]; logger?: Logger; + env: Env; }) { - if (runtimeEnv.NODE_ENV !== "development") return; + if (env.NODE_ENV !== "development") return; try { const results = await db .insert(events) @@ -42,14 +43,16 @@ export class EventService { internalCustomerId, env, limit = 10, + workerEnv, }: { db: DrizzleCli; internalCustomerId: string; orgId: string; env: string; limit?: number; + workerEnv: Env; }) { - if (runtimeEnv.NODE_ENV === "production") return []; + if (workerEnv.NODE_ENV === "production") return []; const results = await db .select({ id: events.id, diff --git a/server/src/internal/auth/handleListAuthOrganizations.ts b/server/src/internal/auth/handleListAuthOrganizations.ts index 9a8d5d227..c5269891a 100644 --- a/server/src/internal/auth/handleListAuthOrganizations.ts +++ b/server/src/internal/auth/handleListAuthOrganizations.ts @@ -2,11 +2,13 @@ import { ErrCode, member, organizations, RecaseError } from "@autumn/shared"; import { asc, eq } from "drizzle-orm"; import type { Context } from "hono"; import { db } from "@/db/initDrizzle.js"; -import { auth } from "@/utils/auth.js"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { createAuth } from "@/utils/auth.js"; const AUTH_ORGANIZATION_LIST_LIMIT = 1000; -export const handleListAuthOrganizations = async (c: Context) => { +export const handleListAuthOrganizations = async (c: Context) => { + const auth = createAuth(c.env); const session = await auth.api.getSession({ headers: c.req.raw.headers, }); diff --git a/server/src/internal/auth/oauth/atmnOAuthClients.ts b/server/src/internal/auth/oauth/atmnOAuthClients.ts index 56c0c3f44..0766219ef 100644 --- a/server/src/internal/auth/oauth/atmnOAuthClients.ts +++ b/server/src/internal/auth/oauth/atmnOAuthClients.ts @@ -1,12 +1,11 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { oauthClientRepo } from "../repos/index.js"; const ATMN_OAUTH_CLIENT_NAMES = new Set(["atmn", "autumn cli"]); -const configuredAtmnClientIds = () => +const configuredAtmnClientIds = (env: Env) => new Set( - (runtimeEnv.ATMN_OAUTH_CLIENT_IDS ?? "") + (env.ATMN_OAUTH_CLIENT_IDS ?? "") .split(",") .map((id) => id.trim()) .filter(Boolean), @@ -36,15 +35,17 @@ const metadataMarksAtmn = (metadata: unknown) => { }; export const isAtmnOAuthClientRecord = ({ + env, clientId, name, metadata, }: { + env: Env; clientId: string | null | undefined; name: string | null | undefined; metadata?: unknown; }) => { - if (clientId && configuredAtmnClientIds().has(clientId)) return true; + if (clientId && configuredAtmnClientIds(env).has(clientId)) return true; if (metadataMarksAtmn(metadata)) return true; const normalizedName = name?.trim().toLowerCase(); @@ -53,12 +54,17 @@ export const isAtmnOAuthClientRecord = ({ export const isAtmnOAuthClientId = async ({ db, + env, clientId, }: { db: DrizzleCli; + env: Env; clientId: string; }) => { const client = await oauthClientRepo.getByClientId({ db, clientId }); - return isAtmnOAuthClientRecord(client ?? { clientId, name: null }); + return isAtmnOAuthClientRecord({ + env, + ...(client ?? { clientId, name: null }), + }); }; diff --git a/server/src/internal/auth/oauth/handleGetOAuthClient.ts b/server/src/internal/auth/oauth/handleGetOAuthClient.ts index 57d21503a..0d5c5aa5f 100644 --- a/server/src/internal/auth/oauth/handleGetOAuthClient.ts +++ b/server/src/internal/auth/oauth/handleGetOAuthClient.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; import { db } from "@/db/initDrizzle.js"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { oauthClientRepo } from "../repos/index.js"; import { isAtmnOAuthClientRecord } from "./atmnOAuthClients.js"; import { @@ -7,7 +8,7 @@ import { isInternalMcpOAuthClientRecord, } from "./internalMcpOAuthClients.js"; -export const handleGetOAuthClient = async (c: Context) => { +export const handleGetOAuthClient = async (c: Context) => { const clientId = c.req.param("client_id"); const redirectUri = c.req.query("redirect_uri"); if (!clientId) { @@ -20,7 +21,10 @@ export const handleGetOAuthClient = async (c: Context) => { return c.json({ error: "Client not found" }, 404); } - const isInternalMcp = isInternalMcpOAuthClientRecord(client); + const isInternalMcp = isInternalMcpOAuthClientRecord({ + env: c.env, + ...client, + }); const internalMcpName = isInternalMcp ? getInternalMcpDisplayName({ metadata: client.metadata, @@ -31,7 +35,7 @@ export const handleGetOAuthClient = async (c: Context) => { return c.json({ client_id: client.clientId, name: internalMcpName || client.name || "Unknown Application", - is_atmn: isAtmnOAuthClientRecord(client), + is_atmn: isAtmnOAuthClientRecord({ env: c.env, ...client }), is_internal_mcp: isInternalMcp, }); }; diff --git a/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts b/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts index 5bff44cbb..80cde1ab4 100644 --- a/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts +++ b/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts @@ -1,7 +1,8 @@ import { AppEnv, RecaseError } from "@autumn/shared"; import type { Context } from "hono"; import { db } from "@/db/initDrizzle.js"; -import { auth } from "@/utils/auth.js"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { createAuth } from "@/utils/auth.js"; import { oauthConsentRepo } from "../repos/index.js"; import { isAtmnOAuthClientId } from "./atmnOAuthClients.js"; import { getOAuthConsentScopeGrant } from "./oauthConsentScopes.js"; @@ -120,7 +121,8 @@ const jsonOAuthError = ({ error }: { error: RecaseError }) => }, ); -export const handleOAuthConsentWithEnv = async (c: Context) => { +export const handleOAuthConsentWithEnv = async (c: Context) => { + const auth = createAuth(c.env); const { contentType, fields } = await parseRequestFields(c.req.raw.clone()); const clientId = getClientIdFromFields(fields); const redirectUri = getRedirectUriFromFields(fields); @@ -165,7 +167,11 @@ export const handleOAuthConsentWithEnv = async (c: Context) => { return response; } - if (!clientId || !env || (await isAtmnOAuthClientId({ db, clientId }))) { + if ( + !clientId || + !env || + (await isAtmnOAuthClientId({ db, env: c.env, clientId })) + ) { return response; } diff --git a/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts b/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts index 2e4ea2068..d0cbb7130 100644 --- a/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts +++ b/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts @@ -7,7 +7,8 @@ import { import { ErrCode, RecaseError } from "@autumn/shared"; import type { Context } from "hono"; import { db } from "@/db/initDrizzle.js"; -import { auth } from "@/utils/auth.js"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { createAuth } from "@/utils/auth.js"; import { oauthAccessTokenRepo, oauthRefreshTokenRepo } from "../repos/index.js"; import { isMcpOAuthClient } from "./mcpOAuthScopes.js"; import { @@ -108,7 +109,8 @@ const jsonTokenResponse = ({ headers: tokenResponseHeaders(response), }); -export const handleOAuthTokenWithApiKey = async (c: Context) => { +export const handleOAuthTokenWithApiKey = async (c: Context) => { + const auth = createAuth(c.env); const resource = await getResourceFromOAuthTokenRequest(c.req.raw.clone()); const response = await auth.handler(c.req.raw); if (!response.ok) return response; @@ -132,6 +134,7 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => { try { const tokenRecord = await getOAuthAccessTokenRecord({ db, + env: c.env, accessToken, resource, requestedScopes, @@ -185,6 +188,7 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => { } apiKeyResult = await getExternalOAuthApiKeyForToken({ db, + env: c.env, tokenRecord, requestedScopes, }); diff --git a/server/src/internal/auth/oauth/internalMcpOAuthClients.ts b/server/src/internal/auth/oauth/internalMcpOAuthClients.ts index d3ddfb083..d9a5a6462 100644 --- a/server/src/internal/auth/oauth/internalMcpOAuthClients.ts +++ b/server/src/internal/auth/oauth/internalMcpOAuthClients.ts @@ -1,11 +1,10 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { MCP_CLIENT_KIND } from "@autumn/auth/oauth"; import type { Context } from "hono"; import { type DrizzleCli, db } from "@/db/initDrizzle.js"; -import { auth } from "@/utils/auth.js"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { createAuth } from "@/utils/auth.js"; import { oauthClientRepo } from "../repos/index.js"; -const INTERNAL_MCP_CLIENT_ID = runtimeEnv.INTERNAL_MCP_OAUTH_CLIENT_ID; const INTERNAL_MCP_CLIENT_NAME = "Autumn internal-mcp"; const INTERNAL_MCP_CLIENT_NAME_NORMALIZED = INTERNAL_MCP_CLIENT_NAME.toLowerCase(); @@ -43,15 +42,20 @@ const inferClientNameFromRedirectUri = (redirectUri: string) => { }; export const isInternalMcpOAuthClientRecord = ({ + env, clientId, name, metadata, }: { + env: Env; clientId: string | null | undefined; name: string | null | undefined; metadata?: unknown; }) => { - if (INTERNAL_MCP_CLIENT_ID && clientId === INTERNAL_MCP_CLIENT_ID) + if ( + env.INTERNAL_MCP_OAUTH_CLIENT_ID && + clientId === env.INTERNAL_MCP_OAUTH_CLIENT_ID + ) return true; if (name?.trim().toLowerCase() === INTERNAL_MCP_CLIENT_NAME_NORMALIZED) { return true; @@ -79,20 +83,29 @@ export const getInternalMcpDisplayName = ({ export const isInternalMcpOAuthClientId = async ({ db, + env, clientId, }: { db: DrizzleCli; + env: Env; clientId: string; }) => { const client = await oauthClientRepo.getByClientId({ db, clientId }); - return isInternalMcpOAuthClientRecord(client ?? { clientId, name: null }); + return isInternalMcpOAuthClientRecord({ + env, + ...(client ?? { clientId, name: null }), + }); }; -export const handleInternalMcpOAuthAuthorize = async (c: Context) => { +export const handleInternalMcpOAuthAuthorize = async (c: Context) => { + const auth = createAuth(c.env); const url = new URL(c.req.raw.url); const clientId = url.searchParams.get("client_id"); - if (!clientId || !(await isInternalMcpOAuthClientId({ db, clientId }))) { + if ( + !clientId || + !(await isInternalMcpOAuthClientId({ db, env: c.env, clientId })) + ) { return auth.handler(c.req.raw); } diff --git a/server/src/internal/auth/oauth/oauthAccessTokenApiKey.ts b/server/src/internal/auth/oauth/oauthAccessTokenApiKey.ts index bfe1fbee5..35bd8641a 100644 --- a/server/src/internal/auth/oauth/oauthAccessTokenApiKey.ts +++ b/server/src/internal/auth/oauth/oauthAccessTokenApiKey.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { stripOAuthTokenPrefix } from "@autumn/auth"; import { AppEnv, @@ -19,21 +18,23 @@ import { oauthAccessTokenRepo, oauthConsentRepo } from "../repos/index.js"; import { isAtmnOAuthClientId } from "./atmnOAuthClients.js"; import { rotateOAuthConsentApiKey } from "./oauthConsentApiKey.js"; -const getOAuthIssuer = () => - `${runtimeEnv.BETTER_AUTH_URL?.replace(/\/$/, "") ?? ""}/api/auth`; +const getOAuthIssuer = (env: Env) => + `${env.BETTER_AUTH_URL?.replace(/\/$/, "") ?? ""}/api/auth`; const verifyResourceAccessToken = async ({ + env, accessToken, resource, requestedScopes, }: { + env: Env; accessToken: string; resource: string | null; requestedScopes: ScopeString[] | null; }) => { if (!resource) return null; - const issuer = getOAuthIssuer(); + const issuer = getOAuthIssuer(env); try { const payload = await verifyAccessToken(accessToken, { jwksUrl: `${issuer}/jwks`, @@ -52,11 +53,13 @@ const verifyResourceAccessToken = async ({ export const getOAuthAccessTokenRecord = async ({ db, + env, accessToken, resource, requestedScopes, }: { db: DrizzleCli; + env: Env; accessToken: string; resource: string | null; requestedScopes: ScopeString[] | null; @@ -67,6 +70,7 @@ export const getOAuthAccessTokenRecord = async ({ const tokenRecord = (await oauthAccessTokenRepo.getValidByTokenValues({ db, tokenValues })) ?? (await verifyResourceAccessToken({ + env, accessToken: rawAccessToken, resource, requestedScopes, @@ -120,10 +124,12 @@ export const getOAuthAccessTokenRecord = async ({ export const getExternalOAuthApiKeyForToken = async ({ db, + env, tokenRecord, requestedScopes, }: { db: DrizzleCli; + env: Env; tokenRecord: ResourceAccessTokenRecord & { userId: string; referenceId: string; @@ -132,6 +138,7 @@ export const getExternalOAuthApiKeyForToken = async ({ }) => { const isAtmnClient = await isAtmnOAuthClientId({ db, + env, clientId: tokenRecord.clientId, }); if (isAtmnClient) return null; @@ -151,19 +158,19 @@ export const getExternalOAuthApiKeyForToken = async ({ }); } - const env = consent.env ?? AppEnv.Sandbox; + const appEnv = consent.env ?? AppEnv.Sandbox; const scopes = requestedScopes ?? tokenRecord.scopes; const apiKey = await rotateOAuthConsentApiKey({ db, consent, tokenRecord, - env, + env: appEnv, scopes, }); return { apiKey, - env, + env: appEnv, orgId: tokenRecord.referenceId, userId: tokenRecord.userId, clientId: tokenRecord.clientId, diff --git a/server/src/internal/auth/oauth/oauthRouter.ts b/server/src/internal/auth/oauth/oauthRouter.ts index 5f8e956bc..018a2383c 100644 --- a/server/src/internal/auth/oauth/oauthRouter.ts +++ b/server/src/internal/auth/oauth/oauthRouter.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, @@ -6,7 +5,7 @@ import { import { type Context, Hono } from "hono"; import { rateLimiter } from "hono-rate-limiter"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -import { auth } from "@/utils/auth.js"; +import { createAuth } from "@/utils/auth.js"; import { handleGetOAuthClient } from "./handleGetOAuthClient.js"; import { handleMcpOAuthRegistration } from "./handleMcpOAuthRegistration.js"; import { handleOAuthConsentWithEnv } from "./handleOAuthConsentWithEnv.js"; @@ -23,24 +22,28 @@ const getClientLookupRateLimitKey = (c: Context) => const oauthClientLookupLimiter = rateLimiter({ windowMs: 60 * 1000, - limit: runtimeEnv.NODE_ENV === "development" ? 1000 : 60, + limit: (c) => (c.env.NODE_ENV === "development" ? 1000 : 60), standardHeaders: "draft-6", keyGenerator: getClientLookupRateLimitKey, }); oauthRouter.get("/api/auth/.well-known/openid-configuration", (c) => { + const auth = createAuth(c.env); return oauthProviderOpenIdConfigMetadata(auth)(c.req.raw); }); oauthRouter.get("/.well-known/oauth-authorization-server", (c) => { + const auth = createAuth(c.env); return oauthProviderAuthServerMetadata(auth)(c.req.raw); }); oauthRouter.get("/api/auth/.well-known/oauth-authorization-server", (c) => { + const auth = createAuth(c.env); return oauthProviderAuthServerMetadata(auth)(c.req.raw); }); oauthRouter.get("/.well-known/oauth-authorization-server/api/auth", (c) => { + const auth = createAuth(c.env); return oauthProviderAuthServerMetadata(auth)(c.req.raw); }); diff --git a/server/src/internal/balances/events/EventBatchingManager.ts b/server/src/internal/balances/events/EventBatchingManager.ts index 7c5f79408..08f385c2b 100644 --- a/server/src/internal/balances/events/EventBatchingManager.ts +++ b/server/src/internal/balances/events/EventBatchingManager.ts @@ -1,5 +1,5 @@ import type { EventInsert } from "@autumn/shared"; -import { logger } from "@server/external/logtail/logtailUtils.js"; +import { createLogger } from "@server/external/logtail/logtailUtils.js"; import { sendEventsToTinybird } from "@server/external/tinybird/sendEvents/sendEvents.js"; import { JobName } from "@server/queue/JobName.js"; import { addTaskToQueue } from "@server/queue/queueUtils.js"; @@ -11,13 +11,13 @@ class BatchingManager { private readonly maxBatchSize = 200; // Max events per batch (~200kb per event, keep batches under 10MB for Tinybird) /** Add an event to the batch */ - addEvent(event: EventInsert): void { + addEvent(event: EventInsert, env: Env): void { const key = event.id; this.events.set(key, event); // Auto-execute if batch size is reached if (this.events.size >= this.maxBatchSize) { - this.executeBatch(); + this.executeBatch(env); return; } @@ -27,12 +27,12 @@ class BatchingManager { } this.timer = setTimeout(() => { - this.executeBatch(); + this.executeBatch(env); }, this.batchWindow); } /** Execute the current batch - queue to SQS for Postgres and send to Tinybird */ - private async executeBatch(): Promise { + private async executeBatch(env: Env): Promise { if (this.timer) { clearTimeout(this.timer); this.timer = null; @@ -56,7 +56,7 @@ class BatchingManager { await sendEventsToTinybird({ events: eventItems, - logger, + logger: createLogger(env), }); } } diff --git a/server/src/internal/balances/finalizeLock/expireLock.ts b/server/src/internal/balances/finalizeLock/expireLock.ts index 431c20c42..1d3e6de7d 100644 --- a/server/src/internal/balances/finalizeLock/expireLock.ts +++ b/server/src/internal/balances/finalizeLock/expireLock.ts @@ -4,15 +4,18 @@ import type { ExpireLockReceiptPayload } from "@/queue/workflows.js"; import { runFinalizeLock } from "./runFinalizeLock.js"; export const expireLock = async ({ + workerEnv, ctx, payload, }: { + workerEnv: Env; ctx: AutumnContext; payload: ExpireLockReceiptPayload; }) => { try { ctx.skipCache = false; await runFinalizeLock({ + workerEnv, ctx, params: { lock_id: payload.lockId, diff --git a/server/src/internal/balances/finalizeLock/runFinalizeLock.ts b/server/src/internal/balances/finalizeLock/runFinalizeLock.ts index 067072f1b..29b74c1d1 100644 --- a/server/src/internal/balances/finalizeLock/runFinalizeLock.ts +++ b/server/src/internal/balances/finalizeLock/runFinalizeLock.ts @@ -13,6 +13,7 @@ import { runFinalizeLockV2 } from "./runFinalizeLockV2.js"; import { runRedisFinalizeLock } from "./runRedisFinalizeLock.js"; type RunFinalizeLockArgs = { + workerEnv: Env; ctx: AutumnContext; params: FinalizeLockParamsV0; }; @@ -35,7 +36,11 @@ export const runFinalizeLock = async (args: RunFinalizeLockArgs) => { }); }; -const runFinalizeLockInner = async ({ ctx, params }: RunFinalizeLockArgs) => { +const runFinalizeLockInner = async ({ + workerEnv, + ctx, + params, +}: RunFinalizeLockArgs) => { const fetchedReceipt = await fetchLockReceipt({ ctx, lockId: params.lock_id, @@ -43,6 +48,7 @@ const runFinalizeLockInner = async ({ ctx, params }: RunFinalizeLockArgs) => { if (fetchedReceipt.source === "redis_v2") { return runFinalizeLockV2({ + workerEnv, ctx, params, receipt: fetchedReceipt.receipt, @@ -64,8 +70,9 @@ const runFinalizeLockInner = async ({ ctx, params }: RunFinalizeLockArgs) => { try { if (notNullish(receipt.expires_at)) { await cancelLockExpiry({ + workerEnv, orgId: ctx.org.id, - env: ctx.env, + appEnv: ctx.env, hashedKey: Bun.hash(params.lock_id).toString(), }); } diff --git a/server/src/internal/balances/finalizeLock/runFinalizeLockV2.ts b/server/src/internal/balances/finalizeLock/runFinalizeLockV2.ts index bb9028d21..97009df06 100644 --- a/server/src/internal/balances/finalizeLock/runFinalizeLockV2.ts +++ b/server/src/internal/balances/finalizeLock/runFinalizeLockV2.ts @@ -20,6 +20,7 @@ import { runRedisFinalizeLockV2 } from "./runRedisFinalizeLockV2.js"; * marker key; `claimed === false` means another finalizer holds it. */ export const runFinalizeLockV2 = async ({ + workerEnv, ctx, params, receipt, @@ -27,6 +28,7 @@ export const runFinalizeLockV2 = async ({ claimed, lockRedisInstance, }: { + workerEnv: Env; ctx: AutumnContext; params: FinalizeLockParamsV0; receipt: LockReceipt; @@ -55,8 +57,9 @@ export const runFinalizeLockV2 = async ({ try { if (notNullish(receipt.expires_at)) { await cancelLockExpiry({ + workerEnv, orgId: ctx.org.id, - env: ctx.env, + appEnv: ctx.env, hashedKey: Bun.hash(params.lock_id).toString(), }); } diff --git a/server/src/internal/balances/handlers/handleFinalizeLock.ts b/server/src/internal/balances/handlers/handleFinalizeLock.ts index e59e64d17..f6046bcd8 100644 --- a/server/src/internal/balances/handlers/handleFinalizeLock.ts +++ b/server/src/internal/balances/handlers/handleFinalizeLock.ts @@ -9,7 +9,7 @@ export const handleFinalizeLock = createRoute({ const ctx = c.get("ctx"); const params = c.req.valid("json"); - const response = await runFinalizeLock({ ctx, params }); + const response = await runFinalizeLock({ workerEnv: c.env, ctx, params }); const status = ctx.extraLogs.finalizeLockFailedOpen ? 202 : 200; return c.json(response, status); diff --git a/server/src/internal/balances/track/runAsyncTrack.ts b/server/src/internal/balances/track/runAsyncTrack.ts index 888170a70..c711d0759 100644 --- a/server/src/internal/balances/track/runAsyncTrack.ts +++ b/server/src/internal/balances/track/runAsyncTrack.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { ErrCode, RecaseError, type TrackParams } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { queueTrack } from "./utils/queueTrack.js"; @@ -13,7 +12,7 @@ export const runAsyncTrack = async ({ ctx: AutumnContext; body: TrackParams; }): Promise => { - const queueUrl = runtimeEnv.TRACK_ASYNC_SQS_QUEUE_URL; + const queueUrl = ctx.env.TRACK_ASYNC_SQS_QUEUE_URL; if (!queueUrl) { ctx.logger.error( "[track] async=true requested but TRACK_ASYNC_SQS_QUEUE_URL is unset", diff --git a/server/src/internal/balances/track/runBatchTrack.ts b/server/src/internal/balances/track/runBatchTrack.ts index 81663a470..c59b77e6d 100644 --- a/server/src/internal/balances/track/runBatchTrack.ts +++ b/server/src/internal/balances/track/runBatchTrack.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { type BatchTrackParams, ErrCode, RecaseError } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { JobName } from "@/queue/JobName.js"; @@ -16,7 +15,7 @@ export const runBatchTrack = async ({ ctx: AutumnContext; body: BatchTrackParams; }): Promise => { - const queueUrl = runtimeEnv.TRACK_ASYNC_SQS_QUEUE_URL; + const queueUrl = ctx.env.TRACK_ASYNC_SQS_QUEUE_URL; if (!queueUrl) { ctx.logger.error( "[track] batch track requested but TRACK_ASYNC_SQS_QUEUE_URL is unset", diff --git a/server/src/internal/balances/track/utils/queueTrack.ts b/server/src/internal/balances/track/utils/queueTrack.ts index f397304d8..521996882 100644 --- a/server/src/internal/balances/track/utils/queueTrack.ts +++ b/server/src/internal/balances/track/utils/queueTrack.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { TrackParams } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { JobName } from "@/queue/JobName.js"; @@ -18,7 +17,7 @@ export const queueTrack = async ({ messageDeduplicationId?: string; }) => { try { - const resolvedQueueUrl = queueUrl ?? runtimeEnv.TRACK_SQS_QUEUE_URL; + const resolvedQueueUrl = queueUrl ?? ctx.env.TRACK_SQS_QUEUE_URL; if (!resolvedQueueUrl) { ctx.logger.warn( "[track] Redis unavailable and TRACK_SQS_QUEUE_URL is unset; falling back to synchronous track", diff --git a/server/src/internal/balances/utils/deduction/computeCreditCosts.ts b/server/src/internal/balances/utils/deduction/computeCreditCosts.ts index 998f12a97..b72023cf2 100644 --- a/server/src/internal/balances/utils/deduction/computeCreditCosts.ts +++ b/server/src/internal/balances/utils/deduction/computeCreditCosts.ts @@ -1,5 +1,5 @@ import type { FullCusEntWithFullCusProduct } from "@autumn/shared"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; import type { FeatureDeduction } from "../types/featureDeduction.js"; @@ -11,12 +11,14 @@ export type CreditCostLookup = (entitlementId: string) => number; export const computeCreditCosts = ({ cusEnts, deduction, + env }: { cusEnts: FullCusEntWithFullCusProduct[]; deduction: FeatureDeduction; + env: Env }): CreditCostLookup => { const costMap = new Map(); - + const logger = createLogger(env) for (const ce of cusEnts) { // Token cost is USD: 1:1 on its own ent; parents apply their ratio to it. if ( diff --git a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts index 88064edbe..41daf4887 100644 --- a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { type FullCusEntWithFullCusProduct, type FullSubject, @@ -52,6 +51,7 @@ export const executeRedisDeductionV2 = async ({ idempotencyKey, deductionOptions = {}, redisInstance, + workerEnv }: { ctx: AutumnContext; fullSubject: FullSubject; @@ -60,6 +60,7 @@ export const executeRedisDeductionV2 = async ({ idempotencyKey?: string | null; deductionOptions?: DeductionOptions; redisInstance?: Redis; + workerEnv: Env }): Promise<{ oldFullSubject: FullSubject; fullSubject: FullSubject; @@ -176,10 +177,10 @@ export const executeRedisDeductionV2 = async ({ const idempotencyRedisKey = idempotencyKey ? getRedisTrackFeatureIdempotencyKey({ - ctx, - customerId, - featureId: feature.id, - }).redisKey + ctx, + customerId, + featureId: feature.id, + }).redisKey : null; const { keys, balanceKeyIndexByFeatureId } = @@ -228,12 +229,12 @@ export const executeRedisDeductionV2 = async ({ idempotencyRedisKey !== null ? TRACK_V3_IDEMPOTENCY_TTL_MS : null, lock: preparedLock ? { - ...preparedLock, - region: currentRegion, - } + ...preparedLock, + region: currentRegion, + } : null, unwind_value: unwindValue ?? null, - debug: runtimeEnv.NODE_ENV !== "production", + debug: workerEnv.NODE_ENV !== "production", }; const targetRedis = redisInstance ?? ctx.redisV2; diff --git a/server/src/internal/balances/utils/lock/cancelLockExpiry.ts b/server/src/internal/balances/utils/lock/cancelLockExpiry.ts index 468f2d91d..eb60014ce 100644 --- a/server/src/internal/balances/utils/lock/cancelLockExpiry.ts +++ b/server/src/internal/balances/utils/lock/cancelLockExpiry.ts @@ -3,14 +3,20 @@ import { buildLockScheduleName } from "./buildLockScheduleName.js"; /** Cancels the EventBridge expiry schedule for a lock receipt. Safe to call even if no schedule exists. */ export const cancelLockExpiry = async ({ + workerEnv, orgId, - env, + appEnv, hashedKey, }: { + workerEnv: Env; orgId: string; - env: string; + appEnv: string; hashedKey: string; }) => { - const scheduleName = buildLockScheduleName({ orgId, env, hashedKey }); - await deleteSchedule({ scheduleName }); + const scheduleName = buildLockScheduleName({ + orgId, + env: appEnv, + hashedKey, + }); + await deleteSchedule({ env: workerEnv, scheduleName }); }; diff --git a/server/src/internal/balances/utils/refreshEntityAggregate/RefreshEntityAggregateBatchingManager.ts b/server/src/internal/balances/utils/refreshEntityAggregate/RefreshEntityAggregateBatchingManager.ts index 6fa4f597a..38383b137 100644 --- a/server/src/internal/balances/utils/refreshEntityAggregate/RefreshEntityAggregateBatchingManager.ts +++ b/server/src/internal/balances/utils/refreshEntityAggregate/RefreshEntityAggregateBatchingManager.ts @@ -1,5 +1,5 @@ import type { AppEnv } from "@autumn/shared"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { currentRegion } from "@/external/redis/initRedis.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; diff --git a/server/src/internal/balances/utils/refreshEntityAggregate/queueRefreshEntityAggregate.ts b/server/src/internal/balances/utils/refreshEntityAggregate/queueRefreshEntityAggregate.ts index fc887d6e8..5f106da08 100644 --- a/server/src/internal/balances/utils/refreshEntityAggregate/queueRefreshEntityAggregate.ts +++ b/server/src/internal/balances/utils/refreshEntityAggregate/queueRefreshEntityAggregate.ts @@ -1,9 +1,8 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { AppEnv } from "@autumn/shared"; import { JobName } from "@/queue/JobName.js"; -export const REFRESH_ENTITY_AGGREGATE_DEDUP_BUCKET_MS = - runtimeEnv.NODE_ENV === "development" ? 1000 : 5000; +export const REFRESH_ENTITY_AGGREGATE_DEDUP_BUCKET_MS = (env: Env) => + env.NODE_ENV === "development" ? 1000 : 5000; /** * Buffer added after the bucket boundary so the trailing enqueue fires *after* @@ -21,15 +20,18 @@ export const buildRefreshEntityAggregateDedupId = ({ env, customerId, nowMs, - bucketMs = REFRESH_ENTITY_AGGREGATE_DEDUP_BUCKET_MS, + workerEnv, + bucketMs, }: { orgId: string; env: AppEnv; customerId: string; nowMs: number; + workerEnv: Env, bucketMs?: number; }): string => { - const bucket = Math.floor(nowMs / bucketMs); + const _bucketMs = bucketMs ?? REFRESH_ENTITY_AGGREGATE_DEDUP_BUCKET_MS(workerEnv) + const bucket = Math.floor(nowMs / _bucketMs); const key = JSON.stringify({ jobName: JobName.RefreshEntityAggregate, orgId, diff --git a/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts b/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts index fd1385514..07362fb46 100644 --- a/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts +++ b/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts @@ -1,5 +1,5 @@ import type { AppEnv } from "@autumn/shared"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { currentRegion } from "@/external/redis/initRedis.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; diff --git a/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts b/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts index 120bbcb12..b8ff4ae06 100644 --- a/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts +++ b/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts @@ -1,5 +1,5 @@ import type { AppEnv } from "@autumn/shared"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { currentRegion } from "@/external/redis/initRedis.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; diff --git a/server/src/internal/billing/attach/handleAttach.ts b/server/src/internal/billing/attach/handleAttach.ts index eab671050..cde89cb33 100644 --- a/server/src/internal/billing/attach/handleAttach.ts +++ b/server/src/internal/billing/attach/handleAttach.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { type AttachResponseV1, AttachResponseV1Schema, @@ -25,24 +24,22 @@ export const handleAttach = createRoute({ body: AttachBodyV0Schema, resource: AffectedResource.Attach, - lock: - runtimeEnv.NODE_ENV !== "development" - ? { - ttlMs: 60000, - errorMessage: - "Attach already in progress for this customer, try again in a few seconds", + lock: { + ttlMs: 60000, + errorMessage: + "Attach already in progress for this customer, try again in a few seconds", - getKey: (c) => { - const ctx = c.get("ctx"); - const attachBody = c.req.valid("json"); - return buildBillingLockKey({ - orgId: ctx.org.id, - env: ctx.env, - customerId: attachBody.customer_id, - }); - }, - } - : undefined, + getKey: (c) => { + if (c.env.NODE_ENV === "development") return null; + const ctx = c.get("ctx"); + const attachBody = c.req.valid("json"); + return buildBillingLockKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: attachBody.customer_id, + }); + }, + }, handler: async (c) => { // await handleAttachRaceCondition({ req, res }); diff --git a/server/src/internal/billing/v2/handlers/handleAttachV2.ts b/server/src/internal/billing/v2/handlers/handleAttachV2.ts index 464c98e07..6425ab84e 100644 --- a/server/src/internal/billing/v2/handlers/handleAttachV2.ts +++ b/server/src/internal/billing/v2/handlers/handleAttachV2.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AffectedResource, ApiVersion, @@ -19,23 +18,21 @@ export const handleAttachV2 = createRoute({ [ApiVersion.V1_Beta]: AttachParamsV0Schema, }, resource: AffectedResource.Attach, - lock: - runtimeEnv.NODE_ENV !== "development" - ? { - ttlMs: 120000, - errorMessage: - "Attach already in progress for this customer, try again in a few seconds", - getKey: (c) => { - const ctx = c.get("ctx"); - const body = c.req.valid("json"); - return buildBillingLockKey({ - orgId: ctx.org.id, - env: ctx.env, - customerId: body.customer_id, - }); - }, - } - : undefined, + lock: { + ttlMs: 120000, + errorMessage: + "Attach already in progress for this customer, try again in a few seconds", + getKey: (c) => { + if (c.env.NODE_ENV === "development") return null; + const ctx = c.get("ctx"); + const body = c.req.valid("json"); + return buildBillingLockKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: body.customer_id, + }); + }, + }, handler: async (c) => { const ctx = c.get("ctx"); const body = c.req.valid("json"); diff --git a/server/src/internal/billing/v2/handlers/handleCreateSchedule.ts b/server/src/internal/billing/v2/handlers/handleCreateSchedule.ts index 517a929a3..c64bfd058 100644 --- a/server/src/internal/billing/v2/handlers/handleCreateSchedule.ts +++ b/server/src/internal/billing/v2/handlers/handleCreateSchedule.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { CreateScheduleParamsV0Schema, type CreateScheduleResponse, @@ -13,23 +12,21 @@ export const handleCreateSchedule = createRoute({ scopes: [Scopes.Billing.Write], body: CreateScheduleParamsV0Schema, - lock: - runtimeEnv.NODE_ENV !== "development" - ? { - ttlMs: 120000, - errorMessage: - "Create schedule already in progress for this customer, try again in a few seconds", - getKey: (c) => { - const ctx = c.get("ctx"); - const body = c.req.valid("json"); - return buildBillingLockKey({ - orgId: ctx.org.id, - env: ctx.env, - customerId: body.customer_id, - }); - }, - } - : undefined, + lock: { + ttlMs: 120000, + errorMessage: + "Create schedule already in progress for this customer, try again in a few seconds", + getKey: (c) => { + if (c.env.NODE_ENV === "development") return null; + const ctx = c.get("ctx"); + const body = c.req.valid("json"); + return buildBillingLockKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: body.customer_id, + }); + }, + }, handler: async (c) => { const response = (await billingActions.createSchedule({ ctx: c.get("ctx"), diff --git a/server/src/internal/billing/v2/handlers/handleMultiAttach.ts b/server/src/internal/billing/v2/handlers/handleMultiAttach.ts index 9d0dde249..6693d9dcd 100644 --- a/server/src/internal/billing/v2/handlers/handleMultiAttach.ts +++ b/server/src/internal/billing/v2/handlers/handleMultiAttach.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AffectedResource, InternalError, @@ -14,23 +13,21 @@ export const handleMultiAttach = createRoute({ scopes: [Scopes.Billing.Write], body: MultiAttachParamsV0Schema, resource: AffectedResource.MultiAttach, - lock: - runtimeEnv.NODE_ENV !== "development" - ? { - ttlMs: 120000, - errorMessage: - "Multi-attach already in progress for this customer, try again in a few seconds", - getKey: (c) => { - const ctx = c.get("ctx"); - const body = c.req.valid("json"); - return buildBillingLockKey({ - orgId: ctx.org.id, - env: ctx.env, - customerId: body.customer_id, - }); - }, - } - : undefined, + lock: { + ttlMs: 120000, + errorMessage: + "Multi-attach already in progress for this customer, try again in a few seconds", + getKey: (c) => { + if (c.env.NODE_ENV === "development") return null; + const ctx = c.get("ctx"); + const body = c.req.valid("json"); + return buildBillingLockKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: body.customer_id, + }); + }, + }, handler: async (c) => { const ctx = c.get("ctx"); const body = c.req.valid("json"); diff --git a/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts b/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts index d4ce36394..605b0b0c2 100644 --- a/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts +++ b/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AffectedResource, ApiVersion, @@ -19,23 +18,21 @@ export const handleUpdateSubscription = createRoute({ [ApiVersion.V1_Beta]: UpdateSubscriptionV0ParamsSchema, }, resource: AffectedResource.ApiSubscriptionUpdate, - lock: - runtimeEnv.NODE_ENV !== "development" - ? { - ttlMs: 120000, - errorMessage: - "Update subscription already in progress for this customer, try again in a few seconds", - getKey: (c) => { - const ctx = c.get("ctx"); - const attachBody = c.req.valid("json"); - return buildBillingLockKey({ - orgId: ctx.org.id, - env: ctx.env, - customerId: attachBody.customer_id, - }); - }, - } - : undefined, + lock: { + ttlMs: 120000, + errorMessage: + "Update subscription already in progress for this customer, try again in a few seconds", + getKey: (c) => { + if (c.env.NODE_ENV === "development") return null; + const ctx = c.get("ctx"); + const attachBody = c.req.valid("json"); + return buildBillingLockKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: attachBody.customer_id, + }); + }, + }, handler: async (c) => { const ctx = c.get("ctx"); const body = c.req.valid("json"); diff --git a/server/src/internal/billing/v2/providers/stripe/utils/connect/validateStripeSubscriptionActionOwnership.ts b/server/src/internal/billing/v2/providers/stripe/utils/connect/validateStripeSubscriptionActionOwnership.ts index cc35448e6..a4a67ea66 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/connect/validateStripeSubscriptionActionOwnership.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/connect/validateStripeSubscriptionActionOwnership.ts @@ -1,14 +1,19 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { BillingContext, StripeSubscriptionAction } from "@autumn/shared"; import { AppEnv, ErrCode, RecaseError } from "@autumn/shared"; import { stripeSubscriptionToApplication } from "@/external/stripe/subscriptions/utils/convertStripeSubscription"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { isStripeConnected } from "@/internal/orgs/orgUtils"; -const expectedStripeApplicationId = ({ ctx }: { ctx: AutumnContext }) => +const expectedStripeApplicationId = ({ + ctx, + env, +}: { + ctx: AutumnContext; + env: Env; +}) => ctx.env === AppEnv.Live - ? runtimeEnv.STRIPE_LIVE_CLIENT_ID - : runtimeEnv.STRIPE_SANDBOX_CLIENT_ID; + ? env.STRIPE_LIVE_CLIENT_ID + : env.STRIPE_SANDBOX_CLIENT_ID; const shouldValidateStripeApplicationOwnership = ({ ctx, @@ -30,10 +35,12 @@ export const validateStripeSubscriptionActionOwnership = ({ ctx, billingContext, stripeSubscriptionAction, + env, }: { ctx: AutumnContext; billingContext: BillingContext; stripeSubscriptionAction?: StripeSubscriptionAction; + env: Env; }) => { if (!shouldValidateStripeApplicationOwnership({ ctx })) return; @@ -49,7 +56,7 @@ export const validateStripeSubscriptionActionOwnership = ({ }); if (!applicationId) return; - const expectedApplicationId = expectedStripeApplicationId({ ctx }); + const expectedApplicationId = expectedStripeApplicationId({ ctx, env }); if (!expectedApplicationId) return; if (applicationId === expectedApplicationId) { diff --git a/server/src/internal/chat/ChatService.ts b/server/src/internal/chat/ChatService.ts index e4c466253..7079f60ad 100644 --- a/server/src/internal/chat/ChatService.ts +++ b/server/src/internal/chat/ChatService.ts @@ -42,9 +42,9 @@ export class ChatService { }); } - static createInstallUrl(ctx: AutumnContext, env = AppEnv.Live) { + static createInstallUrl(ctx: AutumnContext, workerEnv: Env, env = AppEnv.Live) { const state = createChatInstallState({ - secret: getChatStateSecret(), + secret: getChatStateSecret(workerEnv), provider: slackProvider, orgId: ctx.org.id, userId: ctx.userId ?? "", @@ -52,7 +52,7 @@ export class ChatService { expiresAt: addMinutes(Date.now(), 10).getTime(), nonce: randomUUID(), }); - const url = createSlackInstallUrl(state); + const url = createSlackInstallUrl(workerEnv, state); console.info("[chat] Created install URL", { provider: slackProvider, diff --git a/server/src/internal/chat/chatUtils.ts b/server/src/internal/chat/chatUtils.ts index 6eda7693f..54023df67 100644 --- a/server/src/internal/chat/chatUtils.ts +++ b/server/src/internal/chat/chatUtils.ts @@ -1,14 +1,15 @@ import { ErrCode, RecaseError } from "@autumn/shared"; -import { runtimeEnv } from "@/utils/envUtils.js"; export const slackProvider = "slack" as const; export const slackAdminProviderPrefix = "slack_admin" as const; export const getSlackAdminProvider = ({ - clientId = getRequiredChatEnv("SLACK_CLIENT_ID"), + env, + clientId = getRequiredChatEnv(env, "SLACK_CLIENT_ID"), }: { + env: Env; clientId?: string; -} = {}) => `${slackAdminProviderPrefix}:${clientId}` as const; +}) => `${slackAdminProviderPrefix}:${clientId}` as const; export const defaultSlackScopes = [ "app_mentions:read", @@ -32,9 +33,10 @@ export const getMissingSlackScopes = (scopes: string[]) => { }; export const getRequiredChatEnv = ( + env: Env, key: "SLACK_CLIENT_ID" | "ENCRYPTION_PASSWORD", ) => { - const value = runtimeEnv[key]; + const value = env[key]; if (value) return value; throw new RecaseError({ @@ -44,21 +46,21 @@ export const getRequiredChatEnv = ( }); }; -export const getChatStateSecret = () => - runtimeEnv.CHAT_STATE_SECRET ?? - runtimeEnv.SLACK_STATE_SECRET ?? - runtimeEnv.BETTER_AUTH_SECRET ?? - getRequiredChatEnv("ENCRYPTION_PASSWORD"); +export const getChatStateSecret = (env: Env) => + env.CHAT_STATE_SECRET ?? + env.SLACK_STATE_SECRET ?? + env.BETTER_AUTH_SECRET ?? + getRequiredChatEnv(env, "ENCRYPTION_PASSWORD"); -export const createSlackInstallUrl = (state: string) => { - const scope = runtimeEnv.SLACK_BOT_SCOPES ?? defaultSlackScopes.join(","); +export const createSlackInstallUrl = (env: Env, state: string) => { + const scope = env.SLACK_BOT_SCOPES ?? defaultSlackScopes.join(","); const params = new URLSearchParams({ - client_id: getRequiredChatEnv("SLACK_CLIENT_ID"), + client_id: getRequiredChatEnv(env, "SLACK_CLIENT_ID"), scope, state, }); - if (runtimeEnv.SLACK_REDIRECT_URI) { - params.set("redirect_uri", runtimeEnv.SLACK_REDIRECT_URI); + if (env.SLACK_REDIRECT_URI) { + params.set("redirect_uri", env.SLACK_REDIRECT_URI); } return `https://slack.com/oauth/v2/authorize?${params}`; }; diff --git a/server/src/internal/chat/handlers/handleCreateChatInstall.ts b/server/src/internal/chat/handlers/handleCreateChatInstall.ts index 91ad3f862..5163c71d1 100644 --- a/server/src/internal/chat/handlers/handleCreateChatInstall.ts +++ b/server/src/internal/chat/handlers/handleCreateChatInstall.ts @@ -14,7 +14,7 @@ export const handleCreateChatInstall = createRoute({ body: installBody, handler: async (c) => { const { env } = c.req.valid("json"); - const url = ChatService.createInstallUrl(c.get("ctx"), env); + const url = ChatService.createInstallUrl(c.get("ctx"), c.env, env); return c.json({ url }); }, diff --git a/server/src/internal/checkouts/handlers/handleConfirmCheckout.ts b/server/src/internal/checkouts/handlers/handleConfirmCheckout.ts index 8df4bff73..eaaba5397 100644 --- a/server/src/internal/checkouts/handlers/handleConfirmCheckout.ts +++ b/server/src/internal/checkouts/handlers/handleConfirmCheckout.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AffectedResource, type Checkout, @@ -24,23 +23,21 @@ export const handleConfirmCheckout = createRoute({ scopes: [Scopes.Public], resource: AffectedResource.Attach, body: ConfirmCheckoutParamsSchema, - lock: - runtimeEnv.NODE_ENV !== "development" - ? { - ttlMs: 120000, - errorMessage: - "Checkout confirmation already in progress for this customer, try again in a few seconds", - getKey: (c) => { - const ctx = c.get("ctx"); - const checkout = c.get("checkout") as Checkout; - return buildBillingLockKey({ - orgId: ctx.org.id, - env: ctx.env, - customerId: checkout.customer_id, - }); - }, - } - : undefined, + lock: { + ttlMs: 120000, + errorMessage: + "Checkout confirmation already in progress for this customer, try again in a few seconds", + getKey: (c) => { + if (c.env.NODE_ENV === "development") return null; + const ctx = c.get("ctx"); + const checkout = c.get("checkout") as Checkout; + return buildBillingLockKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: checkout.customer_id, + }); + }, + }, handler: async (c) => { const ctx = c.get("ctx"); const checkout = c.get("checkout") as Checkout; diff --git a/server/src/internal/customers/cancel/handleCancelV2.ts b/server/src/internal/customers/cancel/handleCancelV2.ts index 4a377a9b8..7213f6000 100644 --- a/server/src/internal/customers/cancel/handleCancelV2.ts +++ b/server/src/internal/customers/cancel/handleCancelV2.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { UpdateSubscriptionV1Params } from "@autumn/shared"; import { Scopes } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler"; @@ -16,23 +15,21 @@ import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumn export const handleCancelV2 = createRoute({ scopes: [Scopes.Billing.Write], - lock: - runtimeEnv.NODE_ENV !== "development" - ? { - ttlMs: 120000, - errorMessage: - "Cancel already in progress for this customer, try again in a few seconds", - getKey: (c) => { - const ctx = c.get("ctx"); - if (!ctx.customerId) return null; - return buildBillingLockKey({ - orgId: ctx.org.id, - env: ctx.env, - customerId: ctx.customerId, - }); - }, - } - : undefined, + lock: { + ttlMs: 120000, + errorMessage: + "Cancel already in progress for this customer, try again in a few seconds", + getKey: (c) => { + if (c.env.NODE_ENV === "development") return null; + const ctx = c.get("ctx"); + if (!ctx.customerId) return null; + return buildBillingLockKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: ctx.customerId, + }); + }, + }, handler: async (c) => { const ctx = c.get("ctx"); diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.ts index 8532200cd..7aa422b42 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { AppEnv } from "@autumn/shared"; import type { Redis } from "ioredis"; import { @@ -19,7 +18,13 @@ type CustomerToDelete = { customerId: string; }; -const isProductionNode = runtimeEnv.NODE_ENV === "production"; +let _isProductionNode: boolean | undefined; +const getIsProductionNode = (): boolean => { + if (_isProductionNode === undefined) { + _isProductionNode = process.env.NODE_ENV === "production"; + } + return _isProductionNode; +}; /** * Per org: all keys share `{orgId}` so Redis Cluster stays in one slot per pipeline. @@ -36,7 +41,7 @@ const deleteFullCustomerCacheRowsForOrg = async ({ let skipped = 0; let customersToProcess = orgCustomers; - if (!isProductionNode) { + if (!getIsProductionNode()) { const existsPipeline = regionalRedis.pipeline(); for (const customer of orgCustomers) { existsPipeline.exists( diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard.ts index c846430a2..50771a2b6 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard.ts @@ -1,4 +1,3 @@ -import { logger } from "@/external/logtail/logtailUtils.js"; import { redis } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; @@ -37,7 +36,7 @@ export const setTestFullCustomerCacheGuard = async ({ await redis.set(key, "1", "PX", ttlMs); return true; } catch (error) { - logger.error(`Failed to set test fullCustomer cache guard: ${error}`); + ctx.logger.error(`Failed to set test fullCustomer cache guard: ${error}`); return false; } }; @@ -61,7 +60,7 @@ export const removeTestFullCustomerCacheGuard = async ({ await redis.del(key); return true; } catch (error) { - logger.error(`Failed to remove test fullCustomer cache guard: ${error}`); + ctx.logger.error(`Failed to remove test fullCustomer cache guard: ${error}`); return false; } }; diff --git a/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts b/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts index b6836a175..e5be11a54 100644 --- a/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts +++ b/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts @@ -76,6 +76,7 @@ export const handleCreateOAuthApiKeys = createRoute({ const tokenRecord = await getOAuthAccessTokenRecord({ db, + env: c.env, accessToken, resource, requestedScopes, @@ -109,6 +110,7 @@ export const handleCreateOAuthApiKeys = createRoute({ const externalApiKey = await getExternalOAuthApiKeyForToken({ db, + env: c.env, tokenRecord, requestedScopes: apiKeyScopes, }); diff --git a/server/src/internal/dev/handlers/handleCreateSecretKey.ts b/server/src/internal/dev/handlers/handleCreateSecretKey.ts index 7ee0e3af9..276f9e9b1 100644 --- a/server/src/internal/dev/handlers/handleCreateSecretKey.ts +++ b/server/src/internal/dev/handlers/handleCreateSecretKey.ts @@ -6,7 +6,7 @@ import { Scopes, } from "@autumn/shared"; import { z } from "zod/v4"; -import { auth } from "@/utils/auth"; +import { createAuth } from "@/utils/auth"; import { captureOrgEvent } from "@/utils/posthog.js"; import { createRoute } from "../../../honoMiddlewares/routeHandler"; import { ApiKeyPrefix, createKey } from "../api-keys/apiKeyUtils"; @@ -40,6 +40,7 @@ export const handleCreateSecretKey = createRoute({ } // Get session to check for impersonation and author + const auth = createAuth(c.env); const session = await auth.api.getSession({ headers: c.req.raw.headers, }); diff --git a/server/src/internal/emails/constants.ts b/server/src/internal/emails/constants.ts index fa6c25ffc..449d49081 100644 --- a/server/src/internal/emails/constants.ts +++ b/server/src/internal/emails/constants.ts @@ -1,3 +1,4 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; -export const FROM_AUTUMN = `Autumn `; -export const FROM_AYUSH = `Ayush `; +export const getFromAddresses = (env: Env) => ({ + FROM_AUTUMN: `Autumn `, + FROM_AYUSH: `Ayush `, +}); diff --git a/server/src/internal/emails/sendInvitationEmail.ts b/server/src/internal/emails/sendInvitationEmail.ts index 839bb3692..cc753b406 100644 --- a/server/src/internal/emails/sendInvitationEmail.ts +++ b/server/src/internal/emails/sendInvitationEmail.ts @@ -1,35 +1,49 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { sendTextEmail } from "@/external/resend/resendUtils.js"; import { safeResend } from "@/external/resend/safeResend.js"; -import { FROM_AUTUMN } from "./constants.js"; +import { getFromAddresses } from "./constants.js"; -const getInvitationEmailBody = ({ orgName }: { orgName: string }) => { - return `Hey there! You've been invited to join ${orgName} on Autumn. +const getInvitationEmailBody = ({ + orgName, + env, +}: { + orgName: string; + env: Env; +}) => { + return `Hey there! You've been invited to join ${orgName} on Autumn. Click the link below to create an account / sign in to Autumn and accept the invitation. -${runtimeEnv.CLIENT_URL}/sign-in +${env.CLIENT_URL}/sign-in `; }; -export const sendInvitationEmail = safeResend({ - fn: async ({ - email, - orgName, - inviteLink, - }: { - email: string; - orgName: string; - inviteLink?: string; - }) => { - logger.info(`Sending invitation email to ${email}`); - await sendTextEmail({ - from: FROM_AUTUMN, - to: email, - subject: `Join ${orgName} on Autumn`, - body: getInvitationEmailBody({ orgName }), - }); - }, - action: "send org invitation email", -}); +export const sendInvitationEmail = ({ + email, + orgName, + inviteLink, + env, +}: { + email: string; + orgName: string; + inviteLink?: string; + env: Env; +}) => { + const logger = createLogger(env); + const { FROM_AUTUMN } = getFromAddresses(env); + + logger.info(`Sending invitation email to ${email}`); + return safeResend({ + env, + fn: async () => { + await sendTextEmail({ + env, + from: FROM_AUTUMN, + to: email, + subject: `Join ${orgName} on Autumn`, + body: getInvitationEmailBody({ orgName, env }), + }); + }, + action: "send org invitation email", + })(); +}; diff --git a/server/src/internal/emails/sendOTPEmail.ts b/server/src/internal/emails/sendOTPEmail.ts index 4f698278f..910567d86 100644 --- a/server/src/internal/emails/sendOTPEmail.ts +++ b/server/src/internal/emails/sendOTPEmail.ts @@ -1,20 +1,20 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { createResendCli } from "@/external/resend/resendUtils.js"; -import { FROM_AUTUMN } from "./constants.js"; +import { getFromAddresses } from "./constants.js"; import OTPEmail from "./OTPEmail.js"; -const sendOTPEmail = async ({ email, otp }: { email: string; otp: string }) => { - if (!runtimeEnv.RESEND_API_KEY || !runtimeEnv.RESEND_DOMAIN) { +const sendOTPEmail = async ({ email, otp, env }: { email: string; otp: string; env: Env }) => { + const logger = createLogger(env); + if (!env.RESEND_API_KEY || !env.RESEND_DOMAIN) { logger.warn(`RESEND NOT SET UP, SIGN IN OTP: ${otp}`); return; } try { logger.info(`Sending OTP email to ${email}`); - const resend = createResendCli(); + const resend = createResendCli(env); const { error } = await resend.emails.send({ - from: FROM_AUTUMN, + from: getFromAddresses(env).FROM_AUTUMN, to: email, subject: "Your verification code for Autumn", react: OTPEmail({ otpCode: otp }), diff --git a/server/src/internal/emails/sendOnboardingEmail.ts b/server/src/internal/emails/sendOnboardingEmail.ts index 5a4c55892..538ecbae4 100644 --- a/server/src/internal/emails/sendOnboardingEmail.ts +++ b/server/src/internal/emails/sendOnboardingEmail.ts @@ -1,6 +1,6 @@ import { sendHtmlEmail } from "@/external/resend/resendUtils.js"; import { safeResend } from "@/external/resend/safeResend.js"; -import { FROM_AYUSH } from "./constants.js"; +import { getFromAddresses } from "./constants.js"; const getWelcomeEmailBody = (userFirstName: string) => { return ` @@ -19,17 +19,28 @@ Co-founder, Autumn

`; }; -export const sendOnboardingEmail = safeResend({ - fn: async ({ name, email }: { name: string; email: string }) => { +export const sendOnboardingEmail = ({ + env, + name, + email, +}: { + env: Env; + name: string; + email: string; +}) => + safeResend({ + env, + fn: async () => { const firstName = name.split(" ")[0]; await sendHtmlEmail({ - from: FROM_AYUSH, + env, + from: getFromAddresses(env).FROM_AYUSH, to: email, subject: "Anything I can help with?", body: getWelcomeEmailBody(firstName), replyTo: "ayush@useautumn.com", }); - }, - action: "send onboarding email", -}); + }, + action: "send onboarding email", + })(); diff --git a/server/src/internal/invoices/InvoiceService.ts b/server/src/internal/invoices/InvoiceService.ts index a94473a50..9c3d9457f 100644 --- a/server/src/internal/invoices/InvoiceService.ts +++ b/server/src/internal/invoices/InvoiceService.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { type ApiInvoiceV1, type Customer, @@ -26,10 +25,12 @@ export const processInvoice = ({ invoice, withItems = false, features, + env, }: { invoice: Invoice; withItems?: boolean; features?: Feature[]; + env: Env; }): ApiInvoiceV1 => { const processorType = invoice.processor_type ?? ProcessorType.Stripe; const isStripe = processorType === ProcessorType.Stripe; @@ -44,7 +45,7 @@ export const processInvoice = ({ currency: invoice.currency, created_at: invoice.created_at, hosted_invoice_url: isStripe - ? `${runtimeEnv.BETTER_AUTH_URL}/invoices/hosted_invoice_url/${invoice.id}` + ? `${env.BETTER_AUTH_URL}/invoices/hosted_invoice_url/${invoice.id}` : null, // hosted_invoice_url: invoice.hosted_invoice_url, // items: withItems diff --git a/server/src/internal/migrations/v2/handlers/handleRunMigration.ts b/server/src/internal/migrations/v2/handlers/handleRunMigration.ts index 5a78e52cd..5fe15a781 100644 --- a/server/src/internal/migrations/v2/handlers/handleRunMigration.ts +++ b/server/src/internal/migrations/v2/handlers/handleRunMigration.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; import { auth } from "@trigger.dev/sdk/v3"; import { z } from "zod/v4"; @@ -73,7 +72,7 @@ export const handleRunMigration = createRoute({ }); } - const isDev = runtimeEnv.NODE_ENV === "development"; + const isDev = c.env.NODE_ENV === "development"; const { migrationRunId, triggerRunId } = await withMigrationRunClaim({ ctx, migration, diff --git a/server/src/internal/migrations/v2/run/migrateCustomer/logs/logMigrateCustomerResult.ts b/server/src/internal/migrations/v2/run/migrateCustomer/logs/logMigrateCustomerResult.ts index d7d0e3076..99fa86d70 100644 --- a/server/src/internal/migrations/v2/run/migrateCustomer/logs/logMigrateCustomerResult.ts +++ b/server/src/internal/migrations/v2/run/migrateCustomer/logs/logMigrateCustomerResult.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import chalk from "chalk"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { addExtrasToLogs } from "@/utils/logging/addContextToLogs.js"; @@ -23,9 +22,11 @@ const getMigrationCustomerExtras = ({ export const logMigrateCustomerResult = ({ ctx, + env, result, }: { ctx: AutumnContext; + env: Env; result: MigrateCustomerLogResult; }) => { const durationMs = Date.now() - ctx.timestamp; @@ -63,7 +64,7 @@ export const logMigrateCustomerResult = ({ if ( Object.keys(ctx.extraLogs).length > 0 && - runtimeEnv.NODE_ENV === "development" + env.NODE_ENV === "development" ) { const maskedLogs = maskExtraLogs(ctx.extraLogs); ctx.logger.debug(`EXTRA LOGS: ${JSON.stringify(maskedLogs, null, 2)}`); diff --git a/server/src/internal/migrations/v2/run/migrateCustomer/migrateCustomer.ts b/server/src/internal/migrations/v2/run/migrateCustomer/migrateCustomer.ts index 7f9267cd2..f1f677243 100644 --- a/server/src/internal/migrations/v2/run/migrateCustomer/migrateCustomer.ts +++ b/server/src/internal/migrations/v2/run/migrateCustomer/migrateCustomer.ts @@ -26,12 +26,14 @@ export type MigrateCustomerResult = { /** Top-level per-customer migration runner. Preview evaluates without writes. */ export const migrateCustomer = async ({ ctx, + env, customerId, migration, preview = false, hooks, }: { ctx: AutumnContext; + env: Env; customerId: string; migration: MigrationRuntime; preview?: boolean; diff --git a/server/src/internal/misc/cacheV2Ramp/cacheV2RampClient.ts b/server/src/internal/misc/cacheV2Ramp/cacheV2RampClient.ts index 0bdc1dab7..54fcea592 100644 --- a/server/src/internal/misc/cacheV2Ramp/cacheV2RampClient.ts +++ b/server/src/internal/misc/cacheV2Ramp/cacheV2RampClient.ts @@ -1,5 +1,5 @@ import type { Redis } from "ioredis"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { getReachableDragonflyUrl } from "@/external/redis/getReachableDragonflyUrl.js"; import { createRedisConnection, @@ -24,10 +24,11 @@ let lastDecryptFailureKey: string | null = null; * connection string (e.g. credential rotation on the same host), the next * call disconnects the old client and creates a new one. Returns null when * no destination is configured (ramp is dormant). */ -export const getRampDestinationRedis = (): Redis | null => { +export const getRampDestinationRedis = (env: Env): Redis | null => { + const logger = createLogger(env); const config = getCacheV2RampConfig(); if (!config) { - closeRampDestinationClient(); + closeRampDestinationClient(env); return null; } @@ -95,7 +96,8 @@ export const getRampDestinationRedis = (): Redis | null => { }; /** Tear down the cached destination client. Safe to call multiple times. */ -export const closeRampDestinationClient = () => { +export const closeRampDestinationClient = (env: Env) => { + const logger = createLogger(env); if (!cached) return; try { cached.instance.disconnect(); diff --git a/server/src/internal/misc/edgeConfig/edgeConfigStore.ts b/server/src/internal/misc/edgeConfig/edgeConfigStore.ts index 4146f5889..7f4a3e83d 100644 --- a/server/src/internal/misc/edgeConfig/edgeConfigStore.ts +++ b/server/src/internal/misc/edgeConfig/edgeConfigStore.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { ErrCode, ms } from "@autumn/shared"; import type { S3Client } from "@aws-sdk/client-s3"; import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; @@ -24,14 +23,16 @@ const nowIso = () => new Date().toISOString(); * Fail-open: any S3 error resets the in-memory config to `defaultValue()`. */ export const createEdgeConfigStore = ({ + env, s3Key, schema, defaultValue, - pollIntervalMs = runtimeEnv.NODE_ENV === "development" + pollIntervalMs = env.NODE_ENV === "development" ? ms.seconds(1) : ms.seconds(10), s3Client: injectedS3Client, }: { + env: Env; s3Key: string; schema: z.ZodType; defaultValue: () => T; @@ -47,7 +48,7 @@ export const createEdgeConfigStore = ({ let pollTimer: ReturnType | null = null; const getConfigLocation = () => { - const { bucket, region } = getAdminS3Config(); + const { bucket, region } = getAdminS3Config(env); return { bucket, region, diff --git a/server/src/internal/misc/feedback/handleSubmitFeedback.ts b/server/src/internal/misc/feedback/handleSubmitFeedback.ts index 36d5ace3c..709d3fcac 100644 --- a/server/src/internal/misc/feedback/handleSubmitFeedback.ts +++ b/server/src/internal/misc/feedback/handleSubmitFeedback.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { z } from "zod/v4"; import { Scopes } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; @@ -17,7 +16,7 @@ export const handleSubmitFeedback = createRoute({ const userEmail = ctx.user?.email ?? "Unknown user"; const orgSlug = ctx.org?.slug ?? "Unknown org"; - const webhookUrl = runtimeEnv.DISCORD_FEEDBACK_WEBHOOK; + const webhookUrl = c.env.DISCORD_FEEDBACK_WEBHOOK; if (!webhookUrl) { console.warn("DISCORD_FEEDBACK_WEBHOOK not configured"); return c.json({ success: true }); diff --git a/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts b/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts index f2cb593dd..a9221590b 100644 --- a/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts +++ b/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { createAnthropic } from "@ai-sdk/anthropic"; import { type AgentPricingConfig, InternalError } from "@autumn/shared"; import { withTracing } from "@posthog/ai"; @@ -12,13 +11,13 @@ import { OrganisationConfigurationSchema } from "./pricingAgentSchemas.js"; // PostHog client singleton let phClient: PostHog | null = null; -const getPostHogClient = (): PostHog | null => { - if (!runtimeEnv.POSTHOG_API_KEY) { +const getPostHogClient = (env: Env): PostHog | null => { + if (!env.POSTHOG_API_KEY) { return null; } if (!phClient) { - phClient = new PostHog(runtimeEnv.POSTHOG_API_KEY, { - host: runtimeEnv.POSTHOG_HOST || "https://us.i.posthog.com", + phClient = new PostHog(env.POSTHOG_API_KEY, { + host: env.POSTHOG_HOST || "https://us.i.posthog.com", }); } return phClient; @@ -123,7 +122,7 @@ pricingAgentRouter.post("/chat", async (c) => { } = await c.req.json(); const ctx = c.var.ctx; - if (!runtimeEnv.ANTHROPIC_API_KEY) { + if (!c.env.ANTHROPIC_API_KEY) { throw new InternalError({ message: "ANTHROPIC_API_KEY not configured", code: "anthropic_not_configured", @@ -151,11 +150,11 @@ When the user asks to make changes, modify this existing configuration rather th // Create Anthropic client and optionally wrap with PostHog tracing const anthropicClient = createAnthropic({ - apiKey: runtimeEnv.ANTHROPIC_API_KEY, + apiKey: c.env.ANTHROPIC_API_KEY, }); const baseModel = anthropicClient("claude-opus-4-5"); - const posthog = getPostHogClient(); + const posthog = getPostHogClient(c.env); const distinctId = ctx.userId || ctx.org?.id || "anonymous"; const model = posthog diff --git a/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts b/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts index d4e3865d1..ee684d415 100644 --- a/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts +++ b/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { ApiVersion, ApiVersionClass } from "@autumn/shared"; import type { Context } from "hono"; import { matchRoute } from "../../../honoMiddlewares/middlewareUtils"; @@ -225,7 +224,7 @@ export const resolveRateLimit = ({ export const RATE_LIMIT_CONFIGS: Record = { [RateLimitType.General]: { name: "general", - limit: runtimeEnv.NODE_ENV === "development" ? 1000 : 25, + limit: 25, windowMs: 1000, notInRedis: false, scope: RateLimitScope.Org, diff --git a/server/src/internal/misc/rateLimiter/rateLimitFactory.ts b/server/src/internal/misc/rateLimiter/rateLimitFactory.ts index 34feef182..7f6376158 100644 --- a/server/src/internal/misc/rateLimiter/rateLimitFactory.ts +++ b/server/src/internal/misc/rateLimiter/rateLimitFactory.ts @@ -1,7 +1,7 @@ import type { ApiVersion } from "@autumn/shared"; import type { Context, Next } from "hono"; import { rateLimiter } from "hono-rate-limiter"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { shouldUseRedis } from "@/external/redis/initRedis"; import type { HonoEnv } from "@/honoUtils/HonoEnv"; import { @@ -29,7 +29,8 @@ export const setRateLimitKeyInContext = (c: Context, key: string): void => { const RATE_LIMIT_WARNING_INTERVAL_MS = 30_000; let lastRateLimitBypassWarningAt = 0; -const warnRateLimitBypass = () => { +const warnRateLimitBypass = (env: Env) => { + const logger = createLogger(env); const now = Date.now(); if (now - lastRateLimitBypassWarningAt < RATE_LIMIT_WARNING_INTERVAL_MS) return; @@ -44,12 +45,15 @@ const CAP_EXCEEDED_WARNING_INTERVAL_MS = 10_000; const lastCapWarnAtByType = new Map(); const warnOrgCapExceeded = ({ + env, limitType, orgSlug, }: { + env: Env; limitType: string; orgSlug?: string; }) => { + const logger = createLogger(env); const now = Date.now(); const lastWarnAt = lastCapWarnAtByType.get(limitType) ?? 0; if (now - lastWarnAt < CAP_EXCEEDED_WARNING_INTERVAL_MS) return; @@ -90,7 +94,7 @@ export const rateLimitFactory = ({ ): Promise => { const honoContext = c as Context; const ctx = honoContext.get("ctx"); - warnOrgCapExceeded({ limitType: type, orgSlug: ctx?.org?.slug }); + warnOrgCapExceeded({ env: honoContext.env, limitType: type, orgSlug: ctx?.org?.slug }); if (type === RateLimitType.CheckOrg && !isCheckFailOpenRoute(honoContext)) { return c.json( @@ -146,7 +150,7 @@ export const rateLimitFactory = ({ } if (!shouldUseRedis()) { - warnRateLimitBypass(); + warnRateLimitBypass(c.env as Env); return notInRedis ? getInMemoryLimiter()(c, next) : next(); } diff --git a/server/src/internal/misc/trmnl/trmnlRouter.ts b/server/src/internal/misc/trmnl/trmnlRouter.ts index 97360ad33..4e5e6fa93 100644 --- a/server/src/internal/misc/trmnl/trmnlRouter.ts +++ b/server/src/internal/misc/trmnl/trmnlRouter.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { Hono } from "hono"; import { rateLimiter } from "hono-rate-limiter"; import { traceEnrichMiddleware } from "@/honoMiddlewares/traceMiddleware.js"; @@ -11,7 +10,7 @@ import { trmnlAuthMiddleware } from "./trmnlAuthMiddleware.js"; // TRMNL rate limiter: 10 requests per 30 minutes in production, 1000 in dev const trmnlScreenLimiter = rateLimiter({ windowMs: 60 * 1000 * 30, // 30 minutes - limit: runtimeEnv.NODE_ENV === "development" ? 1000 : 10, + limit: (c) => (c.env.NODE_ENV === "development" ? 1000 : 10), standardHeaders: "draft-6", keyGenerator: (c) => c.req.header("x-trmnl-id") ?? "unknown", }); diff --git a/server/src/internal/orgs/handlers/handleGetUploadUrl.ts b/server/src/internal/orgs/handlers/handleGetUploadUrl.ts index a71efc9e3..5bfe8d351 100644 --- a/server/src/internal/orgs/handlers/handleGetUploadUrl.ts +++ b/server/src/internal/orgs/handlers/handleGetUploadUrl.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { ErrCode, Scopes } from "@autumn/shared"; import { getUploadUrl } from "@/external/supabase/storageUtils.js"; import RecaseError from "@/utils/errorUtils.js"; @@ -12,7 +11,7 @@ export const handleGetUploadUrl = createRoute({ const path = `logo/${org.id}`; - if (!runtimeEnv.SUPABASE_URL || !runtimeEnv.SUPABASE_SERVICE_KEY) { + if (!c.env.SUPABASE_URL || !c.env.SUPABASE_SERVICE_KEY) { throw new RecaseError({ message: "Supabase storage not set up", code: ErrCode.SupabaseNotFound, @@ -20,7 +19,7 @@ export const handleGetUploadUrl = createRoute({ }); } - const data = await getUploadUrl({ path }); + const data = await getUploadUrl({ env: c.env, path }); return c.json(data); }, diff --git a/server/src/internal/orgs/handlers/handleResetDefaultAccount.ts b/server/src/internal/orgs/handlers/handleResetDefaultAccount.ts index f994d3865..15c28db00 100644 --- a/server/src/internal/orgs/handlers/handleResetDefaultAccount.ts +++ b/server/src/internal/orgs/handlers/handleResetDefaultAccount.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AppEnv, ErrCode, Scopes } from "@autumn/shared"; import { initMasterStripe } from "@/external/connect/initStripeCli.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; @@ -13,7 +12,7 @@ export const handleResetDefaultAccount = createRoute({ const { db, org, logger, env } = ctx; // Validation: Only allow for test org - if (org.id !== runtimeEnv.TESTS_ORG_ID) { + if (org.id !== c.env.TESTS_ORG_ID) { throw new RecaseError({ message: "This endpoint can only be used for test organizations", code: ErrCode.InvalidRequest, diff --git a/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts b/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts index 9b0ca1a62..6d4b45da9 100644 --- a/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts +++ b/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AppEnv, ErrCode, RecaseError, Scopes } from "@autumn/shared"; import { z } from "zod/v4"; import { @@ -21,8 +20,8 @@ export const handleGetRevenueCatOAuthUrl = createRoute({ const { org, env } = ctx; if ( - !runtimeEnv.REVENUECAT_OAUTH_CLIENT_ID || - !runtimeEnv.REVENUECAT_OAUTH_CLIENT_SECRET + !c.env.REVENUECAT_OAUTH_CLIENT_ID || + !c.env.REVENUECAT_OAUTH_CLIENT_SECRET ) { throw new RecaseError({ message: "RevenueCat OAuth client credentials not configured", @@ -31,7 +30,7 @@ export const handleGetRevenueCatOAuthUrl = createRoute({ }); } - const frontendUrl = runtimeEnv.CLIENT_URL || "http://localhost:5173"; + const frontendUrl = c.env.CLIENT_URL || "http://localhost:5173"; const envPrefix = env === AppEnv.Sandbox ? "/sandbox" : ""; const redirectUri = redirect_url || `${frontendUrl}${envPrefix}/dev?tab=revenuecat`; diff --git a/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts index 0922548ae..57b989133 100644 --- a/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts +++ b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AppEnv, type Organization, @@ -76,7 +75,7 @@ export const handleRevenueCatOAuthCallback = async (c: Context) => { const { db } = initDrizzle(); - const frontendUrl = runtimeEnv.CLIENT_URL || "http://localhost:3000"; + const frontendUrl = c.env.CLIENT_URL || "http://localhost:3000"; let redirectUrl = new URL(`${frontendUrl}`); redirectUrl.searchParams.set("tab", "revenuecat"); let isPlatformFlow = false; diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleDeleteStripe.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleDeleteStripe.ts index a85a5f689..e33e5260d 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleDeleteStripe.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleDeleteStripe.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AppEnv, type Organization, @@ -19,10 +18,12 @@ const disconnectStripe = async ({ org, env, logger, + workerEnv, }: { org: Organization; env: AppEnv; logger: Logger; + workerEnv: Env; }) => { if (isStripeConnected({ org, env, throughSecretKey: true })) { const stripeCli = createStripeCli({ org, env, throughSecretKey: true }); @@ -45,8 +46,8 @@ const disconnectStripe = async ({ await masterStripe.oauth.deauthorize({ client_id: env === AppEnv.Live - ? runtimeEnv.STRIPE_LIVE_CLIENT_ID || "" - : runtimeEnv.STRIPE_SANDBOX_CLIENT_ID || "", + ? workerEnv.STRIPE_LIVE_CLIENT_ID || "" + : workerEnv.STRIPE_SANDBOX_CLIENT_ID || "", stripe_user_id: accountId, }); } catch (error) { @@ -99,7 +100,7 @@ export const handleDeleteStripe = createRoute({ }); try { - await disconnectStripe({ org, env, logger }); + await disconnectStripe({ org, env, logger, workerEnv: c.env }); } catch (error) { logger.error(`Failed to disconnect stripe for ${org.id}, ${org.slug}`, { error, diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts index fc437f35f..3d6b5f2c2 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AppEnv, ErrCode, RecaseError, Scopes } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; @@ -16,8 +15,8 @@ export const handleGetOAuthUrl = createRoute({ const clientId = env === AppEnv.Live - ? runtimeEnv.STRIPE_LIVE_CLIENT_ID - : runtimeEnv.STRIPE_SANDBOX_CLIENT_ID; + ? c.env.STRIPE_LIVE_CLIENT_ID + : c.env.STRIPE_SANDBOX_CLIENT_ID; if (!clientId) { throw new RecaseError({ @@ -28,7 +27,7 @@ export const handleGetOAuthUrl = createRoute({ } // Generate OAuth state and store in Redis - const frontendUrl = runtimeEnv.CLIENT_URL || "http://localhost:5173"; + const frontendUrl = c.env.CLIENT_URL || "http://localhost:5173"; const redirectUri = redirect_url || `${frontendUrl}/dev?tab=stripe`; @@ -43,13 +42,13 @@ export const handleGetOAuthUrl = createRoute({ `https://connect.stripe.com/oauth/v2/authorize?response_type=code&client_id=${clientId}&scope=read_write`, ); - let serverUrl = runtimeEnv.BETTER_AUTH_URL; + let serverUrl = c.env.BETTER_AUTH_URL; if (env === AppEnv.Live && serverUrl?.includes("localhost")) { serverUrl = `https://express.dev.useautumn.com`; } - if (runtimeEnv.NGROK_URL) { - serverUrl = runtimeEnv.NGROK_URL; + if (c.env.NGROK_URL) { + serverUrl = c.env.NGROK_URL; } // Add state + redirect_uri diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts index 48700681c..2163dd2f1 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AppEnv } from "@autumn/shared"; import type { Context } from "hono"; import { initDrizzle } from "@/db/initDrizzle.js"; @@ -20,7 +19,7 @@ export const handleOAuthCallback = async (c: Context) => { const { db } = initDrizzle(); // Build frontend redirect URL (default) - const frontendUrl = runtimeEnv.CLIENT_URL || "http://localhost:3000"; + const frontendUrl = c.env.CLIENT_URL || "http://localhost:3000"; let redirectUrl = new URL(`${frontendUrl}`); redirectUrl.searchParams.set("tab", "stripe"); diff --git a/server/src/internal/orgs/orgUtils/handleStripeSecretKey.ts b/server/src/internal/orgs/orgUtils/handleStripeSecretKey.ts index 522b8e3db..4c4c9fef8 100644 --- a/server/src/internal/orgs/orgUtils/handleStripeSecretKey.ts +++ b/server/src/internal/orgs/orgUtils/handleStripeSecretKey.ts @@ -1,6 +1,6 @@ import { AppEnv } from "@autumn/shared"; import Stripe from "stripe"; -import { logger } from "@/external/logtail/logtailUtils"; +import { createLogger } from "@/external/logtail/logtailUtils"; import { checkKeyValid, createWebhookEndpoint, @@ -16,6 +16,8 @@ export const handleStripeSecretKey = async ({ secretKey: string; env: AppEnv; }) => { + const logger = createLogger(env as unknown as Env); + // 1. Check if key is valid await checkKeyValid(secretKey); const stripe = new Stripe(secretKey); diff --git a/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts b/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts index 3f1607465..eac08e116 100644 --- a/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts +++ b/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AppEnv, ErrCode, RecaseError, Scopes } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; @@ -45,8 +44,8 @@ export const handleGetPlatformOAuth = createRoute({ // Get appropriate Stripe client ID based on environment const clientId = env === "live" - ? runtimeEnv.STRIPE_LIVE_CLIENT_ID - : runtimeEnv.STRIPE_SANDBOX_CLIENT_ID; + ? c.env.STRIPE_LIVE_CLIENT_ID + : c.env.STRIPE_SANDBOX_CLIENT_ID; if (!clientId) { throw new RecaseError({ @@ -64,7 +63,7 @@ export const handleGetPlatformOAuth = createRoute({ oauthUrl.searchParams.set("state", stateKey); oauthUrl.searchParams.set( "redirect_uri", - `${runtimeEnv.BETTER_AUTH_URL || "https://express.dev.useautumn.com"}/stripe/oauth_callback`, + `${c.env.BETTER_AUTH_URL || "https://express.dev.useautumn.com"}/stripe/oauth_callback`, ); logger.info(`Generated OAuth URL for platform org ${org.slug} (${env})`); diff --git a/server/src/internal/platform/platformBeta/handlers/handleUpdateOrganizationStripe.ts b/server/src/internal/platform/platformBeta/handlers/handleUpdateOrganizationStripe.ts index ecc089172..e5aac2424 100644 --- a/server/src/internal/platform/platformBeta/handlers/handleUpdateOrganizationStripe.ts +++ b/server/src/internal/platform/platformBeta/handlers/handleUpdateOrganizationStripe.ts @@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"; import { z } from "zod/v4"; import { initPlatformStripe } from "@/external/connect/initStripeCli.js"; import { registerConnectWebhook } from "@/external/connect/registerConnectWebhook.js"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js"; import { validatePlatformOrg } from "../utils/validatePlatformOrg.js"; @@ -24,18 +24,23 @@ const UpdateOrganizationStripeSchema = z */ const validateAndUpdateStripeAccount = async ({ accountId, + appEnv, env, masterOrg, org, }: { accountId: string; - env: AppEnv; + appEnv: AppEnv; + env: Env; masterOrg: Organization; org: Organization; }) => { + const logger = createLogger(env); + const stripeCli = initPlatformStripe({ masterOrg, env, + appEnv, accountId, }); @@ -44,7 +49,7 @@ const validateAndUpdateStripeAccount = async ({ // Update the organization's Stripe Connect configuration const currentConnect = - env === AppEnv.Sandbox ? org.test_stripe_connect : org.live_stripe_connect; + appEnv === AppEnv.Sandbox ? org.test_stripe_connect : org.live_stripe_connect; return { ...currentConnect, @@ -86,7 +91,8 @@ export const handleUpdateOrganizationStripe = createRoute({ if (test_account_id) { updates.test_stripe_connect = await validateAndUpdateStripeAccount({ accountId: test_account_id, - env: AppEnv.Sandbox, + appEnv: AppEnv.Sandbox, + env: c.env, masterOrg, org, }); @@ -95,7 +101,8 @@ export const handleUpdateOrganizationStripe = createRoute({ if (live_account_id) { updates.live_stripe_connect = await validateAndUpdateStripeAccount({ accountId: live_account_id, - env: AppEnv.Live, + appEnv: AppEnv.Live, + env: c.env, masterOrg, org, }); @@ -113,7 +120,7 @@ export const handleUpdateOrganizationStripe = createRoute({ `Updated Stripe Connect for platform org ${org.slug}: test=${test_account_id}, live=${live_account_id}`, ); - await registerConnectWebhook({ ctx }); + await registerConnectWebhook({ ctx, env: c.env }); return c.json({ message: "Stripe Connect configuration updated successfully", diff --git a/server/src/internal/platform/platformBeta/platformBetaRouter.ts b/server/src/internal/platform/platformBeta/platformBetaRouter.ts index 4df5eb2c1..f4474d41d 100644 --- a/server/src/internal/platform/platformBeta/platformBetaRouter.ts +++ b/server/src/internal/platform/platformBeta/platformBetaRouter.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { Hono } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handleCreatePlatformOrg } from "./handlers/handleCreatePlatformOrg.js"; @@ -19,7 +18,7 @@ platformBetaRouter.use("*", async (c, next) => { const ctx = c.get("ctx"); const { org, logger } = ctx; - if (!runtimeEnv.AUTUMN_SECRET_KEY) { + if (!c.env.AUTUMN_SECRET_KEY) { return next(); } diff --git a/server/src/internal/products/productUtils/detectProductVariant.ts b/server/src/internal/products/productUtils/detectProductVariant.ts index 1f639a072..8d4d5563a 100644 --- a/server/src/internal/products/productUtils/detectProductVariant.ts +++ b/server/src/internal/products/productUtils/detectProductVariant.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { anthropic } from "@ai-sdk/anthropic"; import { BillingInterval, type FullProduct } from "@autumn/shared"; import { generateObject } from "ai"; @@ -28,14 +27,16 @@ To determine if a product is an interval variant, please follow these guidelines export const detectBaseVariant = async ({ db, curProduct, + env, logger, }: { db: DrizzleCli; curProduct: FullProduct; + env: Env; logger: Logger; }) => { logger.info(`Detecting base variant for ${curProduct.id}`); - if (!runtimeEnv.ANTHROPIC_API_KEY) return; + if (!env.ANTHROPIC_API_KEY) return; const existingProducts = (await ProductService.listFull({ db, diff --git a/server/src/node.ts b/server/src/node.ts index bd7afd1d1..16d8f5819 100644 --- a/server/src/node.ts +++ b/server/src/node.ts @@ -1,17 +1,3 @@ -// Entry point: Load Infisical secrets, then start the Node/Bun app. -import { setRuntimeEnvFromProcess } from "@/utils/envUtils.js"; -import cluster from "node:cluster"; +import { startNodeServer } from "./init.js"; -import { initInfisical } from "./external/infisical/initInfisical.js"; - -setRuntimeEnvFromProcess(); - -// Load Infisical secrets into runtimeEnv ONLY in master/primary process. -// Infisical will NOT override existing env vars supplied by the process. -if (cluster.isPrimary) { - await initInfisical(); -} - -// Now dynamically import and run the main app. -await import("./instrumentation.js"); -await import("./init.js"); +export { startNodeServer }; diff --git a/server/src/queue/blueGreen/blueGreenReadinessChecks.ts b/server/src/queue/blueGreen/blueGreenReadinessChecks.ts index d7cfe8d7c..938e86251 100644 --- a/server/src/queue/blueGreen/blueGreenReadinessChecks.ts +++ b/server/src/queue/blueGreen/blueGreenReadinessChecks.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { GetQueueAttributesCommand, SQSClient } from "@aws-sdk/client-sqs"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { @@ -41,19 +40,21 @@ const probe = async ({ } }; -const getConfiguredQueueUrls = () => +const getConfiguredQueueUrls = (env: Env) => [ QUEUE_URL, - runtimeEnv.TRACK_SQS_QUEUE_URL, - runtimeEnv.TRACK_ASYNC_SQS_QUEUE_URL, + env.TRACK_SQS_QUEUE_URL, + env.TRACK_ASYNC_SQS_QUEUE_URL, ].filter((url): url is string => Boolean(url)); export const getBlueGreenQueueUrls = ({ + env, knownQueueUrls = [], }: { + env: Env; knownQueueUrls?: string[]; -} = {}) => - Array.from(new Set([...getConfiguredQueueUrls(), ...knownQueueUrls])); +}) => + Array.from(new Set([...getConfiguredQueueUrls(env), ...knownQueueUrls])); // Build a per-queue-URL SQSClient using the region extracted from the URL, // so SigV4 signs against the queue's region. Reusing one singleton across diff --git a/server/src/queue/blueGreen/initBlueGreen.ts b/server/src/queue/blueGreen/initBlueGreen.ts index 2d535edd8..31926c66b 100644 --- a/server/src/queue/blueGreen/initBlueGreen.ts +++ b/server/src/queue/blueGreen/initBlueGreen.ts @@ -27,12 +27,14 @@ import { */ export const initBlueGreen = async ({ db, + env, logger, }: { db: DrizzleCli; + env: Env; logger?: Logger; }) => { - const identity = await resolveAwsTaskIdentity(); + const identity = await resolveAwsTaskIdentity(env); await startBlueGreenSlotStorePolling({ serviceName: "workers", logger }); startBlueGreenHeartbeat({ db, logger, serviceName: "workers" }); diff --git a/server/src/queue/hatchetWorkflows/createWorkflowTask.ts b/server/src/queue/hatchetWorkflows/createWorkflowTask.ts index 8a97789ec..be6c4b225 100644 --- a/server/src/queue/hatchetWorkflows/createWorkflowTask.ts +++ b/server/src/queue/hatchetWorkflows/createWorkflowTask.ts @@ -5,7 +5,6 @@ import { db } from "@/db/initDrizzle.js"; import { createLogger } from "@/external/logtail/logtailUtils.js"; import { getSentryTags } from "@/external/sentry/sentryUtils.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { runtimeEnv } from "@/utils/envUtils.js"; import { generateId } from "@/utils/genUtils.js"; import { addWorkflowToLogs } from "@/utils/logging/addContextToLogs.js"; import { createWorkerContext } from "../createWorkerContext.js"; @@ -25,15 +24,17 @@ type BaseWorkflowInput = { */ export const createWorkflowTask = ({ handler, + bindings, }: { handler: (params: { input: TInput; autumnContext: AutumnContext; }) => Promise; + bindings: Env; }): ((input: TInput, hatchetCtx: Context) => Promise) => { return async (input: TInput, hatchetCtx: Context) => { const { orgId, env, customerId } = input; - const logger = createLogger(runtimeEnv); + const logger = createLogger(bindings); // Get workflow/task name from Hatchet context const workflowName = hatchetCtx.workflowName(); diff --git a/server/src/queue/initSqs.ts b/server/src/queue/initSqs.ts index 7168b54eb..380848b70 100644 --- a/server/src/queue/initSqs.ts +++ b/server/src/queue/initSqs.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { SQSClient } from "@aws-sdk/client-sqs"; import { DEFAULT_AWS_REGION, @@ -23,8 +22,14 @@ export const extractLocalEndpoint = ({ } }; -const getSqsClientConfig = ({ queueUrl }: { queueUrl?: string } = {}) => { - const resolvedQueueUrl = queueUrl ?? runtimeEnv.SQS_QUEUE_URL_V2; +const getSqsClientConfig = ({ + env, + queueUrl, +}: { + env: Env; + queueUrl?: string; +}) => { + const resolvedQueueUrl = queueUrl ?? env.SQS_QUEUE_URL_V2; const endpoint = extractLocalEndpoint({ queueUrl: resolvedQueueUrl }); const region = extractRegionFromQueueUrl({ queueUrl: resolvedQueueUrl }) || @@ -34,14 +39,20 @@ const getSqsClientConfig = ({ queueUrl }: { queueUrl?: string } = {}) => { region, ...(endpoint ? { endpoint } : {}), credentials: { - accessKeyId: runtimeEnv.AWS_ACCESS_KEY_ID || "", - secretAccessKey: runtimeEnv.AWS_SECRET_ACCESS_KEY || "", + accessKeyId: env.AWS_ACCESS_KEY_ID || "", + secretAccessKey: env.AWS_SECRET_ACCESS_KEY || "", }, }; }; -const getSqsClientCacheKey = ({ queueUrl }: { queueUrl?: string } = {}) => { - const resolvedQueueUrl = queueUrl ?? runtimeEnv.SQS_QUEUE_URL_V2; +const getSqsClientCacheKey = ({ + env, + queueUrl, +}: { + env: Env; + queueUrl?: string; +}) => { + const resolvedQueueUrl = queueUrl ?? env.SQS_QUEUE_URL_V2; const endpoint = extractLocalEndpoint({ queueUrl: resolvedQueueUrl }); const region = extractRegionFromQueueUrl({ queueUrl: resolvedQueueUrl }) || @@ -52,45 +63,54 @@ const getSqsClientCacheKey = ({ queueUrl }: { queueUrl?: string } = {}) => { const sqsClientsByCacheKey = new Map(); -let sqsClient = new SQSClient(getSqsClientConfig()); -sqsClientsByCacheKey.set(getSqsClientCacheKey(), sqsClient); +/** + * Create and initialize the SQS subsystem. Returns a handle with the + * primary SQS client and queue URL. Callers must thread this through + * to any code that needs SQS access. + */ +export const initSqs = (env: Env) => { + const sqsClient = new SQSClient(getSqsClientConfig({ env })); + sqsClientsByCacheKey.set(getSqsClientCacheKey({ env }), sqsClient); -export const sqs = sqsClient; + return { sqs: sqsClient, queueUrl: env.SQS_QUEUE_URL_V2 || "" }; +}; + +export type SqsHandle = ReturnType; /** Recreates the SQS client with fresh connections */ export const recreateSqsClient = ({ + env, queueUrl, }: { + env: Env; queueUrl?: string; -} = {}): SQSClient => { +}): SQSClient => { console.log(`[SQS] Recreating SQS client (stale connection suspected)`); - const cacheKey = getSqsClientCacheKey({ queueUrl }); + const cacheKey = getSqsClientCacheKey({ env, queueUrl }); const existingClient = sqsClientsByCacheKey.get(cacheKey); existingClient?.destroy(); - const nextClient = new SQSClient(getSqsClientConfig({ queueUrl })); + const nextClient = new SQSClient(getSqsClientConfig({ env, queueUrl })); sqsClientsByCacheKey.set(cacheKey, nextClient); - if (!queueUrl || queueUrl === runtimeEnv.SQS_QUEUE_URL_V2) { - sqsClient = nextClient; - } - return nextClient; }; /** Get the current SQS client (use this instead of direct sqs export for refreshable access) */ export const getSqsClient = ({ + env, queueUrl, }: { + env: Env; queueUrl?: string; -} = {}): SQSClient => { - const cacheKey = getSqsClientCacheKey({ queueUrl }); +}): SQSClient => { + const cacheKey = getSqsClientCacheKey({ env, queueUrl }); const existingClient = sqsClientsByCacheKey.get(cacheKey); if (existingClient) return existingClient; - const nextClient = new SQSClient(getSqsClientConfig({ queueUrl })); + const nextClient = new SQSClient(getSqsClientConfig({ env, queueUrl })); sqsClientsByCacheKey.set(cacheKey, nextClient); return nextClient; }; -export const QUEUE_URL = runtimeEnv.SQS_QUEUE_URL_V2 || ""; +export const getQueueUrl = (env: Env): string => env.SQS_QUEUE_URL_V2 || ""; diff --git a/server/src/queue/initWorkers.ts b/server/src/queue/initWorkers.ts index 280a4d82d..79f256068 100644 --- a/server/src/queue/initWorkers.ts +++ b/server/src/queue/initWorkers.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; await import("../sentry.js"); import { ms } from "@autumn/shared"; @@ -12,7 +11,7 @@ import { import * as Sentry from "@sentry/bun"; import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js"; import { startPgPoolMonitor, stopPgPoolMonitor } from "@/db/pgPoolMonitor.js"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { verifyCacheConsistency } from "@/internal/billing/v2/workflows/verifyCacheConsistency/verifyCacheConsistency.js"; import { isJobQueueEnabled, @@ -27,7 +26,7 @@ import { recordPollAttempt, } from "./blueGreen/blueGreenHeartbeat.js"; import { initBlueGreen, shutdownBlueGreen } from "./blueGreen/initBlueGreen.js"; -import { getSqsClient, QUEUE_URL, recreateSqsClient } from "./initSqs.js"; +import { getQueueUrl, getSqsClient, recreateSqsClient } from "./initSqs.js"; import { JobName } from "./JobName.js"; import { processMessage, type SqsJob } from "./processMessage.js"; @@ -41,7 +40,6 @@ const MAX_MESSAGES_BEFORE_RECYCLE = 50_000; // Idle self-kill — exit if worker processes 0 messages for this many consecutive intervals const IDLE_SELF_KILL_THRESHOLD = 5; // ~5 min of 0 messages (5 * 60s) -const shouldIdleSelfKill = runtimeEnv.NODE_ENV !== "development"; // Per-message processing timeout — must be under VisibilityTimeout (30s) const MESSAGE_TIMEOUT_MS = 25_000; @@ -91,6 +89,7 @@ export const startPollingLoop = async ({ getSqsClientFn, recreateSqsClientFn, shouldPoll = () => true, + env, }: { db: DrizzleCli; queueUrl: string; @@ -98,7 +97,11 @@ export const startPollingLoop = async ({ getSqsClientFn: () => SQSClient; recreateSqsClientFn: () => SQSClient; shouldPoll?: () => boolean; + env: Env; }) => { + const logger = createLogger(env); + const shouldIdleSelfKill = env.NODE_ENV !== "development"; + // Per-loop state let messagesProcessed = 0; let totalMessagesProcessed = 0; @@ -172,7 +175,7 @@ export const startPollingLoop = async ({ consecutiveZeroMessageIntervals >= IDLE_SELF_KILL_THRESHOLD && totalMessagesProcessed > 0 && activeMigrationJobs === 0 && - runtimeEnv.NODE_ENV !== "development" + env.NODE_ENV !== "development" ) { console.log( `${prefix} Idle self-kill: 0 messages for ${consecutiveZeroMessageIntervals} intervals after processing ${totalMessagesProcessed} total. Exiting for cluster respawn.`, @@ -238,12 +241,12 @@ export const startPollingLoop = async ({ } if (override) { - await processMessage({ message, db }); + await processMessage({ message, db, env }); } else { await withTimeout({ timeoutMs: MESSAGE_TIMEOUT_MS, timeoutMessage: `Processing timed out after ${MESSAGE_TIMEOUT_MS}ms`, - fn: () => processMessage({ message, db }), + fn: () => processMessage({ message, db, env }), }); } @@ -419,16 +422,19 @@ export const startPollingLoop = async ({ export const initWorkers = async ({ startupStartedAt, queueImplementation, + env, }: { startupStartedAt: number; queueImplementation: string; + env: Env; }) => { - const { db } = initDrizzle({ name: "worker", maxConnections: 40 }); - startPgPoolMonitor(); + const logger = createLogger(env); + const { db } = initDrizzle({ name: "worker", maxConnections: 40, databaseUrl: env.DATABASE_URL }); + startPgPoolMonitor(env); const { warmupRegionalRedis } = await import("@/external/redis/initRedis.js"); await warmupRegionalRedis(); - await initBlueGreen({ db, logger }); + await initBlueGreen({ db, env, logger }); const shutdown = async () => { console.log(`[SQS Worker ${process.pid}] Shutting down...`); @@ -439,7 +445,7 @@ export const initWorkers = async ({ controller.abort(); } - const isProd = runtimeEnv.NODE_ENV === "production"; + const isProd = env.NODE_ENV === "production"; if (isProd) { const shutdownTimeout = setTimeout(() => process.exit(0), 5000); if (shutdownTimeout.unref) { @@ -462,15 +468,15 @@ export const initWorkers = async ({ for (const { queueId, queueUrl } of [ { queueId: JOB_QUEUE_IDS.primary, - queueUrl: QUEUE_URL, + queueUrl: getQueueUrl(env), }, { queueId: JOB_QUEUE_IDS.track, - queueUrl: runtimeEnv.TRACK_SQS_QUEUE_URL, + queueUrl: env.TRACK_SQS_QUEUE_URL, }, { queueId: JOB_QUEUE_IDS.trackAsync, - queueUrl: runtimeEnv.TRACK_ASYNC_SQS_QUEUE_URL, + queueUrl: env.TRACK_ASYNC_SQS_QUEUE_URL, }, ]) { if (!queueUrl) continue; @@ -479,9 +485,10 @@ export const initWorkers = async ({ startPollingLoop({ db, queueUrl, + env, isFifo: queueUrl.endsWith(".fifo"), - getSqsClientFn: () => getSqsClient({ queueUrl }), - recreateSqsClientFn: () => recreateSqsClient({ queueUrl }), + getSqsClientFn: () => getSqsClient({ env, queueUrl }), + recreateSqsClientFn: () => recreateSqsClient({ env, queueUrl }), shouldPoll: () => isJobQueueEnabled({ queue: queueId }) && isActiveSlot({ serviceName: "workers" }), diff --git a/server/src/queue/processMessage.ts b/server/src/queue/processMessage.ts index 001358a6f..a142ba72b 100644 --- a/server/src/queue/processMessage.ts +++ b/server/src/queue/processMessage.ts @@ -1,11 +1,10 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { Message } from "@aws-sdk/client-sqs"; import * as Sentry from "@sentry/bun"; import chalk from "chalk"; import type { Logger } from "pino"; import { isTransientDbError } from "@/db/dbUtils.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { isTransientRedisError } from "@/external/redis/utils/isTransientRedisError.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js"; @@ -67,9 +66,11 @@ export const shouldRetrySqsJobError = ({ export const processMessage = async ({ message, db, + env, }: { message: Message; db: DrizzleCli; + env: Env; }) => { if (!message.Body) { console.warn("Received message without body"); @@ -81,7 +82,7 @@ export const processMessage = async ({ const workflowId = message.MessageId ?? generateId("job"); const workerLogger = addWorkflowToLogs({ - logger: logger, + logger: createLogger(env), workflowContext: { id: workflowId, name: job.name, @@ -100,6 +101,7 @@ export const processMessage = async ({ await detectBaseVariant({ db, curProduct: job.data.curProduct, + env, logger: workerLogger as Logger, }); return; @@ -318,6 +320,7 @@ export const processMessage = async ({ return; } await expireLock({ + workerEnv: env, ctx, payload: job.data, }); @@ -372,7 +375,7 @@ export const processMessage = async ({ done: true, }); - if (runtimeEnv.NODE_ENV === "development") { + if (env.NODE_ENV === "development") { finalLogger.debug( `FINISHED PROCESSING JOB ${job.name}, EXTRA LOGS: ${JSON.stringify(maskedLogs, null, 2)}`, ); diff --git a/server/src/queue/queueUtils.ts b/server/src/queue/queueUtils.ts index 438cef788..7c6a39756 100644 --- a/server/src/queue/queueUtils.ts +++ b/server/src/queue/queueUtils.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { ApiVersion, AppEnv, @@ -119,6 +118,7 @@ export const addTaskToQueue = async ({ generateDeduplicationId, delayMs, queueUrl, + env, }: { jobName: T; payload: Payloads[T]; @@ -127,11 +127,12 @@ export const addTaskToQueue = async ({ generateDeduplicationId?: boolean; delayMs?: number; queueUrl?: string; + env: Env; }) => { - const resolvedQueueUrl = queueUrl || runtimeEnv.SQS_QUEUE_URL_V2; + const resolvedQueueUrl = queueUrl || env.SQS_QUEUE_URL_V2; if (resolvedQueueUrl) { - const sqsClient = getSqsClient({ queueUrl: resolvedQueueUrl }); + const sqsClient = getSqsClient({ env, queueUrl: resolvedQueueUrl }); // SQS implementation const isFifoQueue = resolvedQueueUrl.endsWith(".fifo"); diff --git a/server/src/sentry.ts b/server/src/sentry.ts index d8d7788c0..320cc2336 100644 --- a/server/src/sentry.ts +++ b/server/src/sentry.ts @@ -1,10 +1,11 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import * as Sentry from "@sentry/bun"; -if (runtimeEnv.SENTRY_DSN) { +export const initSentry = (env: Env) => { + if (!env.SENTRY_DSN) return; + Sentry.init({ - dsn: runtimeEnv.SENTRY_DSN, + dsn: env.SENTRY_DSN, sendDefaultPii: true, skipOpenTelemetrySetup: true, }); -} +}; diff --git a/server/src/trigger/configureTrigger.ts b/server/src/trigger/configureTrigger.ts index 1e6751fc5..ec18edaeb 100644 --- a/server/src/trigger/configureTrigger.ts +++ b/server/src/trigger/configureTrigger.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { configure } from "@trigger.dev/sdk/v3"; /** @@ -9,11 +8,10 @@ import { configure } from "@trigger.dev/sdk/v3"; * never collide in a shared shell — the SDK's default `TRIGGER_SECRET_KEY` * is left to autumn-cloud. * - * Module side-effect: this `configure` call runs once on first import. - * Anything that triggers tasks server-side imports from - * `@/trigger/migrations/...`, which re-exports from this file's siblings, - * so the configure happens before any `.trigger()` call. + * Call this before server-side code triggers tasks. */ -if (runtimeEnv.TRIGGER_SERVER_SECRET_KEY) { - configure({ secretKey: runtimeEnv.TRIGGER_SERVER_SECRET_KEY }); -} +export const configureTrigger = (env: Env) => { + if (env.TRIGGER_SERVER_SECRET_KEY) { + configure({ secretKey: env.TRIGGER_SERVER_SECRET_KEY }); + } +}; diff --git a/server/src/trigger/utils/createTriggerContext.ts b/server/src/trigger/utils/createTriggerContext.ts index b33b6704c..dd5e15b91 100644 --- a/server/src/trigger/utils/createTriggerContext.ts +++ b/server/src/trigger/utils/createTriggerContext.ts @@ -7,7 +7,6 @@ import { } from "@/external/logtail/logtailUtils.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { createWorkerContext } from "@/queue/createWorkerContext.js"; -import { runtimeEnv } from "@/utils/envUtils.js"; import { addTriggerToLogs } from "@/utils/logging/addContextToLogs.js"; /** @@ -17,16 +16,18 @@ import { addTriggerToLogs } from "@/utils/logging/addContextToLogs.js"; export const createTriggerContext = async ({ orgId, env, + bindings, triggerCtx, customerId, }: { orgId: string; env: AppEnv; + bindings: Env; triggerCtx: TriggerRunContext; customerId?: string; }): Promise<{ ctx: AutumnContext; logger: Logger }> => { const logger = addTriggerToLogs({ - logger: createDualLogger(runtimeEnv), + logger: createDualLogger(bindings), triggerContext: { run_id: triggerCtx.run.id, task_id: triggerCtx.task.id, diff --git a/server/src/utils/auth.ts b/server/src/utils/auth.ts index ee2c05a3c..c404a66bf 100644 --- a/server/src/utils/auth.ts +++ b/server/src/utils/auth.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { ALL_SCOPES, ac, invitation, roles, schemas } from "@autumn/shared"; import { oauthProvider } from "@better-auth/oauth-provider"; import { passkey } from "@better-auth/passkey"; @@ -15,393 +14,405 @@ import { import type { AccessControl } from "better-auth/plugins/access"; import { eq } from "drizzle-orm"; import { db } from "@/db/initDrizzle.js"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { createLoopsContact } from "@/external/resend/loopsUtils.js"; import { sendInvitationEmail } from "@/internal/emails/sendInvitationEmail.js"; import { sendOnboardingEmail } from "@/internal/emails/sendOnboardingEmail.js"; import sendOTPEmail from "@/internal/emails/sendOTPEmail.js"; import { afterOrgCreated } from "./authUtils/afterOrgCreated.js"; -import { afterSessionCreated } from "./authUtils/afterSessionCreated.js"; -import { afterSessionDeleted } from "./authUtils/afterSessionDeleted.js"; -import { beforeSessionCreated } from "./authUtils/beforeSessionCreated.js"; +import { createAfterSessionCreated } from "./authUtils/afterSessionCreated.js"; +import { createAfterSessionDeleted } from "./authUtils/afterSessionDeleted.js"; +import { createBeforeSessionCreated } from "./authUtils/beforeSessionCreated.js"; import { getScopesForUserInOrg } from "./authUtils/customSessionScopes.js"; import { ADMIN_USER_IDs } from "./constants.js"; -// emulate.dev Google: rewrite outbound Google OAuth host so agent worktrees -// can use any redirect URI without registering it in the real Google console. -// Real Google's oauth2.googleapis.com/token maps to emulate's /oauth2/token path. -if (runtimeEnv.EMULATE_GOOGLE_URL && runtimeEnv.NODE_ENV !== "production") { - const emulate = runtimeEnv.EMULATE_GOOGLE_URL.replace(/\/$/, ""); - const originalFetch = globalThis.fetch; - globalThis.fetch = ((input: any, init?: any) => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.href - : (input as Request).url; - if (url.startsWith("https://oauth2.googleapis.com")) { - return originalFetch( - url.replace("https://oauth2.googleapis.com", `${emulate}/oauth2`), - init, - ); - } - if (url.startsWith("https://www.googleapis.com/oauth2")) { - return originalFetch( - url.replace("https://www.googleapis.com", emulate), - init, - ); - } - return originalFetch(input, init); - }) as typeof fetch; -} +let hasConfiguredGoogleEmulator = false; -const emulateGoogleUrl = - runtimeEnv.NODE_ENV !== "production" - ? runtimeEnv.EMULATE_GOOGLE_URL?.replace(/\/$/, "") - : undefined; - -// HTTPS agent worktrees go through portless (e.g. wtN-api.localhost). The -// OAuth flow leaves and returns via a third-party host (emulate.dev), so the -// state cookie must be SameSite=None+Secure to survive the round trip. -const isHttpsBaseUrl = runtimeEnv.BETTER_AUTH_URL?.startsWith("https://"); -const isProductionAuth = runtimeEnv.NODE_ENV === "production"; - -const parseMcpResourceUrl = (rawUrl: string) => { - const resourceUrl = rawUrl.trim(); - if (!resourceUrl) return null; - - try { - return new URL(resourceUrl).href; - } catch { - console.warn(`Ignoring invalid MCP_RESOURCE_URLS entry: ${resourceUrl}`); - return null; +export const createAuth = (env: Env) => { + const logger = createLogger(env); + // emulate.dev Google: rewrite outbound Google OAuth host so agent worktrees + // can use any redirect URI without registering it in the real Google console. + // Real Google's oauth2.googleapis.com/token maps to emulate's /oauth2/token path. + if ( + env.EMULATE_GOOGLE_URL && + env.NODE_ENV !== "production" && + !hasConfiguredGoogleEmulator + ) { + hasConfiguredGoogleEmulator = true; + const emulate = env.EMULATE_GOOGLE_URL.replace(/\/$/, ""); + const originalFetch = globalThis.fetch; + globalThis.fetch = ((input: any, init?: any) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : (input as Request).url; + if (url.startsWith("https://oauth2.googleapis.com")) { + return originalFetch( + url.replace("https://oauth2.googleapis.com", `${emulate}/oauth2`), + init, + ); + } + if (url.startsWith("https://www.googleapis.com/oauth2")) { + return originalFetch( + url.replace("https://www.googleapis.com", emulate), + init, + ); + } + return originalFetch(input, init); + }) as typeof fetch; } -}; -// Public hosts that serve OAuth-protected MCP endpoints. leaf serves both the -// MCP server (MCP_SERVER_URL) and the chat/slackbot (CHAT_SERVER_URL); the -// autumn server can also proxy /mcp under its own origin (BETTER_AUTH_URL). -// The OAuth `resource` indicator is host-based, so every public host + path -// must be a registered audience. MCP_RESOURCE_URLS is an explicit override. -const mcpServerUrl = - runtimeEnv.MCP_SERVER_URL ?? - (isProductionAuth ? "https://mcp.useautumn.com" : "http://localhost:3099"); -const chatServerUrl = - runtimeEnv.CHAT_SERVER_URL ?? - (isProductionAuth ? "https://chat.useautumn.com" : "http://localhost:3099"); + const emulateGoogleUrl = + env.NODE_ENV !== "production" + ? env.EMULATE_GOOGLE_URL?.replace(/\/$/, "") + : undefined; -const mcpResourcePaths = ["/mcp"]; -const mcpResourceBases = [ - runtimeEnv.BETTER_AUTH_URL, - mcpServerUrl, - chatServerUrl, -].filter((base): base is string => Boolean(base)); + // HTTPS agent worktrees go through portless (e.g. wtN-api.localhost). The + // OAuth flow leaves and returns via a third-party host (emulate.dev), so the + // state cookie must be SameSite=None+Secure to survive the round trip. + const isHttpsBaseUrl = env.BETTER_AUTH_URL?.startsWith("https://"); + const isProductionAuth = env.NODE_ENV === "production"; -const mcpResourceUrls = [ - ...new Set([ - ...mcpResourceBases.flatMap((base) => - mcpResourcePaths.map((path) => new URL(path, base).href), - ), - ...(runtimeEnv.MCP_RESOURCE_URLS?.split(",") - .map(parseMcpResourceUrl) - .filter((url): url is string => Boolean(url)) ?? []), - ]), -]; + const parseMcpResourceUrl = (rawUrl: string) => { + const resourceUrl = rawUrl.trim(); + if (!resourceUrl) return null; -/** - * Passkey (WebAuthn) is bound to the FRONTEND origin where the browser calls - * `navigator.credentials.{create,get}`. Derive rpID/origin from CLIENT_URL so - * Portless worktrees (e.g. https://wt44.localhost) and production both work - * without explicit env vars. - * - * - rpID: the hostname only (no scheme, no port). Browsers treat `*.localhost` - * as a secure context, so passkeys work in dev over Portless TLS. - * - origin: full URL with scheme. Multiple origins may be supplied for envs - * that need to accept both Portless and direct localhost. - */ -const passkeyFrontendUrl = runtimeEnv.CLIENT_URL ?? "http://localhost:3000"; -const passkeyOrigins: string[] = [passkeyFrontendUrl]; -const passkeyRpID = (() => { - try { - return new URL(passkeyFrontendUrl).hostname; - } catch { - return "localhost"; - } -})(); - -if ( - runtimeEnv.VITE_FRONTEND_URL && - runtimeEnv.VITE_FRONTEND_URL !== passkeyFrontendUrl -) { - try { - const viteOrigin = new URL(runtimeEnv.VITE_FRONTEND_URL); - if (viteOrigin.hostname === passkeyRpID) { - passkeyOrigins.push(runtimeEnv.VITE_FRONTEND_URL); + try { + return new URL(resourceUrl).href; + } catch { + console.warn(`Ignoring invalid MCP_RESOURCE_URLS entry: ${resourceUrl}`); + return null; } - } catch { - // Invalid URL, ignore - } -} + }; -const options = { - baseURL: runtimeEnv.BETTER_AUTH_URL, - telemetry: { - enabled: false, - }, - ...(isHttpsBaseUrl && { - advanced: { - useSecureCookies: true, - defaultCookieAttributes: { - sameSite: "none" as const, - secure: true, - }, + // Public hosts that serve OAuth-protected MCP endpoints. leaf serves both the + // MCP server (MCP_SERVER_URL) and the chat/slackbot (CHAT_SERVER_URL); the + // autumn server can also proxy /mcp under its own origin (BETTER_AUTH_URL). + // The OAuth `resource` indicator is host-based, so every public host + path + // must be a registered audience. MCP_RESOURCE_URLS is an explicit override. + const mcpServerUrl = + env.MCP_SERVER_URL ?? + (isProductionAuth ? "https://mcp.useautumn.com" : "http://localhost:3099"); + const chatServerUrl = + env.CHAT_SERVER_URL ?? + (isProductionAuth ? "https://chat.useautumn.com" : "http://localhost:3099"); + + const mcpResourcePaths = ["/mcp"]; + const mcpResourceBases = [ + env.BETTER_AUTH_URL, + mcpServerUrl, + chatServerUrl, + ].filter((base): base is string => Boolean(base)); + + const mcpResourceUrls = [ + ...new Set([ + ...mcpResourceBases.flatMap((base) => + mcpResourcePaths.map((path) => new URL(path, base).href), + ), + ...(env.MCP_RESOURCE_URLS?.split(",") + .map(parseMcpResourceUrl) + .filter((url): url is string => Boolean(url)) ?? []), + ]), + ]; + + /** + * Passkey (WebAuthn) is bound to the FRONTEND origin where the browser calls + * `navigator.credentials.{create,get}`. Derive rpID/origin from CLIENT_URL so + * Portless worktrees (e.g. https://wt44.localhost) and production both work + * without explicit env vars. + * + * - rpID: the hostname only (no scheme, no port). Browsers treat `*.localhost` + * as a secure context, so passkeys work in dev over Portless TLS. + * - origin: full URL with scheme. Multiple origins may be supplied for envs + * that need to accept both Portless and direct localhost. + */ + const passkeyFrontendUrl = env.CLIENT_URL ?? "http://localhost:3000"; + const passkeyOrigins: string[] = [passkeyFrontendUrl]; + const passkeyRpID = (() => { + try { + return new URL(passkeyFrontendUrl).hostname; + } catch { + return "localhost"; + } + })(); + + if ( + env.VITE_FRONTEND_URL && + env.VITE_FRONTEND_URL !== passkeyFrontendUrl + ) { + try { + const viteOrigin = new URL(env.VITE_FRONTEND_URL); + if (viteOrigin.hostname === passkeyRpID) { + passkeyOrigins.push(env.VITE_FRONTEND_URL); + } + } catch { + // Invalid URL, ignore + } + } + + const options = { + baseURL: env.BETTER_AUTH_URL, + telemetry: { + enabled: false, }, - }), - - database: drizzleAdapter(db, { - provider: "pg", - schema: schemas, - }), - - user: { - deleteUser: { - enabled: true, - sendDeleteAccountVerification: async ({ - user, - url, - token, - }: { - user: User; - url: string; - token: string; - }) => { - console.log("Delete account verification", { user, url, token }); + ...(isHttpsBaseUrl && { + advanced: { + useSecureCookies: true, + defaultCookieAttributes: { + sameSite: "none" as const, + secure: true, + }, }, - }, - }, - databaseHooks: { + }), + + database: drizzleAdapter(db, { + provider: "pg", + schema: schemas, + }), + user: { - create: { - after: async (user) => { - await createLoopsContact(user); - await sendOnboardingEmail({ - name: user.name, - email: user.email, - }); + deleteUser: { + enabled: true, + sendDeleteAccountVerification: async ({ + user, + url, + token, + }: { + user: User; + url: string; + token: string; + }) => { + console.log("Delete account verification", { user, url, token }); }, }, }, - session: { - create: { - before: beforeSessionCreated, - after: afterSessionCreated, + databaseHooks: { + user: { + create: { + after: async (user) => { + await createLoopsContact(user); + await sendOnboardingEmail({ + name: user.name, + email: user.email, + }); + }, + }, }, - delete: { - after: afterSessionDeleted, + session: { + create: { + before: createBeforeSessionCreated(env), + after: createAfterSessionCreated(env), + }, + delete: { + after: createAfterSessionDeleted(env), + }, }, }, - }, - trustedOrigins: (request?: Request): string[] => { - const origins: string[] = [ - "http://localhost:3000", - "https://app.useautumn.com", - "https://staging.useautumn.com", - "https://*.useautumn.com", - ]; - if (runtimeEnv.NODE_ENV === "production") return origins; + trustedOrigins: (request?: Request): string[] => { + const origins: string[] = [ + "http://localhost:3000", + "https://app.useautumn.com", + "https://staging.useautumn.com", + "https://*.useautumn.com", + ]; + if (env.NODE_ENV === "production") return origins; - // Worktree ports follow worktreeOffset = (N-1)*100; accept any localhost - // port the running stack might use as origin. - const origin = request?.headers.get("origin") ?? null; - if ( - origin && - /^https?:\/\/(?:[a-zA-Z0-9-]+\.)*localhost(?::\d+)?$/.test(origin) - ) { - origins.push(origin); - } - if (runtimeEnv.CLIENT_URL) origins.push(runtimeEnv.CLIENT_URL); - if (runtimeEnv.BETTER_AUTH_URL) origins.push(runtimeEnv.BETTER_AUTH_URL); - return origins; - }, - emailAndPassword: { - enabled: true, - disableSignUp: false, - requireEmailVerification: true, - minPasswordLength: 8, - maxPasswordLength: 128, - autoSignIn: true, - resetPasswordTokenExpiresIn: 3600, // 1 hour - }, + // Worktree ports follow worktreeOffset = (N-1)*100; accept any localhost + // port the running stack might use as origin. + const origin = request?.headers.get("origin") ?? null; + if ( + origin && + /^https?:\/\/(?:[a-zA-Z0-9-]+\.)*localhost(?::\d+)?$/.test(origin) + ) { + origins.push(origin); + } + if (env.CLIENT_URL) origins.push(env.CLIENT_URL); + if (env.BETTER_AUTH_URL) origins.push(env.BETTER_AUTH_URL); + return origins; + }, + emailAndPassword: { + enabled: true, + disableSignUp: false, + requireEmailVerification: true, + minPasswordLength: 8, + maxPasswordLength: 128, + autoSignIn: true, + resetPasswordTokenExpiresIn: 3600, // 1 hour + }, - socialProviders: { - google: { - clientId: runtimeEnv.GOOGLE_CLIENT_ID!, - clientSecret: runtimeEnv.GOOGLE_CLIENT_SECRET, - redirectURI: `${runtimeEnv.BETTER_AUTH_URL}/api/auth/callback/google`, - ...(emulateGoogleUrl - ? { + socialProviders: { + google: { + clientId: env.GOOGLE_CLIENT_ID!, + clientSecret: env.GOOGLE_CLIENT_SECRET, + redirectURI: `${env.BETTER_AUTH_URL}/api/auth/callback/google`, + ...(emulateGoogleUrl + ? { // HS256-signed id_tokens from emulate fail real Google's RS256 JWKS check. authorizationEndpoint: `${emulateGoogleUrl}/o/oauth2/v2/auth`, verifyIdToken: async () => true, } - : {}), + : {}), + }, }, - }, - plugins: [ - emailOTP({ - async sendVerificationOTP({ email, otp, type }) { - // Implement the sendVerificationOTP method to send the OTP to the user's email address + plugins: [ + emailOTP({ + async sendVerificationOTP({ email, otp, type }) { + // Implement the sendVerificationOTP method to send the OTP to the user's email address - await sendOTPEmail({ - email, - otp, - }); - }, - }), - admin({ - adminUserIds: ADMIN_USER_IDs, - impersonationSessionDuration: 1000 * 60 * 60 * 24, // 1 days - }), - - jwt(), - oauthProvider({ - loginPage: `${runtimeEnv.CLIENT_URL}/sign-in`, - consentPage: `${runtimeEnv.CLIENT_URL}/consent`, - // Resource-based scopes with R/W actions (plus legacy CRUDL + - // meta scopes — see shared/utils/scopeDefinitions.ts). - scopes: [...ALL_SCOPES], - validAudiences: [runtimeEnv.BETTER_AUTH_URL, ...mcpResourceUrls].filter( - Boolean, - ) as string[], - allowDynamicClientRegistration: true, - allowUnauthenticatedClientRegistration: true, - customAccessTokenClaims: ({ referenceId }) => ({ - reference_id: referenceId, + await sendOTPEmail({ + email, + otp, + }); + }, }), - clientReference: ({ session }) => { - return ( - (session?.activeOrganizationId as string | undefined) ?? undefined - ); - }, - // Use the active organization as the consent reference - // This makes consent org-scoped, not just user-scoped - postLogin: { - // Required: page to redirect to if shouldRedirect returns true - page: `${runtimeEnv.CLIENT_URL}/consent`, - // Required: whether to show post-login page (we don't need this, so always false) - shouldRedirect: async () => false, - // Optional: reference ID for consent (org ID makes consent org-scoped) - consentReferenceId: ({ session }) => { + admin({ + adminUserIds: ADMIN_USER_IDs, + impersonationSessionDuration: 1000 * 60 * 60 * 24, // 1 days + }), + + jwt(), + oauthProvider({ + loginPage: `${env.CLIENT_URL}/sign-in`, + consentPage: `${env.CLIENT_URL}/consent`, + // Resource-based scopes with R/W actions (plus legacy CRUDL + + // meta scopes — see shared/utils/scopeDefinitions.ts). + scopes: [...ALL_SCOPES], + validAudiences: [env.BETTER_AUTH_URL, ...mcpResourceUrls].filter( + Boolean, + ) as string[], + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + customAccessTokenClaims: ({ referenceId }) => ({ + reference_id: referenceId, + }), + clientReference: ({ session }) => { return ( (session?.activeOrganizationId as string | undefined) ?? undefined ); }, - }, - }), - - passkey({ - rpID: passkeyRpID, - rpName: "Autumn", - origin: passkeyOrigins.length === 1 ? passkeyOrigins[0]! : passkeyOrigins, - }), - - organization({ - ac: ac as AccessControl, - roles, - creatorRole: "owner", - async sendInvitationEmail(data: { - id: string; - email: string; - organization: Organization; - }) { - const inviteLink = `${runtimeEnv.CLIENT_URL}/accept?id=${data.id}`; - await sendInvitationEmail({ - email: data.email, - orgName: (data.organization.name as string) ?? "an organization", - inviteLink: inviteLink, - }); - - try { - // Update invite to expire in 7 days - await db - .update(invitation) - .set({ - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - }) - .where(eq(invitation.id, data.id)); - } catch (error) { - logger.error("Error updating invite expiration date:", { error }); - } - }, - schema: { - organization: { - modelName: "organizations", - fields: { - createdAt: "createdAt", + // Use the active organization as the consent reference + // This makes consent org-scoped, not just user-scoped + postLogin: { + // Required: page to redirect to if shouldRedirect returns true + page: `${env.CLIENT_URL}/consent`, + // Required: whether to show post-login page (we don't need this, so always false) + shouldRedirect: async () => false, + // Optional: reference ID for consent (org ID makes consent org-scoped) + consentReferenceId: ({ session }) => { + return ( + (session?.activeOrganizationId as string | undefined) ?? undefined + ); }, }, - }, - organizationHooks: { - afterCreateOrganization: async ({ - organization, - user, - }: { + }), + + passkey({ + rpID: passkeyRpID, + rpName: "Autumn", + origin: passkeyOrigins.length === 1 ? passkeyOrigins[0]! : passkeyOrigins, + }), + + organization({ + ac: ac as AccessControl, + roles, + creatorRole: "owner", + async sendInvitationEmail(data: { + id: string; + email: string; organization: Organization; - user: User; - }) => { - await afterOrgCreated({ org: organization, user }); + }) { + const inviteLink = `${env.CLIENT_URL}/accept?id=${data.id}`; + await sendInvitationEmail({ + email: data.email, + orgName: (data.organization.name as string) ?? "an organization", + inviteLink: inviteLink, + }); + + try { + // Update invite to expire in 7 days + await db + .update(invitation) + .set({ + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }) + .where(eq(invitation.id, data.id)); + } catch (error) { + logger.error("Error updating invite expiration date:", { error }); + } }, - }, - }), - ], -} satisfies BetterAuthOptions; - -export const auth = betterAuth({ - ...options, - plugins: [ - ...options.plugins, - /** - * Attach `role` and `scopes` to every session response. - * - * Must be last in the plugin list so it can observe the session - * produced by the other plugins (notably `organization`, which - * populates `session.activeOrganizationId`). - * - * better-auth docs note that custom session fields are NOT cached - * (neither in secondary storage nor the cookie cache), so every - * `getSession` call pays a DB round-trip. Accepted. - */ - customSession(async ({ user, session }) => { - let role: string | null = null; - let scopes: string[] = []; - const orgId = session.activeOrganizationId; - if (orgId && user?.id) { - const resolved = await getScopesForUserInOrg({ - db, - userId: user.id, - organizationId: orgId, - }); - role = resolved.role; - scopes = [...resolved.scopes]; - } + schema: { + organization: { + modelName: "organizations", + fields: { + createdAt: "createdAt", + }, + }, + }, + organizationHooks: { + afterCreateOrganization: async ({ + organization, + user, + }: { + organization: Organization; + user: User; + }) => { + await afterOrgCreated({ org: organization, user }); + }, + }, + }), + ], + } satisfies BetterAuthOptions; + const auth = betterAuth({ + ...options, + plugins: [ + ...options.plugins, /** - * Inject `superuser` for Autumn staff. Triggered when the - * better-auth GLOBAL user role is "admin" (NOT the org role), - * or when the session is an impersonation. Mirrors the - * client-side check in `useAdmin` and `adminAuthMiddleware`. + * Attach `role` and `scopes` to every session response. + * + * Must be last in the plugin list so it can observe the session + * produced by the other plugins (notably `organization`, which + * populates `session.activeOrganizationId`). + * + * better-auth docs note that custom session fields are NOT cached + * (neither in secondary storage nor the cookie cache), so every + * `getSession` call pays a DB round-trip. Accepted. */ - const globalUserRole = (user as { role?: string } | null | undefined) - ?.role; - const impersonatedBy = ( - session as { impersonatedBy?: string | null } | null | undefined - )?.impersonatedBy; - if (globalUserRole === "admin" || impersonatedBy) { - if (!scopes.includes("superuser")) scopes.push("superuser"); - } + customSession(async ({ user, session }) => { + let role: string | null = null; + let scopes: string[] = []; + const orgId = session.activeOrganizationId; + if (orgId && user?.id) { + const resolved = await getScopesForUserInOrg({ + db, + userId: user.id, + organizationId: orgId, + }); + role = resolved.role; + scopes = [...resolved.scopes]; + } - return { user, session, role, scopes }; - }, options), - ], -}); + /** + * Inject `superuser` for Autumn staff. Triggered when the + * better-auth GLOBAL user role is "admin" (NOT the org role), + * or when the session is an impersonation. Mirrors the + * client-side check in `useAdmin` and `adminAuthMiddleware`. + */ + const globalUserRole = (user as { role?: string } | null | undefined) + ?.role; + const impersonatedBy = ( + session as { impersonatedBy?: string | null } | null | undefined + )?.impersonatedBy; + if (globalUserRole === "admin" || impersonatedBy) { + if (!scopes.includes("superuser")) scopes.push("superuser"); + } + + return { user, session, role, scopes }; + }, options), + ], + }); + + return auth; +}; diff --git a/server/src/utils/authUtils/afterOrgCreated.ts b/server/src/utils/authUtils/afterOrgCreated.ts index 7d478132a..8bc40b62a 100644 --- a/server/src/utils/authUtils/afterOrgCreated.ts +++ b/server/src/utils/authUtils/afterOrgCreated.ts @@ -4,7 +4,7 @@ import type { User } from "better-auth"; import type { Organization as BetterAuthOrganization } from "better-auth/plugins/organization"; import { isUniqueConstraintError } from "@/db/dbUtils.js"; import { db } from "@/db/initDrizzle.js"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { createSvixApp } from "@/external/svix/svixHelpers.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; import { createConnectAccount } from "@/internal/orgs/orgUtils/createConnectAccount.js"; @@ -39,13 +39,16 @@ export const afterOrgCreated = async ({ createStripeAccount = true, pkey, livePkey, + env, }: { org: Organization | BetterAuthOrganization; user: User; createStripeAccount?: boolean; pkey?: string; livePkey?: string; + env: Env; }) => { + const logger = createLogger(env); logger.info(`Org created: ${org.id} (${org.slug})`); const { id, slug, createdAt } = org; diff --git a/server/src/utils/authUtils/afterSessionCreated.ts b/server/src/utils/authUtils/afterSessionCreated.ts index 49779c70c..1e7b46605 100644 --- a/server/src/utils/authUtils/afterSessionCreated.ts +++ b/server/src/utils/authUtils/afterSessionCreated.ts @@ -1,8 +1,7 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { GenericEndpointContext } from "@better-auth/core"; import type { BetterAuthOptions, Session } from "better-auth"; -export const afterSessionCreated = async ( +export const createAfterSessionCreated = (env: Env) => async ( session: Session, context: GenericEndpointContext | null, ) => { @@ -12,7 +11,7 @@ export const afterSessionCreated = async ( // In dev, the Vite dashboard sets this cookie client-side (localhost // doesn't support cross-port cookie sharing via Set-Cookie headers). - if (runtimeEnv.NODE_ENV !== "production") return; + if (env.NODE_ENV !== "production") return; // Set a non-httpOnly hint cookie on the root domain so the landing // page (useautumn.com) can detect that the user is logged in on app.useautumn.com. diff --git a/server/src/utils/authUtils/afterSessionDeleted.ts b/server/src/utils/authUtils/afterSessionDeleted.ts index 20e966fa3..6b9e669ab 100644 --- a/server/src/utils/authUtils/afterSessionDeleted.ts +++ b/server/src/utils/authUtils/afterSessionDeleted.ts @@ -1,8 +1,7 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import type { GenericEndpointContext } from "@better-auth/core"; import type { BetterAuthOptions, Session } from "better-auth"; -export const afterSessionDeleted = async ( +export const createAfterSessionDeleted = (env: Env) => async ( _session: Session, context: GenericEndpointContext | null, ) => { @@ -11,7 +10,7 @@ export const afterSessionDeleted = async ( // Dev parity: the cookie is only set server-side in production, so // we don't need to clear it here in other environments. - if (runtimeEnv.NODE_ENV !== "production") return; + if (env.NODE_ENV !== "production") return; // Clear the landing-page hint cookie so signed-out users stop seeing // the "Dashboard" CTA on useautumn.com. Attributes must match the diff --git a/server/src/utils/authUtils/beforeSessionCreated.ts b/server/src/utils/authUtils/beforeSessionCreated.ts index 8b63ae22a..cd87c6155 100644 --- a/server/src/utils/authUtils/beforeSessionCreated.ts +++ b/server/src/utils/authUtils/beforeSessionCreated.ts @@ -4,7 +4,8 @@ import { eq } from "drizzle-orm"; import { db } from "@/db/initDrizzle.js"; import { createDefaultOrg } from "@/utils/authUtils/createDefaultOrg.js"; -export const beforeSessionCreated = async (session: Session) => { +export const createBeforeSessionCreated = + (env: Env) => async (session: Session) => { try { console.log(`Running beforeSessionCreated for user ${session.userId}`); @@ -24,7 +25,7 @@ export const beforeSessionCreated = async (session: Session) => { }; } - const orgId = await createDefaultOrg({ session }); + const orgId = await createDefaultOrg({ env, session }); return { data: { @@ -33,4 +34,4 @@ export const beforeSessionCreated = async (session: Session) => { }, }; } catch (error) {} -}; + }; diff --git a/server/src/utils/authUtils/createDefaultOrg.ts b/server/src/utils/authUtils/createDefaultOrg.ts index 97ec7e393..e88369344 100644 --- a/server/src/utils/authUtils/createDefaultOrg.ts +++ b/server/src/utils/authUtils/createDefaultOrg.ts @@ -3,14 +3,17 @@ import type { Session } from "better-auth"; import type { Organization } from "better-auth/plugins/organization"; import { and, eq, gt } from "drizzle-orm"; import { db } from "@/db/initDrizzle.js"; -import { auth } from "@/utils/auth.js"; +import { createAuth } from "@/utils/auth.js"; import { slugify } from "@/utils/genUtils.js"; export const createDefaultOrg = async ({ + env, session, }: { + env: Env; session: Session; }): Promise => { + const auth = createAuth(env); try { const user = await db.query.user.findFirst({ where: eq(userTable.id, session.userId), diff --git a/server/src/utils/cacheUtils/cacheUtils.ts b/server/src/utils/cacheUtils/cacheUtils.ts index 86c8d45b9..851581828 100644 --- a/server/src/utils/cacheUtils/cacheUtils.ts +++ b/server/src/utils/cacheUtils/cacheUtils.ts @@ -1,5 +1,5 @@ import type { Redis } from "ioredis"; -import { logger } from "@/external/logtail/logtailUtils.js"; +import { createLogger } from "@/external/logtail/logtailUtils.js"; import { redis } from "@/external/redis/initRedis.js"; import { RedisUnavailableError } from "@/external/redis/utils/errors.js"; import type { UnavailableReason } from "@/external/redis/utils/runRedisOp.js"; @@ -10,10 +10,14 @@ const lastRedisWarningAtBySource = new Map(); const warnRedisUnavailable = ({ source, error, + env, }: { source: string; error?: unknown; + env: Env; }) => { + const logger = createLogger(env); + const now = Date.now(); const lastWarningAt = lastRedisWarningAtBySource.get(source) ?? 0; if (now - lastWarningAt < REDIS_WARNING_INTERVAL_MS) return; @@ -43,12 +47,14 @@ const throwIfRedisUnavailable = ({ targetRedis, source, error, + env, }: { targetRedis: Redis; source: string; error?: unknown; + env: Env; }) => { - warnRedisUnavailable({ source, error }); + warnRedisUnavailable({ source, error, env }); const reason = classifyRedisUnavailable(targetRedis, error); if (reason) { @@ -65,12 +71,14 @@ const throwIfRedisUnavailable = ({ export const tryRedisNx = async ({ operation, redisInstance, + env, onRedisUnavailable, onSuccess, onKeyAlreadyExists, }: { operation: () => Promise<"OK" | null>; redisInstance?: Redis; + env: Env; onRedisUnavailable: () => TUnavailable | Promise; onSuccess: () => TSuccess | Promise; onKeyAlreadyExists: () => TExists | Promise; @@ -82,6 +90,7 @@ export const tryRedisNx = async ({ throwIfRedisUnavailable({ targetRedis, source: "tryRedisNx:not-ready", + env, }); return await onRedisUnavailable(); } @@ -95,6 +104,7 @@ export const tryRedisNx = async ({ targetRedis, source: "tryRedisNx:error", error, + env, }); return await onRedisUnavailable(); } @@ -110,6 +120,7 @@ export const tryRedisNx = async ({ * @returns Promise - The result if successful, null otherwise. Returns true if operation returns void/undefined. */ export const tryRedisWrite = async ( + env: Env, operation: () => Promise, redisInstance?: Redis, ): Promise => { @@ -120,6 +131,7 @@ export const tryRedisWrite = async ( throwIfRedisUnavailable({ targetRedis, source: "tryRedisWrite:not-ready", + env, }); return null as T extends void ? true : T | null; } @@ -135,6 +147,7 @@ export const tryRedisWrite = async ( targetRedis, source: "tryRedisWrite:error", error, + env, }); return null as T extends void ? true : T | null; } @@ -149,6 +162,7 @@ export const tryRedisWrite = async ( * @returns Promise - The data if successful, null otherwise */ export const tryRedisRead = async ( + env: Env, operation: () => Promise, redisInstance?: Redis, ): Promise => { @@ -159,6 +173,7 @@ export const tryRedisRead = async ( throwIfRedisUnavailable({ targetRedis, source: "tryRedisRead:not-ready", + env, }); return null; } @@ -171,6 +186,7 @@ export const tryRedisRead = async ( targetRedis, source: "tryRedisRead:error", error, + env, }); return null; } diff --git a/server/src/utils/constants.ts b/server/src/utils/constants.ts index 24326c380..a074dedb4 100644 --- a/server/src/utils/constants.ts +++ b/server/src/utils/constants.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { CusProductStatus } from "@autumn/shared"; const BREAK_API_VERSION = 0.2; @@ -22,12 +21,12 @@ export const ADMIN_USER_IDs = [ "K7NDwSwohMCV9BXJ3Yb5MxgeXhWcwj0L", // charlie sandbox ]; -export const dashboardOrigins = [ +export const getDashboardOrigins = (env: Env) => [ "http://localhost:3000", "https://app.useautumn.com", "https://staging.useautumn.com", "https://dev.useautumn.com", - runtimeEnv.CLIENT_URL!, + env.CLIENT_URL!, ]; export const WEBHOOK_EVENTS = [ diff --git a/server/src/utils/encryptUtils.ts b/server/src/utils/encryptUtils.ts index 491890075..f546f47e3 100644 --- a/server/src/utils/encryptUtils.ts +++ b/server/src/utils/encryptUtils.ts @@ -1,18 +1,17 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { AppEnv } from "@autumn/shared"; import crypto from "crypto"; import KSUID from "ksuid"; -const getKey = () => { +const getKey = (env: Env) => { return crypto .createHash("sha512") - .update(runtimeEnv.ENCRYPTION_PASSWORD!) + .update(env.ENCRYPTION_PASSWORD!) .digest("hex") .substring(0, 32); }; -export function encryptData(data: string) { - const key = getKey(); +export function encryptData(data: string, env: Env) { + const key = getKey(env); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); @@ -26,14 +25,14 @@ export function encryptData(data: string) { return result.toString("base64"); } -export function decryptData(encryptedData: string) { +export function decryptData(encryptedData: string, env: Env) { const buffer = Buffer.from(encryptedData, "base64"); // Extract IV and encrypted data const iv = buffer.slice(0, 16); const encrypted = buffer.slice(16); - const key = getKey(); + const key = getKey(env); const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); const decrypted = Buffer.concat([ diff --git a/server/src/utils/envUtils.ts b/server/src/utils/envUtils.ts index 551eca0b7..5057f96d2 100644 --- a/server/src/utils/envUtils.ts +++ b/server/src/utils/envUtils.ts @@ -1,140 +1,22 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - -let hasLoadedLocalEnv = false; -const shouldLogLocalEnvLoading = false; - -type ProcessLike = { - env?: Record; -}; - type MutableEnv = Partial & Record; -const getProcessEnv = () => - (globalThis as typeof globalThis & { process?: ProcessLike }).process?.env; - -let injectedEnv: MutableEnv = getProcessEnv() ?? {}; - -export const setRuntimeEnv = (env: Partial) => { - const nextEnv: MutableEnv = {}; - Object.assign(nextEnv, env); - injectedEnv = nextEnv; -}; - -export const setRuntimeEnvFromProcess = () => { - const processEnv = getProcessEnv(); - if (!processEnv) return; - setRuntimeEnv(processEnv); -}; - -export const getRuntimeEnvValue = (key: string) => injectedEnv[key]; - -export const setRuntimeEnvValue = (key: string, value: string | undefined) => { - injectedEnv[key] = value; - const processEnv = getProcessEnv(); - if (processEnv) processEnv[key] = value; -}; - -export const runtimeEnv = new Proxy({} as Env, { - get(_target, prop: string | symbol) { - if (typeof prop !== "string") return undefined; - return injectedEnv[prop]; - }, - set(_target, prop: string | symbol, value) { - if (typeof prop === "string") { - setRuntimeEnvValue(prop, value); - } - return true; - }, - has(_target, prop: string | symbol) { - return typeof prop === "string" && prop in injectedEnv; - }, - ownKeys() { - return Reflect.ownKeys(injectedEnv); - }, - getOwnPropertyDescriptor(_target, prop: string | symbol) { - if (typeof prop !== "string" || !(prop in injectedEnv)) return undefined; - return { - configurable: true, - enumerable: true, - value: injectedEnv[prop], - writable: true, - }; - }, -}); - -/** - * Resolve the directory holding `.env`, robust to cwd: - * - cwd already a `server/` dir - * - cwd is `autumn/` (typical workspace root) - * - cwd is the monorepo root (one level above `autumn/`) — happens with - * `bun test autumn/...` invocations from VSCode tasks - */ -const resolveServerDir = (): string => { - const cwd = process.cwd(); - const candidates = [cwd, join(cwd, "server"), join(cwd, "autumn", "server")]; - for (const dir of candidates) { - if (existsSync(join(dir, "package.json"))) return dir; +export const requireEnv = ( + env: Partial, + key: K, + context: string, +): NonNullable => { + const value = env[key]; + if (value === undefined || value === null || value === "") { + throw new Error(`Missing required env ${key} for ${context}`); } - // Fall back to first guess so local env loading silently no-ops if missing. - return cwd.includes("server") ? cwd : join(cwd, "server"); + return value as NonNullable; }; -const parseDotenv = (contents: string): Record => { - const parsed: Record = {}; - - for (const rawLine of contents.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - - const normalized = line.startsWith("export ") ? line.slice(7).trim() : line; - const equalsIndex = normalized.indexOf("="); - if (equalsIndex <= 0) continue; - - const key = normalized.slice(0, equalsIndex).trim(); - let value = normalized.slice(equalsIndex + 1).trim(); - - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - parsed[key] = value; - } - - return parsed; -}; - -export const loadLocalEnv = ({ force = false }: { force?: boolean } = {}) => { - if (hasLoadedLocalEnv && !force) return; - hasLoadedLocalEnv = true; - - const serverDir = resolveServerDir(); - - // Determine which env file to load based on ENV_FILE environment variable - // Defaults to .env if not specified - const envFileName = runtimeEnv.ENV_FILE || ".env"; - const envPath = join(serverDir, envFileName); - - // Load local .env file FIRST - these will take precedence over Infisical - if (existsSync(envPath)) { - const parsed = parseDotenv(readFileSync(envPath, "utf8")); - if (shouldLogLocalEnvLoading) { - // Use stderr so output doesn't pollute stdout for scripts using shell substitution - console.error( - `Loading ${Object.keys(parsed).length} variables from ${envFileName}`, - ); - } - for (const [key, value] of Object.entries(parsed)) { - setRuntimeEnvValue(key, value); - } - } else { - if (shouldLogLocalEnvLoading) { - console.error( - `No ${envFileName} file found (using only Infisical secrets)`, - ); - } +export const mergeEnv = (...sources: Array | undefined>) => { + const merged: MutableEnv = {}; + for (const source of sources) { + if (!source) continue; + Object.assign(merged, source); } + return merged; }; diff --git a/server/src/utils/initUtils.ts b/server/src/utils/initUtils.ts index ff95aaeea..66e35682d 100644 --- a/server/src/utils/initUtils.ts +++ b/server/src/utils/initUtils.ts @@ -1,12 +1,14 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; -import { logger } from "@/external/logtail/logtailUtils.js"; -export const checkEnvVars = () => { - if (!runtimeEnv.DATABASE_URL) { +import { createLogger } from "@/external/logtail/logtailUtils.js"; + +export const checkEnvVars = (env: Env) => { + const logger = createLogger(env); + + if (!env.DATABASE_URL) { console.error(`DATABASE_URL is not set`); process.exit(1); } - if (!runtimeEnv.ENCRYPTION_IV || !runtimeEnv.ENCRYPTION_PASSWORD) { + if (!env.ENCRYPTION_IV || !env.ENCRYPTION_PASSWORD) { console.error( `ENCRYPTION_IV or ENCRYPTION_PASSWORD is not set (used for Stripe key encryption)`, ); @@ -14,33 +16,33 @@ export const checkEnvVars = () => { } if ( - !runtimeEnv.CACHE_URL && - !runtimeEnv.CACHE_URL_US_EAST && - !runtimeEnv.CACHE_BACKUP_URL?.trim() + !env.CACHE_URL && + !env.CACHE_URL_US_EAST && + !env.CACHE_BACKUP_URL?.trim() ) { logger.warn( "No Redis URL set (CACHE_URL, CACHE_URL_US_EAST, or CACHE_BACKUP_URL), running without Redis", ); } - if (!runtimeEnv.BETTER_AUTH_SECRET || !runtimeEnv.BETTER_AUTH_URL) { + if (!env.BETTER_AUTH_SECRET || !env.BETTER_AUTH_URL) { console.error(`BETTER_AUTH_SECRET or BETTER_AUTH_URL is not set`); process.exit(1); } - if (!runtimeEnv.RESEND_API_KEY || !runtimeEnv.RESEND_DOMAIN) { + if (!env.RESEND_API_KEY || !env.RESEND_DOMAIN) { logger.warn( "RESEND_API_KEY or RESEND_DOMAIN is not set (use terminal for sign in OTP)", ); } - if (!runtimeEnv.SUPABASE_URL || !runtimeEnv.SUPABASE_SERVICE_KEY) { + if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_KEY) { logger.warn( `SUPABASE_URL or SUPABASE_SERVICE_KEY is not set, some actions will be skipped`, ); } - if (!runtimeEnv.SVIX_API_KEY) { + if (!env.SVIX_API_KEY) { logger.warn(`SVIX_API_KEY is not set, some actions will be skipped`); return; } diff --git a/server/src/utils/logging/initLogger.ts b/server/src/utils/logging/initLogger.ts index 48e9e23b6..c16c189e8 100644 --- a/server/src/utils/logging/initLogger.ts +++ b/server/src/utils/logging/initLogger.ts @@ -1,6 +1,9 @@ import { Writable } from "node:stream"; import pino from "pino"; -import { getAwsTaskIdentity } from "@/external/aws/ecs/awsTaskIdentity.js"; +import { + getAwsTaskIdentity, + resolveAwsTaskIdentity, +} from "@/external/aws/ecs/awsTaskIdentity.js"; /** * Fields that don't render in the formatted dev/local console output @@ -167,6 +170,7 @@ export type InitLoggerOptions = { export const initLogger = (options: InitLoggerOptions, env: Env) => { const { mode = "default" } = options; + void resolveAwsTaskIdentity(env); const streams: pino.StreamEntry[] = []; const nodeEnv = env.NODE_ENV as string | undefined; diff --git a/server/src/utils/memoryMonitor.ts b/server/src/utils/memoryMonitor.ts index 32f08a181..d63dca2a4 100644 --- a/server/src/utils/memoryMonitor.ts +++ b/server/src/utils/memoryMonitor.ts @@ -5,9 +5,9 @@ * Uses Axiom logger so metrics are queryable via type: "memory_log". */ -import { runtimeEnv } from "@/utils/envUtils.js"; import { monitorEventLoopDelay } from "node:perf_hooks"; -import { logger } from "../external/logtail/logtailUtils.js"; +import { createLogger } from "../external/logtail/logtailUtils.js"; +import type { Logger } from "../external/logtail/logtailUtils.js"; // Event loop lag histogram — samples at 100ms resolution at the C++ level. // No JS callbacks involved, negligible overhead. @@ -22,16 +22,26 @@ export function getEventLoopLagMs(): number { const DEFAULT_INTERVAL_MS = 60_000; // 1 minute let intervalHandle: ReturnType | null = null; +let _logger: Logger | null = null; +let _isDev = false; function toMB(bytes: number): number { return Math.round((bytes / 1024 / 1024) * 10) / 10; } +function getLogger(): Logger { + if (!_logger) { + throw new Error("Memory monitor not initialized — call startMemoryMonitor first"); + } + return _logger; +} + function logMemoryUsage(label: string) { - if (runtimeEnv.NODE_ENV === "development") { + if (_isDev) { return; } + const logger = getLogger(); const mem = process.memoryUsage(); const lagMeanMs = Math.round((lagHistogram.mean / 1e6) * 10) / 10; @@ -67,8 +77,12 @@ function logMemoryUsage(label: string) { */ export function startMemoryMonitor( label: string, + env: Env, intervalMs = DEFAULT_INTERVAL_MS, ) { + _logger = createLogger(env); + _isDev = env.NODE_ENV === "development"; + // Log immediately on start logMemoryUsage(label); diff --git a/server/src/utils/otel/FilteringSpanProcessor.ts b/server/src/utils/otel/FilteringSpanProcessor.ts index 47a53b1aa..d79624013 100644 --- a/server/src/utils/otel/FilteringSpanProcessor.ts +++ b/server/src/utils/otel/FilteringSpanProcessor.ts @@ -1,4 +1,3 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { type Context, SpanStatusCode } from "@opentelemetry/api"; import type { ReadableSpan, @@ -7,15 +6,12 @@ import type { } from "@opentelemetry/sdk-trace-base"; import { recordSpanDurationMetric } from "./spanMetrics.js"; -const REDIS_SUCCESS_SAMPLE_RATE = Number.parseFloat( - runtimeEnv.OTEL_REDIS_SUCCESS_SAMPLE_RATE ?? "0.01", -); - -const normalizedRedisSuccessSampleRate = Number.isFinite( - REDIS_SUCCESS_SAMPLE_RATE, -) - ? Math.min(Math.max(REDIS_SUCCESS_SAMPLE_RATE, 0), 1) - : 0.01; +const resolveSampleRate = (env: Env): number => { + const raw = Number.parseFloat(env.OTEL_REDIS_SUCCESS_SAMPLE_RATE ?? "0.01"); + return Number.isFinite(raw) + ? Math.min(Math.max(raw, 0), 1) + : 0.01; +}; const hashStringToUnitInterval = (value: string): number => { let hash = 2166136261; @@ -32,20 +28,24 @@ const isSuccessfulNonSlowRedisSpan = (span: ReadableSpan) => span.status.code === SpanStatusCode.OK && span.attributes["db.redis.slow"] !== true; -const shouldDropSuccessfulRedisSpan = (span: ReadableSpan): boolean => { - if (!isSuccessfulNonSlowRedisSpan(span)) return false; - if (normalizedRedisSuccessSampleRate >= 1) return false; - if (normalizedRedisSuccessSampleRate <= 0) return true; - - const spanContext = span.spanContext(); - const sampleKey = `${spanContext.traceId}:${spanContext.spanId}:${span.name}`; - return ( - hashStringToUnitInterval(sampleKey) >= normalizedRedisSuccessSampleRate - ); -}; - export class FilteringSpanProcessor implements SpanProcessor { - constructor(private readonly delegate: SpanProcessor) {} + private readonly sampleRate: number; + + constructor(private readonly delegate: SpanProcessor, env: Env) { + this.sampleRate = resolveSampleRate(env); + } + + private shouldDropSuccessfulRedisSpan(span: ReadableSpan): boolean { + if (!isSuccessfulNonSlowRedisSpan(span)) return false; + if (this.sampleRate >= 1) return false; + if (this.sampleRate <= 0) return true; + + const spanContext = span.spanContext(); + const sampleKey = `${spanContext.traceId}:${spanContext.spanId}:${span.name}`; + return ( + hashStringToUnitInterval(sampleKey) >= this.sampleRate + ); + } onStart(span: Span, parentContext: Context): void { this.delegate.onStart(span, parentContext); @@ -54,7 +54,7 @@ export class FilteringSpanProcessor implements SpanProcessor { onEnd(span: ReadableSpan): void { recordSpanDurationMetric(span); - if (shouldDropSuccessfulRedisSpan(span)) return; + if (this.shouldDropSuccessfulRedisSpan(span)) return; this.delegate.onEnd(span); } diff --git a/server/src/utils/posthog.ts b/server/src/utils/posthog.ts index e938a4fca..8ff95499f 100644 --- a/server/src/utils/posthog.ts +++ b/server/src/utils/posthog.ts @@ -1,26 +1,35 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import { PostHog } from "posthog-node"; -const posthogClient = runtimeEnv.POSTHOG_API_KEY - ? new PostHog(runtimeEnv.POSTHOG_API_KEY, { - host: runtimeEnv.POSTHOG_HOST || "https://us.i.posthog.com", - }) - : null; +let _posthogClient: PostHog | null = null; + +const getPostHogClient = (env: Env): PostHog | null => { + if (!env.POSTHOG_API_KEY) { + return null; + } + if (!_posthogClient) { + _posthogClient = new PostHog(env.POSTHOG_API_KEY, { + host: env.POSTHOG_HOST || "https://us.i.posthog.com", + }); + } + return _posthogClient; +}; -// Helper for capturing events with org group export const captureOrgEvent = async ({ + env, orgId, event, properties = {}, }: { + env: Env; orgId: string; event: string; properties?: Record; }) => { - if (!posthogClient) return; + const client = getPostHogClient(env); + if (!client) return; try { - await posthogClient.capture({ + await client.capture({ distinctId: orgId, event, properties: { diff --git a/server/src/utils/scriptUtils/scriptUtils.ts b/server/src/utils/scriptUtils/scriptUtils.ts index fc35a0710..d4200999a 100644 --- a/server/src/utils/scriptUtils/scriptUtils.ts +++ b/server/src/utils/scriptUtils/scriptUtils.ts @@ -9,7 +9,6 @@ import { createLogger } from "@/external/logtail/logtailUtils.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; import { ProductService } from "@/internal/products/ProductService.js"; -import { runtimeEnv } from "@/utils/envUtils.js"; import { timeout } from "@/utils/genUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; import { createReadOnlyStripeCli } from "./readOnlyStripe.js"; @@ -226,9 +225,11 @@ export const saveCusSubsAndProducts = async ({ export const initScript = async ({ orgId, env, + bindings, }: { orgId: string; env: AppEnv; + bindings: Env; }) => { const [org, autumnProducts, features] = await Promise.all([ OrgService.get({ db, orgId }), @@ -246,7 +247,7 @@ export const initScript = async ({ const stripeCli: Stripe = createStripeCli({ org, env }); - const logger = createLogger(runtimeEnv); + const logger = createLogger(bindings); const req: ExtendedRequest = { orgId, @@ -302,7 +303,7 @@ export const initReadScript = async ({ const stripeCliRaw: Stripe = createStripeCli({ org, env }); const stripeCli = createReadOnlyStripeCli(stripeCliRaw); - const logger = createLogger(runtimeEnv); + const logger = createLogger(bindings); const req: ExtendedRequest = { orgId, diff --git a/server/src/workers.ts b/server/src/workers.ts index 69278ae5c..e7730cebe 100644 --- a/server/src/workers.ts +++ b/server/src/workers.ts @@ -1,8 +1,8 @@ -import { runtimeEnv } from "@/utils/envUtils.js"; import cluster from "node:cluster"; +import { initHatchet } from "./external/hatchet/initHatchet.js"; import { initInfisical } from "./external/infisical/initInfisical.js"; -import { logger } from "./external/logtail/logtailUtils.js"; +import { createLogger } from "./external/logtail/logtailUtils.js"; import { startAllEdgeConfigPolling, stopAllEdgeConfigPolling, @@ -13,122 +13,124 @@ import "./internal/misc/redisV2Cache/redisV2CacheStore.js"; import "./internal/misc/cacheV2Ramp/cacheV2RampStore.js"; import "./internal/misc/jobQueues/jobQueueStore.js"; -// Number of worker processes (defaults to CPU cores) -const NUM_PROCESSES = runtimeEnv.NODE_ENV === "development" ? 3 : 4; +export const startWorkers = async (initialEnv: Env) => { + const env = await initInfisical(initialEnv); + const logger = createLogger(env); -// Track if we're shutting down -let isShuttingDown = false; + // Number of worker processes (capped at 4) + const NUM_PROCESSES = env.NODE_ENV === "development" ? 3 : 4; -import { startMemoryMonitor } from "./utils/memoryMonitor.js"; + // Track if we're shutting down + let isShuttingDown = false; -if (cluster.isPrimary) { - await initInfisical(); + const { startMemoryMonitor } = await import("./utils/memoryMonitor.js"); - // const { initHatchetWorker } = await import("./queue/initWorkers.js"); - // await initHatchetWorker(); + if (cluster.isPrimary) { + // const { initHatchetWorker } = await import("./queue/initWorkers.js"); + // await initHatchetWorker(); - console.log(`Starting ${NUM_PROCESSES} worker processes`); - console.log(`SQS URL: ${runtimeEnv.SQS_QUEUE_URL_V2}`); + console.log(`Starting ${NUM_PROCESSES} worker processes`); + console.log(`SQS URL: ${env.SQS_QUEUE_URL_V2}`); - // Fork workers - for (let i = 0; i < NUM_PROCESSES; i++) { - cluster.fork(); - } - - // Graceful shutdown handler for primary process - const shutdown = async () => { - if (isShuttingDown) return; - isShuttingDown = true; - - console.log( - "\n🛑 Received shutdown signal, gracefully shutting down workers...", - ); - - // Send SIGTERM to all workers - for (const id in cluster.workers) { - cluster.workers[id]?.kill("SIGTERM"); - } - - // Give workers 10 seconds to finish, then force exit - const shutdownTimeout = setTimeout(() => { - console.log("⚠️ Shutdown timeout reached, forcing exit"); - process.exit(0); - }, 10000); - - if (shutdownTimeout.unref) { - shutdownTimeout.unref(); - } - - // Wait for all workers to exit gracefully - const checkWorkers = setInterval(() => { - const aliveWorkers = Object.keys(cluster.workers || {}).length; - if (aliveWorkers === 0) { - clearInterval(checkWorkers); - clearTimeout(shutdownTimeout); - console.log("✅ All workers shut down gracefully"); - process.exit(0); - } - }, 100); - }; - - process.on("SIGTERM", shutdown); - process.on("SIGINT", shutdown); - - // Handle worker exits - cluster.on("exit", (worker, code, signal) => { - if (isShuttingDown) { - console.log(`[Worker ${worker.process.pid}] Exited gracefully`); - return; - } - - console.log( - `⚠️ Worker ${worker.process.pid} died unexpectedly (${signal || code}). Restarting...`, - ); - - if (runtimeEnv.NODE_ENV === "development") { - process.exit(1); - } else { + // Fork workers + for (let i = 0; i < NUM_PROCESSES; i++) { cluster.fork(); } - }); -} else { - // Worker process — start OTel SDK so child spans (Stripe/Redis/Drizzle/ - // withSpan/withWorkerSpan) export to Axiom. - await import("./instrumentation.js"); - const startupStartedAt = Date.now(); - const queueImplementation = "SQS"; - startMemoryMonitor("worker", 60_000); - await startAllEdgeConfigPolling({ logger }); + // Graceful shutdown handler for primary process + const shutdown = async () => { + if (isShuttingDown) return; + isShuttingDown = true; - const { db } = await import("./db/initDrizzle.js"); - const { primeRedisMonitor } = await import( - "./external/redis/initUtils/redisAvailability.js" - ); - const { primeRedisV2Monitor, startRedisV2Monitor, stopRedisV2Monitor } = - await import("./external/redis/initUtils/redisV2Availability.js"); - const { startRedisMonitor, stopRedisMonitor } = await import( - "./external/redis/initRedis.js" - ); - const { preWarmOrgRedisConnections } = await import( - "./external/redis/orgRedisPool.js" - ); + console.log( + "\n🛑 Received shutdown signal, gracefully shutting down workers...", + ); - await Promise.all([primeRedisMonitor(), primeRedisV2Monitor()]); - startRedisMonitor(); - startRedisV2Monitor(); + // Send SIGTERM to all workers + for (const id in cluster.workers) { + cluster.workers[id]?.kill("SIGTERM"); + } - void preWarmOrgRedisConnections({ db }).catch((error) => { - logger.warn("[OrgRedis] Warmup failed", { error }); - }); + // Give workers 10 seconds to finish, then force exit + const shutdownTimeout = setTimeout(() => { + console.log("⚠️ Shutdown timeout reached, forcing exit"); + process.exit(0); + }, 10000); - process.once("exit", () => { - stopAllEdgeConfigPolling(); - stopRedisMonitor(); - stopRedisV2Monitor(); - }); + if (shutdownTimeout.unref) { + shutdownTimeout.unref(); + } - const { initWorkers } = await import("./queue/initWorkers.js"); - await initWorkers({ startupStartedAt, queueImplementation }); - // SQS implementation handles its own SIGTERM/SIGINT -} + // Wait for all workers to exit gracefully + const checkWorkers = setInterval(() => { + const aliveWorkers = Object.keys(cluster.workers || {}).length; + if (aliveWorkers === 0) { + clearInterval(checkWorkers); + clearTimeout(shutdownTimeout); + console.log("✅ All workers shut down gracefully"); + process.exit(0); + } + }, 100); + }; + + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + + // Handle worker exits + cluster.on("exit", (worker, code, signal) => { + if (isShuttingDown) { + console.log(`[Worker ${worker.process.pid}] Exited gracefully`); + return; + } + + console.log( + `⚠️ Worker ${worker.process.pid} died unexpectedly (${signal || code}). Restarting...`, + ); + + if (env.NODE_ENV === "development") { + process.exit(1); + } else { + cluster.fork(); + } + }); + } else { + // Worker process — start OTel SDK so child spans (Stripe/Redis/Drizzle/ + // withSpan/withWorkerSpan) export to Axiom. + await import("./instrumentation.js"); + + startMemoryMonitor("worker", env, 60_000); + await startAllEdgeConfigPolling({ logger }); + + const { db } = await import("./db/initDrizzle.js"); + const { primeRedisMonitor } = await import( + "./external/redis/initUtils/redisAvailability.js" + ); + const { primeRedisV2Monitor, startRedisV2Monitor, stopRedisV2Monitor } = + await import("./external/redis/initUtils/redisV2Availability.js"); + const { startRedisMonitor, stopRedisMonitor } = await import( + "./external/redis/initRedis.js" + ); + const { preWarmOrgRedisConnections } = await import( + "./external/redis/orgRedisPool.js" + ); + + await Promise.all([primeRedisMonitor(), primeRedisV2Monitor()]); + startRedisMonitor(); + startRedisV2Monitor(); + + void preWarmOrgRedisConnections({ db }).catch((error) => { + logger.warn("[OrgRedis] Warmup failed", { error }); + }); + + process.once("exit", () => { + stopAllEdgeConfigPolling(); + stopRedisMonitor(); + stopRedisV2Monitor(); + }); + + initHatchet(env); + const { initWorkers } = await import("./queue/initWorkers.js"); + await initWorkers(); + // SQS implementation handles its own SIGTERM/SIGINT + } +}; diff --git a/server/tests/setup-integration-tests.ts b/server/tests/setup-integration-tests.ts index b0d7e8a58..d11843611 100644 --- a/server/tests/setup-integration-tests.ts +++ b/server/tests/setup-integration-tests.ts @@ -1,5 +1,4 @@ import { execSync } from "node:child_process"; -import { loadLocalEnv } from "@/utils/envUtils"; import type { TestContext } from "./utils/testInitUtils/createTestContext"; const loadInfisicalSecrets = async () => { @@ -50,7 +49,6 @@ declare global { console.log("--- Setup integration tests ---"); await loadInfisicalSecrets(); -loadLocalEnv({ force: true }); // Unit-only lanes don't set TESTS_ORG; silently skip there. Anything else // must succeed — a swallowed init failure here resurfaces as the opaque diff --git a/server/tests/setupMain.ts b/server/tests/setupMain.ts index ff0460e0d..8fa5e8ac0 100644 --- a/server/tests/setupMain.ts +++ b/server/tests/setupMain.ts @@ -1,6 +1,3 @@ -import { loadLocalEnv } from "../src/utils/envUtils"; - -loadLocalEnv(); import { AppEnv } from "@autumn/shared"; import { setupOrg } from "@tests/utils/setup/setupOrg.js";