refactor: pass env explicitly across all remaining request-path modules

- Remove last runtimeEnv import from processMessage.ts
- Add env: Env param to all functions with 'Cannot find name env' errors
- Convert initRedisV2 to lazy initialization (ensureRedisV2 + Proxy)
- Add Bindings: Env to StripeWebhookHonoEnv
- Fix 12+ files across external/, internal/, queue/ modules
- Scanning test passes: 1/1 (runtime-env-imports.test.ts)
This commit is contained in:
2026-06-17 07:08:41 -07:00
parent b5e75901a1
commit a98f11c214
180 changed files with 2334 additions and 1907 deletions

10
.vscode/settings.json vendored
View File

@@ -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"

View File

@@ -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.`);

88
scripts/fix-logger.ts Normal file
View File

@@ -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);
}
}

View File

@@ -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(

View File

@@ -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

View File

@@ -1,7 +1,3 @@
import { loadLocalEnv } from "../src/utils/envUtils";
loadLocalEnv();
const requireEnv = ({ key }: { key: string }) => {
const value = process.env[key];

View File

@@ -1,6 +1,3 @@
import { loadLocalEnv } from "../src/utils/envUtils";
loadLocalEnv();
import {
AppEnv,

View File

@@ -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";

View File

@@ -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";

View File

@@ -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);
};

View File

@@ -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);
});

View File

@@ -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)",

View File

@@ -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<typeof initDrizzle>["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<typeof initDrizzle>["client"];
export let db: DrizzleCli;
export let clientCritical: ReturnType<typeof initDrizzle>["client"];
export let dbCritical: DrizzleCli;
export let clientGeneral: ReturnType<typeof initDrizzle>["client"];
export let dbGeneral: DrizzleCli;
export let clientReplica: ReturnType<typeof initDrizzle>["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<typeof initDrizzle>["db"];
client = clientGeneral;
db = dbGeneral;
};

View File

@@ -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",
});
};

View File

@@ -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<string, RegisteredPool>();
let snapshotInterval: ReturnType<typeof setInterval> | 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()),

View File

@@ -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,
),
});

View File

@@ -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)

View File

@@ -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

View File

@@ -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;

View File

@@ -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<string, string>;
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<OrgConfig>;
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<TResponse> => {
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) {

View File

@@ -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<OrgConfig>;
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 ||

View File

@@ -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<HonoEnv>();
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");

View File

@@ -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<AwsTaskIdentity> => {
export const resolveAwsTaskIdentity = async (
env: Env,
): Promise<AwsTaskIdentity> => {
if (identityResolved && cachedIdentity) return cachedIdentity;
if (identityPromise) return identityPromise;
identityPromise = (async (): Promise<AwsTaskIdentity> => {
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> => {
`[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();

View File

@@ -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);

View File

@@ -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.<region>.amazonaws.com/<account>/<name> -> arn:aws:sqs:<region>:<account>:<name> */
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)",
);

View File

@@ -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));
};

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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})`,
});
}

View File

@@ -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;
};

View File

@@ -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<typeof HatchetClient.init> | 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;

View File

@@ -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<string, string | undefined> = {},
): Promise<InfisicalSyncEnvVar[]> =>
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,
});

View File

@@ -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<Env> => {
// 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<string, string> = {};
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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<Record<RedisV2InstanceName, string>> = {};
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<string | symbol, unknown>)[prop];
if (typeof value === "function") {
return value.bind(_redisV2);
}
return value;
},
});
const alternateInstanceUrls: Partial<Record<RedisV2InstanceName, string>> = {
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<RedisV2InstanceName, Redis>();
const missingUrlWarned = new Set<RedisV2InstanceName>();
@@ -39,10 +68,11 @@ const missingUrlWarned = new Set<RedisV2InstanceName>();
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<void> => {
if (!hasRedisV2Config) return;
if (!_env?.CACHE_V2_DRAGONFLY_URL?.trim()) return;
if (!_redisV2) return;
await waitForRedisReady(redisV2, "v2");
await waitForRedisReady(_redisV2, "v2");
};

View File

@@ -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";

View File

@@ -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 });

View File

@@ -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<string, Redis> = 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<string, Redis> = new Map();
export const redis: Redis = new Proxy({} as Redis, {
get(_target, prop) {
const inst = getRedis();
const value = (inst as Record<string | symbol, unknown>)[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 */

View File

@@ -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<string, string | undefined> = {};
// Map of region to cache URL. When CACHE_BACKUP_URL is set, all regions use it
// (failover / single backup endpoint).
const regionToCacheUrl: Record<string, string | undefined> = 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;

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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,

View File

@@ -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";

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<T extends (...args: any[]) => any>({
env,
fn,
action,
}: {
env: Env;
fn: T;
action: string;
}): (...args: Parameters<T>) => Promise<ReturnType<T> | undefined> {
const logger = createLogger(env);
return async (...args: Parameters<T>) => {
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}`,
);

