Skip to content

Commit 23bc210

Browse files
authored
fix(seo): noindex non-production sim.ai hosts, redirect indexed 404s (#5988)
* fix(seo): noindex non-production sim.ai hosts, redirect indexed 404s Non-production deployments (dev.sim.ai, staging.sim.ai) serve the same build as www.sim.ai and were fully crawlable. Send X-Robots-Tag noindex for those hosts, and 301 seven marketing paths that an external SEO audit found returning 404. * fix(seo): parse only the first entry of a comma-joined forwarded host A multi-value x-forwarded-host survives the port split intact, so endsWith('.sim.ai') matched on the trailing entry and could apply noindex to the canonical site. Normalize with the same split(',')[0].trim() the rest of the repo uses for forwarded headers. * fix(seo): drop the /security redirect, complete the urls test mock security.txt advertises /security as its RFC 9116 Policy URI, so a permanent redirect to marketing would mislead that link and shadow a real policy page added later. urlsMock is installed globally and documents itself as carrying every real export, but was missing CANONICAL_SITE_HOST and isNonCanonicalSimHost — any test loading proxy.ts would have called undefined in track().
1 parent 4edc169 commit 23bc210

5 files changed

Lines changed: 129 additions & 2 deletions

File tree

apps/sim/lib/core/utils/urls.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
getBrowserOrigin,
1818
getSocketUrl,
1919
isLocalhostUrl,
20+
isNonCanonicalSimHost,
2021
isSafeHttpUrl,
2122
parseOriginList,
2223
} from '@/lib/core/utils/urls'
@@ -163,3 +164,41 @@ describe('isSafeHttpUrl', () => {
163164
expect(isSafeHttpUrl('http://')).toBe(false)
164165
})
165166
})
167+
168+
describe('isNonCanonicalSimHost', () => {
169+
it.each(['www.sim.ai', 'sim.ai', 'WWW.SIM.AI', 'www.sim.ai:443'])(
170+
'treats %s as the canonical marketing site',
171+
(host) => {
172+
expect(isNonCanonicalSimHost(host)).toBe(false)
173+
}
174+
)
175+
176+
it.each(['dev.sim.ai', 'www.dev.sim.ai', 'staging.sim.ai', 'prod.sockets.sim.ai'])(
177+
'treats %s as non-canonical',
178+
(host) => {
179+
expect(isNonCanonicalSimHost(host)).toBe(true)
180+
}
181+
)
182+
183+
it.each(['sim.example.com', 'localhost:3000', 'notsim.ai', 'sim.ai.evil.com'])(
184+
'leaves %s alone',
185+
(host) => {
186+
expect(isNonCanonicalSimHost(host)).toBe(false)
187+
}
188+
)
189+
190+
it.each(['www.sim.ai, dev.sim.ai', 'sim.ai,dev.sim.ai', ' www.sim.ai , staging.sim.ai'])(
191+
'classifies a comma-joined forwarded host by its first entry (%s)',
192+
(host) => {
193+
expect(isNonCanonicalSimHost(host)).toBe(false)
194+
}
195+
)
196+
197+
it('still flags a comma-joined host whose first entry is non-canonical', () => {
198+
expect(isNonCanonicalSimHost('dev.sim.ai, www.sim.ai')).toBe(true)
199+
})
200+
201+
it('does not throw on an empty host', () => {
202+
expect(isNonCanonicalSimHost('')).toBe(false)
203+
})
204+
})

apps/sim/lib/core/utils/urls.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { isProd } from '@/lib/core/config/env-flags'
44
/** Canonical base URL for the public-facing marketing site. No trailing slash. */
55
export const SITE_URL = 'https://www.sim.ai'
66

7+
/** Host of the canonical marketing site, e.g. `www.sim.ai`. */
8+
export const CANONICAL_SITE_HOST = new URL(SITE_URL).host
9+
710
function hasHttpProtocol(url: string): boolean {
811
return /^https?:\/\//i.test(url)
912
}
@@ -88,14 +91,36 @@ export function getBaseDomain(): string {
8891
}
8992
}
9093

