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:
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -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"
|
||||
|
||||
72
scripts/fix-env-threading.ts
Normal file
72
scripts/fix-env-threading.ts
Normal 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
88
scripts/fix-logger.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { loadLocalEnv } from "../src/utils/envUtils";
|
||||
|
||||
loadLocalEnv();
|
||||
|
||||
const requireEnv = ({ key }: { key: string }) => {
|
||||
const value = process.env[key];
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { loadLocalEnv } from "../src/utils/envUtils";
|
||||
|
||||
loadLocalEnv();
|
||||
|
||||
import {
|
||||
AppEnv,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
12
server/src/external/ai/initAi.ts
vendored
12
server/src/external/ai/initAi.ts
vendored
@@ -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;
|
||||
|
||||
19
server/src/external/autumn/autumnCli.ts
vendored
19
server/src/external/autumn/autumnCli.ts
vendored
@@ -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) {
|
||||
|
||||
7
server/src/external/autumn/autumnRpcCli.ts
vendored
7
server/src/external/autumn/autumnRpcCli.ts
vendored
@@ -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 ||
|
||||
|
||||
@@ -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");
|
||||
|
||||
15
server/src/external/aws/ecs/awsTaskIdentity.ts
vendored
15
server/src/external/aws/ecs/awsTaskIdentity.ts
vendored
@@ -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();
|
||||
|
||||
5
server/src/external/aws/ecs/onAwsEcs.ts
vendored
5
server/src/external/aws/ecs/onAwsEcs.ts
vendored
@@ -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);
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
12
server/src/external/aws/s3/adminS3Config.ts
vendored
12
server/src/external/aws/s3/adminS3Config.ts
vendored
@@ -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",
|
||||
|
||||
33
server/src/external/axiom/initAxiom.ts
vendored
33
server/src/external/axiom/initAxiom.ts
vendored
@@ -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;
|
||||
|
||||
17
server/src/external/connect/connectUtils.ts
vendored
17
server/src/external/connect/connectUtils.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
50
server/src/external/connect/initStripeCli.ts
vendored
50
server/src/external/connect/initStripeCli.ts
vendored
@@ -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})`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
12
server/src/external/hatchet/initHatchet.ts
vendored
12
server/src/external/hatchet/initHatchet.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
56
server/src/external/infisical/initInfisical.ts
vendored
56
server/src/external/infisical/initInfisical.ts
vendored
@@ -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;
|
||||
|
||||
22
server/src/external/logtail/logtailUtils.ts
vendored
22
server/src/external/logtail/logtailUtils.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
75
server/src/external/redis/initRedisV2.ts
vendored
75
server/src/external/redis/initRedisV2.ts
vendored
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
2
server/src/external/redis/orgRedisPool.ts
vendored
2
server/src/external/redis/orgRedisPool.ts
vendored
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
2
server/src/external/redis/resolveRedisV2.ts
vendored
2
server/src/external/redis/resolveRedisV2.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
14
server/src/external/resend/loopsUtils.ts
vendored
14
server/src/external/resend/loopsUtils.ts
vendored
@@ -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,
|
||||
|
||||
18
server/src/external/resend/resendUtils.ts
vendored
18
server/src/external/resend/resendUtils.ts
vendored
@@ -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,
|
||||
|
||||
8
server/src/external/resend/safeResend.ts
vendored
8
server/src/external/resend/safeResend.ts
vendored
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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")
|
||||
) {
|
||||
|
||||
2
server/src/external/stripe/stripeCusUtils.ts
vendored
2
server/src/external/stripe/stripeCusUtils.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface StripeWebhookContext extends AutumnContext {
|
||||
}
|
||||
|
||||
export type StripeWebhookHonoEnv = {
|
||||
Bindings: Env;
|
||||
Variables: {
|
||||
ctx: StripeWebhookContext;
|
||||
validated: boolean;
|
||||
|
||||
@@ -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);
|
||||
|
||||
12
server/src/external/supabase/storageUtils.ts
vendored
12
server/src/external/supabase/storageUtils.ts
vendored
@@ -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
|
||||
|
||||
3
server/src/external/svix/svixHelpers.ts
vendored
3
server/src/external/svix/svixHelpers.ts
vendored
@@ -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;
|
||||
|
||||
|
||||
13
server/src/external/svix/svixUtils.ts
vendored
13
server/src/external/svix/svixUtils.ts
vendored
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
39
server/src/external/tinybird/initClickhouse.ts
vendored
39
server/src/external/tinybird/initClickhouse.ts
vendored
@@ -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 => {
|
||||
|
||||
40
server/src/external/tinybird/initTinybirdV2.ts
vendored
40
server/src/external/tinybird/initTinybirdV2.ts
vendored
@@ -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}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
23
server/src/external/tinybird/tinybirdUtils.ts
vendored
23
server/src/external/tinybird/tinybirdUtils.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user