View File

@@ -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,
}: {

View File

@@ -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<typeof initRevenuecatCli>;
* 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<Env>): 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<Env>;
}): 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<Env>;
}): Promise<"exists" | "created" | "skipped"> => {
const url = getRevenuecatWebhookUrl({ orgId, env });
const url = getRevenuecatWebhookUrl({ orgId, env, serverEnv });
if (!url) return "skipped";
const existing = await rcCli.listWebhookIntegrations();

View File

@@ -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);
};

View File

@@ -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")
) {

View File

@@ -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,

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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<StripeSubscriptionLock | null> => {
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;

View File

@@ -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}`,
);

View File

@@ -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`,
);

View File

@@ -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<StripeWebhookHonoEnv>,
next: Next,
) => {
if (runtimeEnv.NODE_ENV === "development") {
if (env.NODE_ENV === "development") {
await next();
return;
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -9,6 +9,7 @@ export interface StripeWebhookContext extends AutumnContext {
}
export type StripeWebhookHonoEnv = {
Bindings: Env;
Variables: {
ctx: StripeWebhookContext;
validated: boolean;

View File

@@ -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);

View File

@@ -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

View File

@@ -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;

View File

@@ -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<T extends (...args: any[]) => any>({
fn,
action,
env,
}: {
fn: T;
action: string;
env: Env;
}): (...args: Parameters<T>) => Promise<ReturnType<T> | undefined> {
return async (...args: Parameters<T>) => {
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}`);
}
};

View File

@@ -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 => {

View File

@@ -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}`,
);
}
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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`;
};

View File

@@ -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<HonoEnv>, next: Next) => {
const auth = createAuth(c.env);
const data = await auth.api.getSession({
headers: c.req.raw.headers,
});

View File

@@ -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,

View File

@@ -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<HonoEnv>, 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") ||

View File

@@ -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<HonoEnv>, 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,
});

View File

@@ -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<HonoEnv>, next: Next) => {
const logger = createLogger(c.env);
if (!shouldUseRedis()) return next();
try {

View File

@@ -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<HonoEnv>) => {
if (!_logger) {
initHealthCheck(c.env);
}
if (!startupReady) {
tryLatchStartupReady();
if (!startupReady) {

View File

@@ -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<typeof createLogger>) {
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<typeof createLogger>) {
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<typeof createLogger>) {
shuttingDown = true;
console.log("Shutting down worker, flushing telemetry and closing DB...");
try {

View File

@@ -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<HonoEnv>();
const auth = createAuth(env);
app.route("", createChatProxyRouter(env));

View File

@@ -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 };

View File

@@ -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<DrizzleCli, "delete" | "select">;
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 }),
),
),
);
});

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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<HonoEnv>) => {
const auth = createAuth(c.env);
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});

View File

@@ -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 }),
});
};

View File

@@ -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<HonoEnv>) => {
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,
});
};

View File

@@ -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<HonoEnv>) => {
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;
}

View File

@@ -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<HonoEnv>) => {
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,
});

View File

@@ -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<HonoEnv>) => {
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);
}

View File

@@ -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,

View File

@@ -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<HonoEnv>) =>
const oauthClientLookupLimiter = rateLimiter<HonoEnv>({
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);
});

View File

@@ -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<void> {
private async executeBatch(env: Env): Promise<void> {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
@@ -56,7 +56,7 @@ class BatchingManager {
await sendEventsToTinybird({
events: eventItems,
logger,
logger: createLogger(env),
});
}
}

View File

@@ -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,

View File

@@ -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(),
});
}

View File

@@ -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(),
});
}

View File

@@ -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);

View File

@@ -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<void> => {
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",

View File

@@ -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<void> => {
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",

Some files were not shown because too many files have changed in this diff Show More