94+
/** Drops a leading `www.` label, e.g. `www.sim.ai` -> `sim.ai`. */
95+
function stripWwwPrefix(host: string): string {
96+
return host.startsWith('www.') ? host.slice(4) : host
97+
}
98+
99+
/**
100+
* True for a sim.ai host that is not the canonical marketing site — dev.sim.ai,
101+
* staging.sim.ai, and their www variants serve the same build as www.sim.ai, so
102+
* search engines treat them as duplicates unless told otherwise.
103+
*
104+
* `sim.ai` and `www.sim.ai` are both canonical. Self-hosted domains return
105+
* false, as do lookalikes such as `notsim.ai`.
106+
*
107+
* Takes the first entry of a comma-joined forwarded host so a chained proxy
108+
* can't make the canonical site look non-canonical via a trailing entry.
109+
*/
110+
export function isNonCanonicalSimHost(host: string): boolean {
111+
const first = host.split(',')[0]?.trim() ?? ''
112+
const hostname = stripWwwPrefix(first.toLowerCase().split(':')[0])
113+
const canonical = stripWwwPrefix(CANONICAL_SITE_HOST)
114+
return hostname !== canonical && hostname.endsWith(`.${canonical}`)
115+
}
116+
91117
/**
92118
* Returns the domain for email addresses, stripping www subdomain for Resend compatibility
93119
* @returns The email domain (e.g., 'sim.ai' instead of 'www.sim.ai')
94120
*/
95121
export function getEmailDomain(): string {
96122
try {
97-
const baseDomain = getBaseDomain()
98-
return baseDomain.startsWith('www.') ? baseDomain.substring(4) : baseDomain
123+
return stripWwwPrefix(getBaseDomain())
99124
} catch (_e) {
100125
return isProd ? 'sim.ai' : 'localhost:3000'
101126
}

apps/sim/next.config.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,28 @@ const nextConfig: NextConfig = {
531531
}
532532
)
533533

534+
/**
535+
* Indexed 404s from an external SEO audit. The capability paths read as
536+
* tool/feature pages and map to the integrations catalog; the rest have no
537+
* closer successor than the homepage.
538+
*
539+
* `/security` is deliberately excluded: security.txt advertises it as the
540+
* RFC 9116 `Policy` URI, so a permanent redirect to marketing would both
541+
* mislead that link and shadow a real policy page added later.
542+
*/
543+
redirects.push(
544+
...['read', 'research', 'scrape'].map((slug) => ({
545+
source: `/${slug}`,
546+
destination: '/integrations',
547+
permanent: true,
548+
})),
549+
...['actions', 'crawl', 'fast'].map((slug) => ({
550+
source: `/${slug}`,
551+
destination: '/',
552+
permanent: true,
553+
}))
554+
)
555+
534556
return redirects
535557
},
536558
async rewrites() {

apps/sim/proxy.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getEnv } from './lib/core/config/env'
66
import { isAuthDisabled, isDev, isHosted } from './lib/core/config/env-flags'
77
import { generateRuntimeCSP } from './lib/core/security/csp'
88
import { getClientIp } from './lib/core/utils/request'
9+
import { isNonCanonicalSimHost } from './lib/core/utils/urls'
910

1011
const logger = createLogger('Proxy')
1112

@@ -297,10 +298,31 @@ export async function proxy(request: NextRequest) {
297298
return track(request, response)
298299
}
299300

301+
/**
302+
* Keeps non-production sim.ai deployments out of search results.
303+
*
304+
* `noindex` rather than a robots.txt `Disallow` is deliberate: a disallowed URL
305+
* can still be indexed when linked externally, and blocking the crawl stops
306+
* search engines from ever seeing the directive that removes pages already in
307+
* the index. robots.txt is excluded from this proxy's matcher so it keeps
308+
* serving the crawlable rules this header depends on.
309+
*/
310+
function applyIndexingPolicy(request: NextRequest, response: NextResponse): void {
311+
const host =
312+
request.headers.get('x-forwarded-host')?.split(',')[0]?.trim() ||
313+
request.headers.get('host') ||
314+
request.nextUrl.host
315+
316+
if (isNonCanonicalSimHost(host)) {
317+
response.headers.set('X-Robots-Tag', 'noindex, nofollow')
318+
}
319+
}
320+
300321
/**
301322
* Sends request data to Profound analytics (fire-and-forget) and returns the response.
302323
*/
303324
function track(request: NextRequest, response: NextResponse): NextResponse {
325+
applyIndexingPolicy(request, response)
304326
sendToProfound(request, response.status)
305327
return response
306328
}

packages/testing/src/mocks/urls.mock.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export const LOCALHOST_HOSTNAMES_MOCK: ReadonlySet<string> = new Set([
1010
'::1',
1111
])
1212

