diff --git a/apps/gittensory-ui/wrangler.jsonc b/apps/gittensory-ui/wrangler.jsonc index ba4046e5ee..cd8daab4a9 100644 --- a/apps/gittensory-ui/wrangler.jsonc +++ b/apps/gittensory-ui/wrangler.jsonc @@ -27,5 +27,10 @@ "pattern": "gittensory.aethereal.dev", "custom_domain": true, }, + // LoopOver rebrand (issue #4762, Step 1): live side-by-side with the domain above, not a replacement yet. + { + "pattern": "loopover.ai", + "custom_domain": true, + }, ], } diff --git a/src/auth/github-oauth.ts b/src/auth/github-oauth.ts index ed37eebd18..46f0ae931d 100644 --- a/src/auth/github-oauth.ts +++ b/src/auth/github-oauth.ts @@ -245,8 +245,13 @@ function normalizeReturnTo(env: Env, value: string | undefined): string { // two lines up), so a separate hardcoded entry here was dead weight once a self-hoster sets their own // PUBLIC_SITE_ORIGIN -- it kept accepting the cloud origin as a valid redirect target even for a self-host // instance that never uses it (#4615). Rely solely on siteOrigin. + const aliasOrigins = (env.PUBLIC_SITE_ORIGIN_ALIASES ?? "") + .split(",") + .map((alias) => alias.trim().replace(/\/$/, "")) + .filter(Boolean); const allowedOrigins = new Set([ siteOrigin.replace(/\/$/, ""), + ...aliasOrigins, "http://localhost:3000", "http://localhost:4173", "http://localhost:5173", diff --git a/src/env.d.ts b/src/env.d.ts index 60b7e54c1c..4416a6d542 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -72,6 +72,12 @@ declare global { * Deliberately NOT declared in this chunk; the review path keeps its current concurrency behavior. */ PUBLIC_API_ORIGIN?: string; PUBLIC_SITE_ORIGIN?: string; + /** Comma-separated extra origins (each `scheme://host[:port]`) allowed as a post-GitHub-OAuth `returnTo` + * redirect target, alongside PUBLIC_SITE_ORIGIN. Lets the web login flow work correctly from more than + * one live UI domain at once (e.g. during a brand/domain migration) without breaking PUBLIC_SITE_ORIGIN's + * single-value override contract for self-hosters. Unset ⇒ only PUBLIC_SITE_ORIGIN (+ localhost) is + * allowed, exactly as before this var existed. */ + PUBLIC_SITE_ORIGIN_ALIASES?: string; AI_SUMMARIES_ENABLED?: string; AI_PUBLIC_COMMENTS_ENABLED?: string; /** Model id for a genuine Cloudflare Workers AI binding only — no live deployment (hosted or self-host) diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index d22ea0c725..ec7c2d0657 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -789,6 +789,41 @@ describe("private-beta auth and rate limiting", () => { expect(local.returnTo).toBe("http://localhost:5173/app"); }); + it("PUBLIC_SITE_ORIGIN_ALIASES lets a login started on a second live domain return to that same domain (#4762)", async () => { + const env = createTestEnv({ + GITHUB_OAUTH_CLIENT_ID: "client-id", + GITHUB_OAUTH_CLIENT_SECRET: "client-secret", + PUBLIC_SITE_ORIGIN: "https://gittensory.aethereal.dev", + PUBLIC_SITE_ORIGIN_ALIASES: "https://loopover.ai, https://staging.loopover.ai", + }); + + // The primary PUBLIC_SITE_ORIGIN still works exactly as before. + const primary = await startGitHubWebOAuth(env, "https://gittensory-api.aethereal.dev/v1/auth/github/start", "https://gittensory.aethereal.dev/app/workbench"); + expect(primary.returnTo).toBe("https://gittensory.aethereal.dev/app/workbench"); + + // An aliased origin is accepted verbatim, not bounced to the primary origin's fallback. + const alias = await startGitHubWebOAuth(env, "https://gittensory-api.aethereal.dev/v1/auth/github/start", "https://loopover.ai/app/workbench"); + expect(alias.returnTo).toBe("https://loopover.ai/app/workbench"); + + // A second, comma-separated alias (with surrounding whitespace) is also accepted. + const secondAlias = await startGitHubWebOAuth(env, "https://gittensory-api.aethereal.dev/v1/auth/github/start", "https://staging.loopover.ai/app"); + expect(secondAlias.returnTo).toBe("https://staging.loopover.ai/app"); + + // An origin that is neither the primary nor an alias still falls back to the primary origin's /app. + const untrusted = await startGitHubWebOAuth(env, "https://gittensory-api.aethereal.dev/v1/auth/github/start", "https://evil.example/app"); + expect(untrusted.returnTo).toBe("https://gittensory.aethereal.dev/app"); + + // Unset PUBLIC_SITE_ORIGIN_ALIASES behaves exactly as before this var existed -- only the primary origin. + const noAliases = createTestEnv({ + GITHUB_OAUTH_CLIENT_ID: "client-id", + GITHUB_OAUTH_CLIENT_SECRET: "client-secret", + PUBLIC_SITE_ORIGIN: "https://gittensory.aethereal.dev", + }); + delete (noAliases as Partial).PUBLIC_SITE_ORIGIN_ALIASES; + const rejected = await startGitHubWebOAuth(noAliases, "https://gittensory-api.aethereal.dev/v1/auth/github/start", "https://loopover.ai/app/workbench"); + expect(rejected.returnTo).toBe("https://gittensory.aethereal.dev/app"); + }); + it("verifies the GitHub token audience before minting a session on the token-exchange path", async () => { const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", GITHUB_OAUTH_CLIENT_SECRET: "client-secret" }); const introspect = (appClientId: string | undefined) => diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 405ed4928a..34e9be5122 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 902e76d8bfe7cb797b601690c9e44c8d) +// Generated by Wrangler by running `wrangler types` (hash: d5851e3e7ea2bc8cd084f39b4328cb86) // Runtime types generated with workerd@1.20260701.1 2026-05-28 nodejs_compat interface __BaseEnv_Env { DB: D1Database; @@ -14,6 +14,7 @@ interface __BaseEnv_Env { GITTENSORY_DRIFT_ISSUE_REPO: "JSONbored/gittensory"; PUBLIC_API_ORIGIN: "https://gittensory-api.aethereal.dev"; PUBLIC_SITE_ORIGIN: "https://gittensory.aethereal.dev"; + PUBLIC_SITE_ORIGIN_ALIASES: "https://loopover.ai"; ADMIN_GITHUB_LOGINS: "JSONbored"; GITTENSORY_REVIEW_UNIFIED_COMMENT: "false"; GITTENSORY_REVIEW_INLINE_COMMENTS: "false"; @@ -102,6 +103,7 @@ declare namespace NodeJS { | "PUBLIC_API_ORIGIN" | "PUBLIC_REPO_STATS_ALLOWLIST" | "PUBLIC_SITE_ORIGIN" + | "PUBLIC_SITE_ORIGIN_ALIASES" | "SCORING_TIME_DECAY_ENABLED" >> {} } diff --git a/wrangler.jsonc b/wrangler.jsonc index 89099f1323..1fb51b625f 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -40,6 +40,9 @@ "GITTENSORY_DRIFT_ISSUE_REPO": "JSONbored/gittensory", "PUBLIC_API_ORIGIN": "https://gittensory-api.aethereal.dev", "PUBLIC_SITE_ORIGIN": "https://gittensory.aethereal.dev", + // LoopOver rebrand (issue #4762, Step 1): loopover.ai runs live side-by-side with the existing domain, not + // as a replacement -- keeps the post-OAuth-login redirect working when a user signs in from loopover.ai. + "PUBLIC_SITE_ORIGIN_ALIASES": "https://loopover.ai", "ADMIN_GITHUB_LOGINS": "JSONbored", // Hosted reviews are retired. Cloudflare now serves the public API + Orb broker only; review execution runs // in the self-host container, where Redis-backed transient state is mandatory. The Cloudflare API worker no @@ -226,6 +229,11 @@ "pattern": "gittensory-api.aethereal.dev", "custom_domain": true, }, + // LoopOver rebrand (issue #4762, Step 1): live side-by-side with the domain above, not a replacement yet. + { + "pattern": "api.loopover.ai", + "custom_domain": true, + }, ], "d1_databases": [ {