diff --git a/src/proxy/handler.test.ts b/src/proxy/handler.test.ts index 946e12b6a4..edeb8974f0 100644 --- a/src/proxy/handler.test.ts +++ b/src/proxy/handler.test.ts @@ -1608,6 +1608,81 @@ describe("Proxy Handler", () => { } }); + it("returns 404 for a hosted environment root that names no project", async () => { + // staging.veryfront.com parses as an environment root: a veryfront domain + // with slug null. It used to be forwarded with x-project-slug: "", which + // the runtime answers 502 "Missing project context" — a config gap + // reported as an upstream failure. It is the same condition a custom + // domain answers 404 for. + const { server, port } = createMockServer((req: Request) => { + const { pathname } = new URL(req.url); + if (pathname === "/auth/token") return createTokenResponse(); + return createNotFoundResponse(); + }); + + try { + const handler = createHandler(port); + + for ( + const host of [ + "staging.veryfront.com", + "preview.veryfront.com", + "production.veryfront.com", + "staging.veryfront.org", + ] + ) { + const ctx = await handler.processRequest( + new Request(`http://${host}/page`, { headers: { host } }), + ); + + assertEquals(ctx.projectSlug, undefined); + assertEquals(ctx.error?.status, 404); + assertEquals(ctx.error?.message, `No project configured for domain: ${host}`); + } + + await handler.close(); + } finally { + await server.shutdown(); + } + }); + + it("keeps project-less local dev hosts reachable for the project chooser", async () => { + // Locally a project-less veryfront host is not a misconfiguration: it is + // how the chooser is reached. ProjectsHandler is enabled for exactly + // `isVeryfrontDomain && !projectSlug`, so these must keep forwarding + // rather than 404 like their hosted counterparts. + const { server, port } = createMockServer((req: Request) => { + const { pathname } = new URL(req.url); + if (pathname === "/auth/token") return createTokenResponse(); + return createNotFoundResponse(); + }); + + try { + const handler = createHandler(port); + + for ( + const host of [ + "lvh.me", + "veryfront.me", + "veryfront.dev", + "preview.lvh.me", + "staging.lvh.me", + ] + ) { + const ctx = await handler.processRequest( + new Request(`http://${host}/`, { headers: { host } }), + ); + + assertEquals(ctx.error, undefined, `${host} must not be an error context`); + assertEquals(ctx.contentSourceId, "no-project"); + } + + await handler.close(); + } finally { + await server.shutdown(); + } + }); + it("returns 404 error when custom domain not found", async () => { const { server, port } = createMockServer((req: Request) => { const { pathname } = new URL(req.url); diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index 51dc77dfa7..bbe9d50508 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -1,5 +1,9 @@ import { TokenManager, type TokenScope } from "./token-manager.ts"; -import { type ParsedDomain, parseProjectDomain } from "#veryfront/server/utils/domain-parser.ts"; +import { + isHostedVeryfrontDomain, + type ParsedDomain, + parseProjectDomain, +} from "#veryfront/server/utils/domain-parser.ts"; import type { TokenCache } from "./cache/types.ts"; import { computeContentSourceId } from "#veryfront/cache/keys.ts"; import { getEnv } from "#veryfront/platform/compat/process.ts"; @@ -815,6 +819,23 @@ export function createProxyHandler(options: ProxyHandlerOptions) { }, logger); if (!projectSlug && parsedDomain.isVeryfrontDomain) { + // A hosted environment root (staging.veryfront.com) names no project and + // nothing downstream can supply one, so forwarding only sends + // x-project-slug: "" and earns 502 "Missing project context" — a + // configuration gap reported as an upstream failure. A custom domain in + // that state already answers 404. + // + // Locally the same shape means something else: on lvh.me and friends a + // project-less host is how the project chooser is reached, so those keep + // forwarding. See ProjectsHandler, enabled for exactly this state. + if (isHostedVeryfrontDomain(host)) { + logger?.info("No project for hosted veryfront domain", { host }); + return createProxyErrorContext(base, { + status: 404, + message: `No project configured for domain: ${host}`, + }); + } + return { token: undefined, projectSlug: undefined, diff --git a/src/server/utils/domain-parser.ts b/src/server/utils/domain-parser.ts index 44736b89b2..8bbd8a1e7a 100644 --- a/src/server/utils/domain-parser.ts +++ b/src/server/utils/domain-parser.ts @@ -178,6 +178,18 @@ export function parseProjectDomain(host: string): ParsedDomain { return createParsedDomain(null, null, null, false, false); } +/** + * Whether the host is a hosted veryfront domain (veryfront.com / veryfront.org) + * rather than one of the local development domains. + * + * The two differ in what a project-less host means. Locally it means "no project + * chosen yet" and the project chooser answers; hosted it means the domain names + * no project at all and nothing can answer. + */ +export function isHostedVeryfrontDomain(host: string): boolean { + return new RegExp(`^(?:.+\\.)?(${PROD_DOMAINS})$`, "i").test(stripPort(host)); +} + /** * Check if a domain is a valid veryfront domain (includes veryfront.me and lvh.me for local dev) */