13+
/** Mirrors the real `CANONICAL_SITE_HOST` from `@/lib/core/utils/urls`. */
14+
export const CANONICAL_SITE_HOST_MOCK = 'www.sim.ai'
15+
1316
const DEFAULT_SOCKET_URL = 'http://localhost:3002'
1417
const DEFAULT_OLLAMA_URL = 'http://localhost:11434'
1518

@@ -72,6 +75,18 @@ function isLoopbackHostnameImpl(hostname: string): boolean {
7275
return LOCALHOST_HOSTNAMES_MOCK.has(hostname)
7376
}
7477

78+
/** Mirrors the real `stripWwwPrefix` from `@/lib/core/utils/urls`. */
79+
function stripWwwPrefix(host: string): string {
80+
return host.startsWith('www.') ? host.slice(4) : host
81+
}
82+
83+
function isNonCanonicalSimHostImpl(host: string): boolean {
84+
const first = host.split(',')[0]?.trim() ?? ''
85+
const hostname = stripWwwPrefix(first.toLowerCase().split(':')[0])
86+
const canonical = stripWwwPrefix(CANONICAL_SITE_HOST_MOCK)
87+
return hostname !== canonical && hostname.endsWith(`.${canonical}`)
88+
}
89+
7590
function parseOriginListImpl(
7691
raw: string | undefined | null,
7792
onInvalid?: (value: string) => void
@@ -156,6 +171,7 @@ export const urlsMockFns = {
156171
mockGetBaseDomain: vi.fn(getBaseDomainImpl),
157172
mockGetEmailDomain: vi.fn(getEmailDomainImpl),
158173
mockIsLoopbackHostname: vi.fn(isLoopbackHostnameImpl),
174+
mockIsNonCanonicalSimHost: vi.fn(isNonCanonicalSimHostImpl),
159175
mockParseOriginList: vi.fn(parseOriginListImpl),
160176
mockIsLocalhostUrl: vi.fn(isLocalhostUrlImpl),
161177
mockGetBrowserOrigin: vi.fn(getBrowserOriginImpl),
@@ -176,6 +192,7 @@ export function resetUrlsMock(): void {
176192
urlsMockFns.mockGetBaseDomain.mockReset().mockImplementation(getBaseDomainImpl)
177193
urlsMockFns.mockGetEmailDomain.mockReset().mockImplementation(getEmailDomainImpl)
178194
urlsMockFns.mockIsLoopbackHostname.mockReset().mockImplementation(isLoopbackHostnameImpl)
195+
urlsMockFns.mockIsNonCanonicalSimHost.mockReset().mockImplementation(isNonCanonicalSimHostImpl)
179196
urlsMockFns.mockParseOriginList.mockReset().mockImplementation(parseOriginListImpl)
180197
urlsMockFns.mockIsLocalhostUrl.mockReset().mockImplementation(isLocalhostUrlImpl)
181198
urlsMockFns.mockGetBrowserOrigin.mockReset().mockImplementation(getBrowserOriginImpl)
@@ -197,12 +214,14 @@ export function resetUrlsMock(): void {
197214
export const urlsMock = {
198215
SITE_URL: 'https://www.sim.ai',
199216
LOCALHOST_HOSTNAMES: LOCALHOST_HOSTNAMES_MOCK,
217+
CANONICAL_SITE_HOST: CANONICAL_SITE_HOST_MOCK,
200218
getBaseUrl: urlsMockFns.mockGetBaseUrl,
201219
getInternalApiBaseUrl: urlsMockFns.mockGetInternalApiBaseUrl,
202220
ensureAbsoluteUrl: urlsMockFns.mockEnsureAbsoluteUrl,
203221
getBaseDomain: urlsMockFns.mockGetBaseDomain,
204222
getEmailDomain: urlsMockFns.mockGetEmailDomain,
205223
isLoopbackHostname: urlsMockFns.mockIsLoopbackHostname,
224+
isNonCanonicalSimHost: urlsMockFns.mockIsNonCanonicalSimHost,
206225
parseOriginList: urlsMockFns.mockParseOriginList,
207226
isLocalhostUrl: urlsMockFns.mockIsLocalhostUrl,
208227
getBrowserOrigin: urlsMockFns.mockGetBrowserOrigin,

0 commit comments

Comments
 (0)