From d0921bd9d0364db20302f8a78a558befcf97d3ab Mon Sep 17 00:00:00 2001 From: Ada Date: Thu, 16 Apr 2026 13:21:01 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20feat:=20Support=20mult?= =?UTF-8?q?iple=20CORS=20origins=20via=20comma-separated=20UI=5FBASE=5FURL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously CORS allowed UI_BASE_URL plus two hardcoded ironkitsune.tech origins. This meant adding a new domain required a code change and deploy. Now UI_BASE_URL can be a comma-separated list and drives both the CORS allowlist and the primary redirect URL. Changes: - env.ts: parse UI_BASE_URL as a list (trim, drop trailing slash) - env.ts: export UI_ALLOWED_ORIGINS (all entries) and keep UI_BASE_URL as the first entry (used for OAuth redirects) - app.ts: remove hardcoded ironkitsune.tech entries, use env list Prod .env needs updating to: UI_BASE_URL=https://thehumanpatternlab.com,https://ironkitsune.tech,https://www.ironkitsune.tech Co-authored-by: Sage --- src/app.ts | 21 +++++++++++++++++---- src/env.ts | 12 +++++++++++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/app.ts b/src/app.ts index 37c29f6..c7e8410 100644 --- a/src/app.ts +++ b/src/app.ts @@ -58,9 +58,21 @@ export function createApp() { const isTest = env.NODE_ENV === "test"; const isProd = env.NODE_ENV === "production"; - // This must match the browser's Origin exactly (no trailing slash). - // Example: "https://thehumanpatternlab.com" + // CORS origins come from UI_BASE_URL (comma-separated allowed). + // Browser Origin header must match EXACTLY (no trailing slash). + // Example: UI_BASE_URL="https://thehumanpatternlab.com,https://ironkitsune.tech" const uiOrigin = env.UI_BASE_URL ?? "http://localhost:5173"; + const allowedOrigins = + env.UI_ALLOWED_ORIGINS.length > 0 + ? env.UI_ALLOWED_ORIGINS + : ["http://localhost:5173"]; + const corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error(`CORS: origin ${origin} not allowed`)); + } + }; /* =========================================================== 5) CORS (BEFORE SESSION IF CROSS-ORIGIN) @@ -78,13 +90,13 @@ export function createApp() { =========================================================== */ app.use( cors({ - origin: uiOrigin, + origin: corsOrigin, credentials: true, }) ); // โœ… Force-handle ALL preflight requests - app.options(/.*/, cors({ origin: uiOrigin, credentials: true })); + app.options(/.*/, cors({ origin: corsOrigin, credentials: true })); app.use((req, _res, next) => { if (req.method === "OPTIONS") { console.log("๐Ÿงช Preflight:", req.headers.origin, req.headers["access-control-request-method"], req.url); @@ -240,3 +252,4 @@ export function createApp() { registerOpenApiRoutes(app); return app; } + diff --git a/src/env.ts b/src/env.ts index 8582d7d..fcc9986 100644 --- a/src/env.ts +++ b/src/env.ts @@ -16,6 +16,7 @@ type Env = { SESSION_SECRET?: string; UI_BASE_URL?: string; + UI_ALLOWED_ORIGINS: string[]; // optional OAuth vars (used by auth.ts via process.env) OAUTH_GITHUB_CLIENT_ID?: string; @@ -64,6 +65,14 @@ function normalizeNodeEnv(value: string): NodeEnv { throw new EnvError('Invalid NODE_ENV="' + value + '". Use "development", "test", or "production".'); } +function parseOriginList(value: string | undefined): string[] { + if (!value) return []; + return value + .split(",") + .map((s) => s.trim().replace(/\/$/, "")) // drop trailing slash + .filter((s) => s.length > 0); +} + function parsePort(value: string | undefined, fallback: number): number { if (value == null || value.trim() === "") return fallback; const n = Number(value); @@ -97,7 +106,8 @@ function validateEnv(input: NodeJS.ProcessEnv): Env { DB_PATH, SESSION_SECRET, - UI_BASE_URL: input.UI_BASE_URL?.trim(), + UI_BASE_URL: parseOriginList(input.UI_BASE_URL)[0], + UI_ALLOWED_ORIGINS: parseOriginList(input.UI_BASE_URL), OAUTH_GITHUB_CLIENT_ID: input.OAUTH_GITHUB_CLIENT_ID?.trim(), OAUTH_GITHUB_CLIENT_SECRET: input.OAUTH_GITHUB_CLIENT_SECRET?.trim(), From c0d1b964684501634b4dcc056738359cd71e8d5e Mon Sep 17 00:00:00 2001 From: Ada Date: Thu, 16 Apr 2026 13:26:10 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=A7=20fix:=20Use=20parsed=20env.UI?= =?UTF-8?q?=5FBASE=5FURL=20in=20adminRoutes=20OAuth=20redirects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit changed UI_BASE_URL to accept a comma-separated list, but adminRoutes was still reading process.env.UI_BASE_URL raw. With the new format that would produce a broken OAuth redirect like: https://thehumanpatternlab.com,https://ironkitsune.tech/admin/dashboard Switch adminRoutes to use env.UI_BASE_URL (the parsed first entry), so redirects go to the primary UI origin only. Also: - Remove dead uiOrigin variable in app.ts (no longer referenced) - Fix .env.example: UI_BASE_URL default was pointing at the API port (8001), now points at Vite's default (5173) with documentation showing the comma-separated syntax Co-authored-by: Sage --- .env.example | 7 ++++++- src/app.ts | 1 - src/routes/adminRoutes.ts | 6 ++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index f04f23e..b8421a0 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,12 @@ NODE_ENV=development # GitHub OAuth GITHUB_CLIENT_ID=your_client_id_here GITHUB_CLIENT_SECRET=your_secret_here -UI_BASE_URL=http://localhost:8001 +# UI origin(s). Comma-separated list โ€” ALL are added to the CORS allowlist. +# The first entry is the "primary" origin used for OAuth redirects. +# Examples: +# UI_BASE_URL=http://localhost:5173 +# UI_BASE_URL=https://thehumanpatternlab.com,https://ironkitsune.tech +UI_BASE_URL=http://localhost:5173 # DB DB_PATH=/path/to/lab.db diff --git a/src/app.ts b/src/app.ts index c7e8410..f3ab14e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -61,7 +61,6 @@ export function createApp() { // CORS origins come from UI_BASE_URL (comma-separated allowed). // Browser Origin header must match EXACTLY (no trailing slash). // Example: UI_BASE_URL="https://thehumanpatternlab.com,https://ironkitsune.tech" - const uiOrigin = env.UI_BASE_URL ?? "http://localhost:5173"; const allowedOrigins = env.UI_ALLOWED_ORIGINS.length > 0 ? env.UI_ALLOWED_ORIGINS diff --git a/src/routes/adminRoutes.ts b/src/routes/adminRoutes.ts index 2a3b041..0c688cd 100644 --- a/src/routes/adminRoutes.ts +++ b/src/routes/adminRoutes.ts @@ -7,6 +7,7 @@ import passport, { requireAdmin, isGithubOAuthEnabled } from "../auth.js"; import { requireAuth } from "../middleware/requireAuth.js"; import { syncLabNotesFromFs, SyncCounts } from "../services/syncLabNotesFromFs.js"; import { normalizeLocale, sha256Hex } from "../lib/helpers.js"; +import { env } from "../env.js"; marked.setOptions({ gfm: true, @@ -14,8 +15,9 @@ marked.setOptions({ }); export function registerAdminRoutes(app: any, db: Database.Database) { - // Must match your UI origin exactly (no trailing slash) - const UI_BASE_URL = process.env.UI_BASE_URL ?? "http://localhost:8001"; + // Primary UI origin (first entry of UI_BASE_URL). Used for OAuth redirects. + // For CORS allowlist behavior, see env.UI_ALLOWED_ORIGINS. + const UI_BASE_URL = env.UI_BASE_URL ?? "http://localhost:5173"; // --------------------------------------------------------------------------- // Admin: list Lab Notes (protected)