Skip to content

Commit 5fcc12f

Browse files
willwearingclaude
andauthored
fix: add dynamic CORS for brand custom domains (#101)
Static ALLOWED_ORIGINS alone blocks requests from brand custom domains added after deploy. Query active brand domains from DB with a 5-min TTL cache so new brands work without redeploying. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2556e7b commit 5fcc12f

1 file changed

Lines changed: 41 additions & 4 deletions

File tree

backend/src/main.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import helmet from 'helmet';
1414
import { LoggingInterceptor } from './telemetry/logging.interceptor';
1515
import { OtelExceptionFilter } from './telemetry/exception.filter';
1616
import { PostHogService } from './shared/application/posthog.service';
17+
import { PrismaService } from './prisma/prisma.service';
1718

1819
async function bootstrap() {
1920
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
@@ -38,12 +39,48 @@ async function bootstrap() {
3839
transform: true,
3940
}));
4041

41-
const allowedOrigins = config.get<string>('ALLOWED_ORIGINS')?.split(',').filter(Boolean);
42-
if (config.get('NODE_ENV') === 'production' && (!allowedOrigins || allowedOrigins.length === 0)) {
43-
console.warn('⚠ ALLOWED_ORIGINS is not set — CORS will reject all cross-origin requests');
42+
// Static origins from env var (platform hosts, localhost, etc.)
43+
const staticOrigins = new Set(
44+
config.get<string>('ALLOWED_ORIGINS')?.split(',').filter(Boolean) ?? [],
45+
);
46+
47+
// Dynamic CORS: static origins are checked first, then brand domains from DB (cached 5 min)
48+
const prisma = app.get(PrismaService);
49+
let brandDomainCache: Set<string> = new Set();
50+
let cacheExpiresAt = 0;
51+
const CACHE_TTL_MS = 5 * 60 * 1000;
52+
53+
async function loadBrandDomains(): Promise<Set<string>> {
54+
const now = Date.now();
55+
if (now < cacheExpiresAt) return brandDomainCache;
56+
try {
57+
const brands = await prisma.brand.findMany({
58+
where: { isActive: true },
59+
select: { domain: true },
60+
});
61+
brandDomainCache = new Set(
62+
brands.map((b) => `https://${b.domain}`),
63+
);
64+
cacheExpiresAt = now + CACHE_TTL_MS;
65+
} catch (err) {
66+
console.error('Failed to load brand domains for CORS:', err);
67+
// Keep stale cache on error
68+
cacheExpiresAt = now + 30_000;
69+
}
70+
return brandDomainCache;
4471
}
72+
4573
app.enableCors({
46-
origin: allowedOrigins && allowedOrigins.length > 0 ? allowedOrigins : false,
74+
origin: async (origin, callback) => {
75+
// Allow requests with no origin (server-to-server, curl, etc.)
76+
if (!origin) return callback(null, true);
77+
if (staticOrigins.has(origin)) return callback(null, true);
78+
79+
const domains = await loadBrandDomains();
80+
if (domains.has(origin)) return callback(null, true);
81+
82+
callback(new Error(`Origin ${origin} not allowed by CORS`));
83+
},
4784
credentials: true,
4885
});
4986

0 commit comments

Comments
 (0)