diff --git a/.github/workflows/publish-ghcr-platform.yaml b/.github/workflows/publish-ghcr-platform.yaml index e74f1f7..93bb72b 100644 --- a/.github/workflows/publish-ghcr-platform.yaml +++ b/.github/workflows/publish-ghcr-platform.yaml @@ -360,8 +360,9 @@ jobs: --volume "$volume:/data" \ "$APPLICATION_IMAGE" - # @note /signin renders per request, so its markup must carry the - # runtime site host stamped by the request context + # @note the markup stamps the configured origins from the runtime + # environment, so the probe proves SITE_URL supplied at run time + # reached the page rather than the image's build-time default for attempt in $(seq 1 90); do if docker exec "$container" node -e \ "fetch('http://127.0.0.1:3000/signin').then(async (response) => { const body = await response.text(); process.exit(response.status < 500 && body.includes('smoke-test.invalid') ? 0 : 1) }).catch(() => process.exit(1))" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f8915d..51c08c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,80 @@ here. The release version is defined in the workspace root `package.json`. ## [Unreleased] +## [0.2.1] - 2026-09-10 + +### Fixed + +- Use the runtime API URL in both Twilio webhook instructions and the install + popup, preserving the deployment scheme, port and route prefix. +- Carry the port everywhere a host is named. The `chatbotkit.host` cookie, the + `data-*-host` attributes and their fallbacks now all hold the host as the + request context records it; app, portal and partner lookups reduce it to a + hostname first, and a `HOSTS_CONFIG` mapping is selected by exact host, + then by hostname, so a deployment reached on a port + resolves its shells, the API, static and widget origins, portal sign-in, + partner domains, embed frame policies and canonical URLs the same way one + on the default port does. Portal origins minted from `PORTAL_APEX` take the + `SITE_URL` port, and apex links built in the browser follow the page port; + a target on a different port needs a `HOSTS_CONFIG` mapping. The + cookie is written by the proxy at runtime from the host the context trusts + and is `Secure` only when `SITE_URL` is https, so plain-http deployments + receive it too. +- Keep a mapped host that spells its default port on the page's scheme, and + let links to the page's own host follow the scheme and port the page was + reached on rather than the configured origin. +- Recognize mapped API hosts with explicit default ports in HTTP tools, and + bracket IPv6 addresses when the local proxy forwards a host with a port. +- Return app-host sign-ins to their app instead of the platform onboarding + route, which is not served on main, labs or individual app hosts. +- Advertise the mapped API host and its route prefix in the OpenAPI spec, + including deployments whose frontend and API use separate hosts. +- Retain the configured site port in app manifest IDs when no request host is + available. +- Use the runtime API URL in both Recall webhook instructions and the install + popup. +- Use the resolved deployment in API docs examples: raw HTTP includes the + required `/api` prefix on shared sites, and Node and Go clients use the + deployment's API origin. +- Select portal and partner authentication by hostname when the request host + includes a port. +- Advertise the OpenAPI server URL with the deployment's scheme when the + request carries none, so a plain-http deployment no longer publishes an + https server it does not serve. +- Use the deployment's scheme and port when the Slack integrations app builds + install manifests, including the configured-site fallback. +- Use the deployment's scheme and port in widget frame policies and preview + capture URLs, and resolve auto-widget partner branding by hostname. +- Include the deployment's scheme and port in portal URLs returned by GraphQL + and preserve the site port in generated portal hosts. +- Preserve static and widget host ports, resolve mapped asset URL schemes, and + include the served widget origin in MCP resource policies. +- Preserve configured ports in host values and read server-provided origins + when initializing browser configuration. +- Allow tool-specific MCP widgets to load their validated bundle origin in the + resource policy, including its scheme and port. +- Strip ports before portal login email domain and team invitation partner + lookups. +- Keep local testing proxy redirects on the browser's origin when accessed + through an HTTPS tunnel or a different local host and port. +- Honor the configured scheme of a separate API origin, including APIs on a + different port of the site hostname and HTTPS loopback origins. +- Clear a dedicated API's port when in-app HTTP tools translate its URL onto + a frontend origin using the default port. +- Send API playground requests through the frontend's actual port when the + entered API URL uses another port or omits it. +- Keep app install manifests discoverable when a host mapping assigns a + separate frontend hostname to the main or labs shell. +- Preserve static app asset paths, portal configuration lookup and missing-page + handling when the mapped frontend differs from the app or portal routing host. +- Resolve space-site storage from its routing hostname when its host mapping + names a separate frontend, preventing valid space sites from returning 404. +- Match Pipedream relative-path integrations by hostname when the target URL + includes a port. +- Resolve request-context host mappings by exact host first, then hostname, + and match API, static and app routing targets without their ports, including + IPv6 app origins. + ## [0.2.0] - 2026-09-08 ### Added @@ -19,6 +93,11 @@ here. The release version is defined in the workspace root `package.json`. without TLS, matching the existing websocket allowance. The app shells on a local http deployment load banners from the site or static origin, which the browser refused before. +- Recognise the labs shell host in the browser, so its sidebar links resolve + to `/` instead of `/apps/`, which the shell rewrite turned into a + 404. Only the main shell host was overlaid from the runtime attributes. +- Preserve explicit ports and bracket IPv6 addresses for both request and + asserted frontend hosts in the local reverse proxy. ## [0.1.0] - 2026-09-08 diff --git a/docs/configuration.md b/docs/configuration.md index 929ee3f..c1ce88c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -280,15 +280,23 @@ answers on several domain families. | Field | Purpose | | --------- | --------------------------------------------------------- | -| `match` | Exact incoming hostnames that select this mapping | +| `match` | Exact incoming hosts that select this mapping | | `site` | Site or application host for request-affine frontend URLs | | `api` | API host for request-affine API URLs and clean API routes | | `static` | Static host for public assets and static-host routing | | `widgets` | Host for private MCP widget bundles | -Values are exact hostnames without a protocol, wildcard, path, query, or hash. -Every target that can receive a request should also appear in `match`, so a -request arriving on an API or static host selects the same mapping. +Values are exact hosts - a hostname plus the port when the deployment is +reached on one - without a protocol, wildcard, path, query, or hash. A +mapping whose `match` names the exact request host is selected first, so +mappings may differ by port; otherwise selection is by hostname, so `match` +entries may be listed without the port. When several mappings share a +hostname and none names the request host exactly, the first in configuration +order is used. The `site`, `api`, `static` and +`widgets` targets are used verbatim and should carry the port when the +deployment does. Every target +that can receive a request should also appear in `match`, so a request +arriving on an API or static host selects the same mapping. Every `api` and `static` target is enabled at server startup. Targets that are also site hosts are excluded from host-specific routing, so a shared host @@ -297,8 +305,10 @@ continues serving the application and its API at `/api/v1`. Changing targets, the routing table. At runtime, context injection selects the mapping once when the authenticated frontend host or normalized request host appears in `match`. Server URL helpers read the resolved targets from that context. The HTML -document exposes only the resolved site, API, static, and widget hosts for -client hooks. An unknown host keeps the existing custom-domain behavior. +document exposes the resolved site, API, static, and widget hosts, the +configured origins and the API route decision for client hooks (see +Reverse-proxy headers below). An unknown host keeps the existing custom-domain +behavior. The proxy also uses this API host classification for browser security headers. A dedicated API host is exempt regardless of its name; an ordinary site named @@ -322,8 +332,8 @@ derivation: advertised API URLs follow `API_URL`, and unset it they stay on the site host. App shells and apex-based routing are controlled independently by the scalar variables above. -The configuration fails validation on malformed hostnames, missing fields, -unknown fields, or a hostname matched by more than one mapping. +The configuration fails validation on malformed hosts, missing fields, +unknown fields, or a host matched by more than one mapping. ## `ZONE_CONFIG` @@ -427,6 +437,21 @@ is disabled, the forwarded values are ignored and the application uses the ordinary `Host` header, the request URL protocol where available, and the directly connected socket address for rate limiting and audit records. +The same trusted host is what the platform hands the browser: the +`chatbotkit.host` cookie, written on every response with the forwarded host +when it is trusted and the `Host` header otherwise (port included, `Secure` +only when `SITE_URL` is https), and the `data-audience` and `data-*-host` +attributes on ``, which carry the request-affine site, static, widget +and API hosts, next to the `data-*-url` attributes carrying the configured +origins and `data-api-clean-routes`, the server's decision whether the API +host serves `/v1` at its root or under `/api`. A page rendered for a request +carries them all; a page prerendered at build time carries everything but the +request host itself, for which the browser falls back to the cookie and then +the configured site host. Note that a portal gateway's signed frontend +host assertion is not consulted for the cookie; on such pages the attributes +carry the public host and the cookie the gateway's upstream host. Client +code takes hosts from those sources and only the scheme from the page. + Behind a reverse proxy this flag also decides whether the sign-in abuse controls work at all: without it every client shares the proxy's socket address, so the per-address budgets for code issuance and verification become diff --git a/package.json b/package.json index f3c9f93..f32af01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "platform", - "version": "0.2.0", + "version": "0.2.1", "private": true, "license": "Apache-2.0", "packageManager": "pnpm@11.24.0", diff --git a/platform/app/apps/(adhoc)/b4d0c8f2/components.jsx b/platform/app/apps/(adhoc)/b4d0c8f2/components.jsx index eb791c5..6cb89ce 100644 --- a/platform/app/apps/(adhoc)/b4d0c8f2/components.jsx +++ b/platform/app/apps/(adhoc)/b4d0c8f2/components.jsx @@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react' -import { getExternalAPIHost, getExternalAPIHostURL } from '@/lib/host' +import useExternalAPIURL from '@/hooks/useExternalAPIURL' import { LuBookMarked, LuCopy, LuRefreshCcw, LuSearch } from 'react-icons/lu' import toast from '@/lib/toast' @@ -238,6 +238,8 @@ function Section({ title, badge, children }) { } function Inspector({ operation, loading }) { + const getAPIURL = useExternalAPIURL() + if (!operation) { return (
@@ -272,7 +274,7 @@ function Inspector({ operation, loading }) { /v1{operation.path} @@ -608,7 +610,8 @@ function CodeTabs({ tabs, activeTab, setActiveTab }) { function RequestCodeBlock({ requestSchema, method, path }) { const tabs = ['Node', 'Go', 'JavaScript', 'cURL', 'HTTP'] const [activeTab, setActiveTab] = useState(tabs[0]) - const url = getExternalAPIHostURL(`/v1${path}`) + const getAPIURL = useExternalAPIURL() + const url = getAPIURL(`/v1${path}`) function generateCurl(body) { return [ @@ -628,9 +631,11 @@ function RequestCodeBlock({ requestSchema, method, path }) { } function generateHttp(body) { + const endpoint = new URL(url) + return [ - `${method.toUpperCase()} /v1${path} HTTP/1.1`, - `Host: ${getExternalAPIHost()}`, + `${method.toUpperCase()} ${endpoint.pathname}${endpoint.search} HTTP/1.1`, + `Host: ${endpoint.host}`, 'Authorization: Bearer CBK_API_SECRET', ...(method !== 'get' ? ['Content-Type: application/json', '', toPrettyJson(body)] @@ -668,7 +673,8 @@ function RequestCodeBlock({ requestSchema, method, path }) { "import { ChatBotKit } from '@chatbotkit/sdk'", '', 'const cbk = new ChatBotKit({', - ' secret: process.env.CHATBOTKIT_API_KEY!', + ' secret: process.env.CHATBOTKIT_API_KEY!,', + ` baseUrl: ${JSON.stringify(new URL(url).origin)},`, '})', '', `const response = await cbk.${methodChain}(`, @@ -704,6 +710,7 @@ function RequestCodeBlock({ requestSchema, method, path }) { '', 'client := sdk.New(sdk.Options{', ' Secret: os.Getenv("CHATBOTKIT_API_KEY"),', + ` BaseURL: ${JSON.stringify(new URL(url).origin)},`, '})', '', `response, err := client.${clientChain}(ctx${ diff --git a/platform/app/apps/(adhoc)/b4d0c8f2/components.utest.jsx b/platform/app/apps/(adhoc)/b4d0c8f2/components.utest.jsx new file mode 100644 index 0000000..046ead9 --- /dev/null +++ b/platform/app/apps/(adhoc)/b4d0c8f2/components.utest.jsx @@ -0,0 +1,87 @@ +import useExternalAPIURL from '@/hooks/useExternalAPIURL' + +import { Main } from './components' + +import { fireEvent, render, screen } from '@testing-library/react' + +jest.mock('./server', () => ({ + fetchSpecOperation: jest.fn(), + listSpecOperations: jest.fn(), +})) + +jest.mock('@/hooks/useExternalAPIURL', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('@/components/CodeBlock', () => ({ + __esModule: true, + default: ({ children }) =>
{children}
, +})) + +describe('API documentation request snippets', () => { + it.each([ + ['Node', 'baseUrl: "http://console.localhost:3000"'], + ['Go', 'BaseURL: "http://console.localhost:3000"'], + ])('points the %s SDK example at the serving deployment', (tab, expected) => { + useExternalAPIURL.mockReturnValue( + (path) => `http://console.localhost:3000/api${path}` + ) + + render( +
+ ) + + fireEvent.click(screen.getByRole('button', { name: tab, exact: true })) + + expect(screen.getByTestId('request-code').textContent).toContain(expected) + }) + + it.each([ + [ + 'http://console.localhost:3000/api', + '/api/v1/bot/list', + 'console.localhost:3000', + ], + ['https://api.example.com', '/v1/bot/list', 'api.example.com'], + ])( + 'uses the resolved endpoint in the HTTP tab on %s', + (baseURL, pathname, host) => { + document.documentElement.dataset.apiHost = host + useExternalAPIURL.mockReturnValue((path) => `${baseURL}${path}`) + + try { + render( +
+ ) + + fireEvent.click( + screen.getByRole('button', { name: 'HTTP', exact: true }) + ) + + const snippet = screen.getByTestId('request-code').textContent + + expect(snippet).toContain(`GET ${pathname} HTTP/1.1`) + expect(snippet).toContain(`Host: ${host}`) + } finally { + delete document.documentElement.dataset.apiHost + } + } + ) +}) diff --git a/platform/app/apps/(adhoc)/f49c75da/server.ts b/platform/app/apps/(adhoc)/f49c75da/server.ts index f39315e..eb84416 100644 --- a/platform/app/apps/(adhoc)/f49c75da/server.ts +++ b/platform/app/apps/(adhoc)/f49c75da/server.ts @@ -1,6 +1,7 @@ 'use server' -import { siteHostname } from '@/config/site' +import { siteHost } from '@/config/site' +import { getExternalFrontendHostURL } from '@/lib/host' import type { UnwrapPromise } from '@chatbotkit-dev/typescript-utils/promise' @@ -53,7 +54,7 @@ export const listIntegrations = appActionHandler( icon: '@clearbit/slack.com', manifestUrl: buildSlackManifestInstallUrl( i, - `https://${context.host || siteHostname}` + new URL(getExternalFrontendHostURL('/', context.host || siteHost)).origin ), signingSecret: i.signingSecret, botToken: i.botToken, diff --git a/platform/app/apps/(adhoc)/f49c75da/server.utest.js b/platform/app/apps/(adhoc)/f49c75da/server.utest.js new file mode 100644 index 0000000..bc78837 --- /dev/null +++ b/platform/app/apps/(adhoc)/f49c75da/server.utest.js @@ -0,0 +1,77 @@ +/** + * @jest-environment node + */ +import { siteHost } from '@/config/site' + +import { buildSlackManifestInstallUrl } from '@/lib/slack.manifest' + +import { listIntegrations } from './server' + +// @note the tests carry their own site fixture - a Compose-style origin with +// a port - instead of depending on whatever SITE_URL the shell exports +jest.mock('@/config/site', () => { + const siteUrl = 'http://cbk.localhost:3000' + const staticUrl = 'http://cbk-static.localhost:3000' + const widgetUrl = 'http://cbk-widgets.localhost:3000' + const apiUrl = 'http://cbk.localhost:3000' + + return { + siteUrl, + siteHostname: new URL(siteUrl).hostname, + siteHost: new URL(siteUrl).host, + staticUrl, + staticHostname: new URL(staticUrl).hostname, + staticHost: new URL(staticUrl).host, + widgetUrl, + widgetHostname: new URL(widgetUrl).hostname, + widgetHost: new URL(widgetUrl).host, + apiUrl, + apiHostname: new URL(apiUrl).hostname, + apiHost: new URL(apiUrl).host, + } +}) + +jest.mock('@/lib/app.action', () => ({ + appActionHandler: (_app, _configSchema, _inputSchema, fn) => (input, context) => + fn({}, { user: { id: 'user_1' } }, input, context), +})) +jest.mock('@/lib/cbk.sdk', () => ({ + getSessionClient: jest.fn(async () => ({ + integration: { + slack: { + list: jest.fn(async () => ({ + items: [{ id: 'slack_1', name: 'Team', signingSecret: '', botToken: '' }], + })), + }, + }, + })), +})) +jest.mock('@/lib/slack.manifest', () => ({ + buildSlackManifestInstallUrl: jest.fn(() => 'https://api.slack.com/apps'), +})) + +describe('slack integrations app listIntegrations', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('builds the manifest on the origin the deployment serves', async () => { + await listIntegrations({}, { host: 'cbk.localhost:3000' }) + + // @note the manifest carries callback URLs; a hard-coded https origin + // would send Slack to a scheme the local stack does not serve + expect(buildSlackManifestInstallUrl).toHaveBeenCalledWith( + expect.objectContaining({ id: 'slack_1' }), + 'http://cbk.localhost:3000' + ) + }) + + it('falls back to the configured site origin without a request host', async () => { + await listIntegrations({}, {}) + + expect(buildSlackManifestInstallUrl).toHaveBeenCalledWith( + expect.anything(), + `http://${siteHost}` + ) + }) +}) diff --git a/platform/app/apps/(index)/app.webmanifest/route.ts b/platform/app/apps/(index)/app.webmanifest/route.ts index 7d90434..58e7125 100644 --- a/platform/app/apps/(index)/app.webmanifest/route.ts +++ b/platform/app/apps/(index)/app.webmanifest/route.ts @@ -1,6 +1,6 @@ import { headers } from 'next/headers' -import { siteHostname } from '@/config/site' +import { siteHost } from '@/config/site' import { getPublicAppConfig } from '@/lib/app.router.app.config' import { setupHeadersContext } from '@/lib/context.setup' @@ -18,7 +18,7 @@ export async function GET(): Promise { setupHeadersContext(thisHeaders) const host = - getContextFrontendHost() || getContextRequestHost() || siteHostname + getContextFrontendHost() || getContextRequestHost() || siteHost const config = await getPublicAppConfig() diff --git a/platform/app/apps/(index)/app.webmanifest/route.utest.js b/platform/app/apps/(index)/app.webmanifest/route.utest.js new file mode 100644 index 0000000..e894da4 --- /dev/null +++ b/platform/app/apps/(index)/app.webmanifest/route.utest.js @@ -0,0 +1,58 @@ +/** + * @jest-environment node + */ +import { GET } from './route' + +const requestHeaders = { current: new Headers() } + +// @note the tests carry their own site fixture - a Compose-style origin with +// a port - instead of depending on whatever SITE_URL the shell exports +jest.mock('@/config/site', () => { + const siteUrl = 'http://cbk.localhost:3000' + const staticUrl = 'http://cbk-static.localhost:3000' + const widgetUrl = 'http://cbk-widgets.localhost:3000' + const apiUrl = 'http://cbk.localhost:3000' + + return { + siteUrl, + siteHostname: new URL(siteUrl).hostname, + siteHost: new URL(siteUrl).host, + staticUrl, + staticHostname: new URL(staticUrl).hostname, + staticHost: new URL(staticUrl).host, + widgetUrl, + widgetHostname: new URL(widgetUrl).hostname, + widgetHost: new URL(widgetUrl).host, + apiUrl, + apiHostname: new URL(apiUrl).hostname, + apiHost: new URL(apiUrl).host, + } +}) + +jest.mock('next/headers', () => ({ + headers: jest.fn(async () => requestHeaders.current), +})) + +jest.mock('@/lib/app.router.app.config', () => ({ + getPublicAppConfig: jest.fn(async () => null), +})) + +describe('app.webmanifest', () => { + it('identifies the manifest by the request host, port included', async () => { + requestHeaders.current = new Headers({ host: 'CBK-Labs.localhost:3000' }) + + const manifest = await (await GET()).json() + + expect(manifest.id).toBe('cbk-labs-localhost-3000') + }) + + it('falls back to the configured site host without a request host', async () => { + requestHeaders.current = new Headers() + + const manifest = await (await GET()).json() + + // @note the fallback is the host as configured - on a Compose stack + // that carries the port, so it stays distinct from a bare hostname + expect(manifest.id).toBe('cbk-localhost-3000') + }) +}) diff --git a/platform/app/apps/static/lib.hosts.utest.js b/platform/app/apps/static/lib.hosts.utest.js new file mode 100644 index 0000000..3d58fbf --- /dev/null +++ b/platform/app/apps/static/lib.hosts.utest.js @@ -0,0 +1,129 @@ +import prisma from '@/prisma/client' + +import { getAppSlugByHostname } from '@/lib/app.helpers' +import { setupHeadersContext } from '@/lib/context.setup' +import { executeInContext } from '@/lib/context.store' + +import { + createSpaceSiteHandler, + getAppMountBaseHref, + resolveSpaceSiteConfig, +} from './lib' + +jest.mock('@/prisma/client', () => ({ + __esModule: true, + default: { portal: { findUnique: jest.fn().mockResolvedValue(null) } }, +})) + +jest.mock('@/config/apexes', () => ({ + ...jest.requireActual('@/config/apexes'), + appApex: 'apps.static-test.localhost', + portalApex: 'portals.static-test.localhost', +})) + +jest.mock('@/config/hosts', () => ({ + ...jest.requireActual('@/config/hosts'), + hostsConfig: { + custom: { + match: [ + 'static.apps.static-test.localhost:4300', + 'acme.portals.static-test.localhost:4300', + ], + site: 'public.static-test.localhost:4300', + api: 'public.static-test.localhost:4300', + static: 'public.static-test.localhost:4300', + widgets: 'public.static-test.localhost:4300', + }, + portal: { + match: ['upstream.portals.static-test.localhost:4300'], + site: 'public.portals.static-test.localhost:4300', + api: 'public.static-test.localhost:4300', + static: 'public.static-test.localhost:4300', + widgets: 'public.static-test.localhost:4300', + }, + }, +})) + +describe('static app host mappings', () => { + beforeEach(() => jest.clearAllMocks()) + + it('keeps relative resources at the root of a mapped static app host', async () => { + expect(getAppSlugByHostname('static.apps.static-test.localhost')).toBe( + 'static' + ) + + await executeInContext(async () => { + setupHeadersContext( + new Headers({ host: 'static.apps.static-test.localhost:4300' }) + ) + + const request = new Request( + 'http://static.apps.static-test.localhost:4300/apps/static/about/' + ) + + expect(getAppMountBaseHref(request, { params: { path: ['about'] } })).toBe( + '/about/' + ) + }) + }) + + it('does not redirect missing documents back into the mapped static app', async () => { + const handler = createSpaceSiteHandler({ getBaseHref: getAppMountBaseHref }) + const response = await handler( + new Request( + 'http://static.apps.static-test.localhost:4300/apps/static/missing', + { + headers: { + host: 'static.apps.static-test.localhost:4300', + accept: 'text/html', + }, + } + ), + { params: { path: ['missing'] } } + ) + + expect(response.status).toBe(404) + expect(await response.text()).toBe('') + }) + + it('looks up the routed portal when its mapped frontend has a custom name', async () => { + await executeInContext(async () => { + setupHeadersContext( + new Headers({ host: 'acme.portals.static-test.localhost:4300' }) + ) + + await resolveSpaceSiteConfig() + + expect(prisma.portal.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { slug: 'acme' } }) + ) + }) + }) + + it('keeps a recognized frontend portal ahead of the routing portal', async () => { + await executeInContext(async () => { + setupHeadersContext( + new Headers({ host: 'upstream.portals.static-test.localhost:4300' }) + ) + + await resolveSpaceSiteConfig() + + expect(prisma.portal.findUnique).toHaveBeenCalledTimes(1) + expect(prisma.portal.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { slug: 'public' } }) + ) + }) + }) + + it('does not look up a portal for an unrelated site host', async () => { + await executeInContext(async () => { + setupHeadersContext( + new Headers({ host: 'console.static-test.localhost:4300' }) + ) + + await resolveSpaceSiteConfig() + + expect(prisma.portal.findUnique).not.toHaveBeenCalled() + }) + }) +}) diff --git a/platform/app/apps/static/lib.ts b/platform/app/apps/static/lib.ts index 3371ac5..88b4511 100644 --- a/platform/app/apps/static/lib.ts +++ b/platform/app/apps/static/lib.ts @@ -7,6 +7,7 @@ import { getAppConfigBySlug, getAppGlobalBySlug, getAppSlugByHostname, + isAppHostname, } from '@/lib/app.helpers' import { encode } from '@/lib/b64' import { setupRequestContext } from '@/lib/context.setup' @@ -17,6 +18,7 @@ import { } from '@/lib/context.store' import { captureException } from '@/lib/error' import fetch from '@/lib/fetch' +import { hostToHostname } from '@/lib/host.parse' import { merge } from '@/lib/object' import { getPortalGlobalConfig } from '@/lib/portal.config' import { getPortalSlugFromHostname } from '@/lib/portal.hostname' @@ -41,6 +43,18 @@ import type { z } from 'zod' */ export const SPACE_SITE_APP_NAME = STATIC_APP_NAME +function getContextAppHostname(): string { + const hostnames = [getContextFrontendHost(), getContextRequestHost()] + .map(hostToHostname) + .filter(Boolean) + + // @note a custom frontend names the public origin while the request host + // can retain the app or portal identity, as in app configuration lookup + return ( + hostnames.find((hostname) => isAppHostname(hostname)) || hostnames[0] || '' + ) +} + const DEFAULT_INDEX = 'index.html' const DEFAULT_NOT_FOUND = '404.html' @@ -296,8 +310,8 @@ export function getSiteResponseHeaders({ export async function resolveSpaceSiteConfig(): Promise< Record > { - const host = getContextFrontendHost() || getContextRequestHost() - const portalSlug = host ? getPortalSlugFromHostname(host) : null + const hostname = getContextAppHostname() + const portalSlug = hostname ? getPortalSlugFromHostname(hostname) : null if (!portalSlug) { return merge( @@ -384,11 +398,11 @@ export function getAppMountBaseHref( req: Request, context: SpaceSiteRouteContext ): string { - const host = getContextFrontendHost() || getContextRequestHost() + const hostname = getContextAppHostname() const url = new URL(req.url) const sitePath = context.params.path?.join('/') || '' - if (host && getAppSlugByHostname(host) === SPACE_SITE_APP_NAME) { + if (hostname && getAppSlugByHostname(hostname) === SPACE_SITE_APP_NAME) { return withTrailingSlash(sitePath ? `/${sitePath}` : '/') } @@ -626,7 +640,7 @@ export function createSpaceSiteHandler({ try { const url = new URL(req.url) const sitePath = context.params.path?.join('/') || '' - const host = getContextFrontendHost() || getContextRequestHost() + const hostname = getContextAppHostname() return await serveSpaceSite({ config, @@ -634,8 +648,7 @@ export function createSpaceSiteHandler({ trailingSlash: url.pathname.endsWith('/'), baseHref: getBaseHref(req, context), isDocument: isDocumentRequest(req), - isOwnAppHost: - getAppSlugByHostname(host || '') === SPACE_SITE_APP_NAME, + isOwnAppHost: getAppSlugByHostname(hostname) === SPACE_SITE_APP_NAME, head, }) } catch (e) { diff --git a/platform/app/apps/static/lib.utest.ts b/platform/app/apps/static/lib.utest.ts index 6f1226b..bb24e58 100644 --- a/platform/app/apps/static/lib.utest.ts +++ b/platform/app/apps/static/lib.utest.ts @@ -187,6 +187,23 @@ describe('space site base href resolvers', () => { ) }) + it('serves from the bare root when the app own host carries a port', () => { + ;(getContextRequestHost as jest.Mock).mockReturnValue( + 'static.chatbotkit.app:3000' + ) + + // @note the internal app-prefixed path: only the own-host branch strips + // it back to the bare root, the fallback would answer /static/about/ + const req = new Request( + 'http://static.chatbotkit.app:3000/apps/static/about/', + { headers: { 'x-forwarded-host': 'static.chatbotkit.app:3000' } } + ) + + expect(getAppMountBaseHref(req, { params: { path: ['about'] } })).toBe( + '/about/' + ) + }) + it('keeps the app-prefixed mount for a portal host', () => { ;(getContextRequestHost as jest.Mock).mockReturnValue( 'acme.chatbotkit.agency' @@ -301,3 +318,23 @@ describe('static site helpers', () => { }) }) }) + +describe('resolveSpaceSiteConfig on a host that carries a port', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(getContextFrontendHost as jest.Mock).mockReturnValue( + 'acme.chatbotkit.agency:3000' + ) + ;(getContextRequestHost as jest.Mock).mockReturnValue(null) + ;(getPortalSlugFromHostname as jest.Mock).mockReturnValue(null) + }) + + it('looks the portal slug up by hostname', async () => { + await resolveSpaceSiteConfig() + + // @note the context carries the host; the slug table holds hostnames + expect(getPortalSlugFromHostname).toHaveBeenCalledWith( + 'acme.chatbotkit.agency' + ) + }) +}) diff --git a/platform/app/layout.tsx b/platform/app/layout.tsx index ab43680..812eb7a 100644 --- a/platform/app/layout.tsx +++ b/platform/app/layout.tsx @@ -6,7 +6,13 @@ import observability from '@chatbotkit-dev/observability' import { appApex, partnersApex, portalApex, spaceApex } from '@/config/apexes' import { appLabsHost, appMainHost } from '@/config/origins' -import { siteHostname } from '@/config/site' +import { + apiUrl, + siteHost, + siteUrl, + staticUrl, + widgetUrl, +} from '@/config/site' import { setupHeadersContext } from '@/lib/context.setup' import { @@ -19,6 +25,7 @@ import { getExternalFrontendHost, getExternalStaticHost, getExternalWidgetHost, + servesCleanAPIRoutes, } from '@/lib/host' import ChunkErrorListener from '@/components/ChunkErrorListener' @@ -42,15 +49,22 @@ export default async function RootLayout({ children }) { setupHeadersContext(thisHeaders) const host = - getContextFrontendHost() || getContextRequestHost() || siteHostname + getContextFrontendHost() || getContextRequestHost() || siteHost return ( { + const siteUrl = 'http://cbk.localhost:3000' + const staticUrl = 'http://cbk-static.localhost:3000' + const widgetUrl = 'http://cbk-widgets.localhost:3000' + const apiUrl = 'http://cbk.localhost:3000' + + return { + siteUrl, + siteHostname: new URL(siteUrl).hostname, + siteHost: new URL(siteUrl).host, + staticUrl, + staticHostname: new URL(staticUrl).hostname, + staticHost: new URL(staticUrl).host, + widgetUrl, + widgetHostname: new URL(widgetUrl).hostname, + widgetHost: new URL(widgetUrl).host, + apiUrl, + apiHostname: new URL(apiUrl).hostname, + apiHost: new URL(apiUrl).host, + } +}) + +jest.mock('next/headers', () => ({ + headers: jest.fn(async () => requestHeaders.current), +})) + +jest.mock('@/components/ChunkErrorListener', () => () => null) +jest.mock('@/components/GlobalRoot', () => () => null) + +describe('root layout', () => { + it('stamps the configured origins alongside the resolved hosts', async () => { + requestHeaders.current = new Headers({ host: 'cbk-labs.localhost:3000' }) + + const html = await RootLayout({ children: null }) + + // @note the browser seeds config/site from these; without them it + // would seed from its own origin and never patch server-rendered hrefs + expect(html.props).toMatchObject({ + 'data-audience': 'cbk-labs.localhost:3000', + 'data-site-url': siteUrl, + 'data-static-url': staticUrl, + 'data-widget-url': widgetUrl, + 'data-api-url': apiUrl, + }) + }) + + it('falls back to the configured site host, port included', async () => { + requestHeaders.current = new Headers() + + const html = await RootLayout({ children: null }) + + expect(html.props['data-audience']).toBe(siteHost) + }) +}) diff --git a/platform/components/Auth.utest.js b/platform/components/Auth.utest.js index 23799fc..c957dcc 100644 --- a/platform/components/Auth.utest.js +++ b/platform/components/Auth.utest.js @@ -28,7 +28,11 @@ jest.mock('@/hooks/useSession', () => jest.mock('@/hooks/useIsTop', () => jest.fn(() => true)) jest.mock('@/hooks/useSignin', () => jest.fn(() => ({ signin: jest.fn() }))) jest.mock('@/hooks/useSignout', () => jest.fn(() => ({ signout: jest.fn() }))) -jest.mock('@/hooks/useHostname', () => jest.fn(() => 'chatbotkit.com')) +jest.mock('@/hooks/useHost', () => ({ + __esModule: true, + default: jest.fn(() => 'chatbotkit.com'), + useHostname: jest.fn(() => 'chatbotkit.com'), +})) jest.mock('@/lib/error', () => ({ captureException: jest.fn() })) jest.mock('@/lib/toast', () => ({ success: jest.fn() })) jest.mock('@/lib/email.validation', () => ({ diff --git a/platform/components/Pagedown.jsx b/platform/components/Pagedown.jsx index 38d00aa..b4d43b5 100644 --- a/platform/components/Pagedown.jsx +++ b/platform/components/Pagedown.jsx @@ -10,7 +10,7 @@ import Image from '@/components/Image' import Link from '@/components/Link' import YoutubePlayer from '@/components/YoutubePlayer' -import useHostname from '@/hooks/useHostname' +import { useHostname } from '@/hooks/useHost' import clsx from 'clsx' import rehypeRaw from 'rehype-raw' diff --git a/platform/components/PortalList.jsx b/platform/components/PortalList.jsx index 85c2152..36f0187 100644 --- a/platform/components/PortalList.jsx +++ b/platform/components/PortalList.jsx @@ -5,7 +5,7 @@ import { useMemo } from 'react' import ResourceList from '@/components/ResourceList' import useGraphQLConnectionListRoute from '@/hooks/useGraphQLConnectionListRoute' -import { useApexHostURL, usePortalApex } from '@/hooks/useHostname' +import { useApexHostURL, usePortalApex } from '@/hooks/useHost' import useProjectScope, { scopeListRoute } from '@/hooks/useProjectScope' const DEFAULT_LIST_ROUTE = '/api/v1/portal/list' diff --git a/platform/components/SpaceSiteList.jsx b/platform/components/SpaceSiteList.jsx index ab6aef1..f144942 100644 --- a/platform/components/SpaceSiteList.jsx +++ b/platform/components/SpaceSiteList.jsx @@ -8,7 +8,7 @@ import { useConfirmDelete } from '@/components/Confirm' import List from '@/components/List' import useFetch from '@/hooks/useFetch' -import { useApexHostURL, useSpaceApex } from '@/hooks/useHostname' +import { useApexHostURL, useSpaceApex } from '@/hooks/useHost' import usePopup from '@/hooks/usePopup' /** diff --git a/platform/components/ThisSolution.jsx b/platform/components/ThisSolution.jsx index e27d0ee..666b0a1 100644 --- a/platform/components/ThisSolution.jsx +++ b/platform/components/ThisSolution.jsx @@ -17,7 +17,7 @@ import FOC from '@/components/FOC' import Portal from '@/components/Portal' import useDashboardWidgetSend from '@/hooks/useDashboardWidgetSend' -import { useApexHostURL, usePortalApex } from '@/hooks/useHostname' +import { useApexHostURL, usePortalApex } from '@/hooks/useHost' import useTeamSwitch from '@/hooks/useTeamSwitch' import useUserSwitch from '@/hooks/useUserSwitch' diff --git a/platform/components/Widget.jsx b/platform/components/Widget.jsx index 5e76342..059c138 100644 --- a/platform/components/Widget.jsx +++ b/platform/components/Widget.jsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' -import { getExternalAPIHost } from '@/lib/host' +import { resolvePlatformApiUrl } from '@/lib/api.tool.url' import { prompt } from 'react-prompt-kit/src' import { isEmpty as isObjectEmpty } from '@/lib/object' @@ -589,17 +589,7 @@ export default function Widget({ required: ['url'], }, handler: async ({ method, url, data }) => { - const u = new URL(url, window.location.origin) - - if (u.hostname === getExternalAPIHost()) { - const localOrigin = new URL(window.location.origin) - - u.protocol = localOrigin.protocol - u.host = localOrigin.host - u.pathname = u.pathname.startsWith('/api/') - ? u.pathname - : `/api${u.pathname}` - } + const u = resolvePlatformApiUrl(url) if (u.origin !== window.location.origin) { return { diff --git a/platform/config/apps.ts b/platform/config/apps.ts index 5ad0617..0a40337 100644 --- a/platform/config/apps.ts +++ b/platform/config/apps.ts @@ -214,11 +214,11 @@ if (shellHosts[LABS_TYPE]) { export const appSlugs = Object.freeze(apps.map(({ slug }) => slug)) /** - * Create a list of app hostnames. Hostless apps (a deployment with no + * Create a list of app hosts. Hostless apps (a deployment with no * deployment hostname configuration) simply do not appear - nothing matches * by host. */ -export const appHostnames = Object.freeze( +export const appHosts = Object.freeze( apps.map(({ host }) => host).filter((host): host is string => !!host) ) @@ -234,10 +234,11 @@ export const appSlugToUrlMap = Object.freeze({ }) /** - * Create a mapping of app slug to hostname. Hostless entries are omitted so - * host matching never compares against nothing. + * Create a mapping of app slug to host - port included when the origin has + * one. Hostless entries are omitted so host matching never compares against + * nothing. */ -export const appSlugToHostnameMap: Readonly> = +export const appSlugToHostMap: Readonly> = Object.freeze({ ...Object.fromEntries( apps diff --git a/platform/config/hosts.js b/platform/config/hosts.js index 4677331..10d5536 100644 --- a/platform/config/hosts.js +++ b/platform/config/hosts.js @@ -1,7 +1,7 @@ // @ts-check import { z } from 'zod' -const hostname = z +const host = z .string() .transform((value) => value.trim().toLowerCase()) .refine((value) => { @@ -23,15 +23,15 @@ const hostname = z } catch { return false } - }, 'Expected a hostname without a protocol, wildcard, path, query, or hash') + }, 'Expected a host without a protocol, wildcard, path, query, or hash') export const hostMappingSchema = z .object({ - match: z.array(hostname).min(1), - site: hostname, - api: hostname, - static: hostname, - widgets: hostname, + match: z.array(host).min(1), + site: host, + api: host, + static: host, + widgets: host, }) .strict() .superRefine((mapping, ctx) => { @@ -41,7 +41,7 @@ export const hostMappingSchema = z if (seen.has(value)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Duplicate match hostname: ${value}`, + message: `Duplicate match host: ${value}`, path: ['match', index], }) } @@ -90,7 +90,7 @@ export const hostsSchema = z if (existing) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Match hostname ${value} is already used by ${existing}`, + message: `Match host ${value} is already used by ${existing}`, path: [name, 'match'], }) } else { diff --git a/platform/config/site.js b/platform/config/site.js index e63cd66..3ca01bf 100644 --- a/platform/config/site.js +++ b/platform/config/site.js @@ -3,7 +3,7 @@ import { z } from 'zod' // @note the server asserts its runtime environment strictly. The browser // bundle carries no environment, so the document origin is the seed and the -// useHostname hooks overlay the request values from the data-* attributes. +// useHost hooks overlay the request values from the data-* attributes. // @note the deployment URLs are origins - a provided value that carries a // path, query, or hash still validates as a URL but is normalised to its @@ -41,17 +41,28 @@ const env = WIDGET_URL: process.env.WIDGET_URL, API_URL: process.env.API_URL, }) - : { - SITE_URL: process.env.SITE_URL || window.location.origin, - STATIC_URL: process.env.STATIC_URL, - WIDGET_URL: process.env.WIDGET_URL, - API_URL: process.env.API_URL, + : // @note the server stamps its configured origins on so the + // browser seeds every host hook with the value the server rendered; + // seeding from window.location would already equal the runtime value + // and leave server-rendered hrefs unpatched after hydration + { + SITE_URL: + process.env.SITE_URL || + document.documentElement.dataset.siteUrl || + window.location.origin, + STATIC_URL: + process.env.STATIC_URL || document.documentElement.dataset.staticUrl, + WIDGET_URL: + process.env.WIDGET_URL || document.documentElement.dataset.widgetUrl, + API_URL: process.env.API_URL || document.documentElement.dataset.apiUrl, } export const siteUrl = env.SITE_URL export const siteHostname = new URL(siteUrl).hostname +export const siteHost = new URL(siteUrl).host + // @note the origin embed snippets and widget frames are served from. The // hosted deployment fronts these through a dedicated static host // (STATIC_URL=https://static.chatbotkit.com); any other deployment serves @@ -61,12 +72,16 @@ export const staticUrl = env.STATIC_URL || siteUrl export const staticHostname = new URL(staticUrl).hostname +export const staticHost = new URL(staticUrl).host + // @note the origin private MCP widget bundles are served from export const widgetUrl = env.WIDGET_URL || siteUrl export const widgetHostname = new URL(widgetUrl).hostname +export const widgetHost = new URL(widgetUrl).host + // @note the origin the external API is advertised on. The site URL is the // fallback - the API is then served under /api on the site host; a dedicated // origin (API_URL=https://api.chatbotkit.com) serves it under /v1. @@ -74,3 +89,5 @@ export const widgetHostname = new URL(widgetUrl).hostname export const apiUrl = env.API_URL || siteUrl export const apiHostname = new URL(apiUrl).hostname + +export const apiHost = new URL(apiUrl).host diff --git a/platform/eslint.config.mjs b/platform/eslint.config.mjs index cdb040f..71d16d0 100644 --- a/platform/eslint.config.mjs +++ b/platform/eslint.config.mjs @@ -89,12 +89,12 @@ const config = [ { source: '@/config/site', reason: - 'siteUrl and the hostnames freeze at build and ignore the serving (partner) host - use the @/hooks/useHostname family or router.absoluteHref.', + 'siteUrl and the hostnames freeze at build and ignore the serving (partner) host - use the @/hooks/useHost family or router.absoluteHref.', }, { source: '@/config/apexes', reason: - 'the apexes freeze at build - use usePortalApex/useSpaceApex from @/hooks/useHostname.', + 'the apexes freeze at build - use usePortalApex/useSpaceApex from @/hooks/useHost.', }, { source: '@/config/origins', diff --git a/platform/eslint/custom-rules/no-direct-process-env.js b/platform/eslint/custom-rules/no-direct-process-env.js index c4fa1d3..683f306 100644 --- a/platform/eslint/custom-rules/no-direct-process-env.js +++ b/platform/eslint/custom-rules/no-direct-process-env.js @@ -133,7 +133,7 @@ module.exports = { ], messages: { noDirectProcessEnv: - "Client-bundle code must not read process.env.{{name}} directly - the value freezes at build. Use the config seams ('@/config/site', '@/config/apexes') or the hooks in '@/hooks/useHostname'. A read that genuinely must stay opens the file with `/* eslint-disable custom-eslint-rules/no-direct-process-env -- */`.", + "Client-bundle code must not read process.env.{{name}} directly - the value freezes at build. Use the config seams ('@/config/site', '@/config/apexes') or the hooks in '@/hooks/useHost'. A read that genuinely must stay opens the file with `/* eslint-disable custom-eslint-rules/no-direct-process-env -- */`.", noBareProcessEnv: 'Client-bundle code must not pass, spread, alias or enumerate process.env - it is empty in the browser and bypasses the per-name checks. Read the specific names through the config seams or hooks instead.', }, diff --git a/platform/graphql/v1/schema.portal.url.utest.js b/platform/graphql/v1/schema.portal.url.utest.js new file mode 100644 index 0000000..1b07c3b --- /dev/null +++ b/platform/graphql/v1/schema.portal.url.utest.js @@ -0,0 +1,71 @@ +/** + * @jest-environment node + */ +import { graphql } from 'graphql' + +jest.mock('@/prisma/client', () => ({ + __esModule: true, + default: { + portal: { + findMany: jest.fn(async () => [ + { + id: 'portal_1', + slug: 'acme', + name: 'Acme', + description: null, + blueprintId: null, + config: {}, + meta: {}, + userId: 'user_1', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]), + }, + }, +})) + +jest.mock('@/lib/portal.config', () => ({ + getPortalGlobalConfig: jest.fn(async () => null), +})) +jest.mock('@/config/apexes', () => ({ portalApex: 'cbk.localhost' })) +jest.mock('@/config/site', () => ({ + siteUrl: 'http://cbk.localhost:3000', + siteHost: 'cbk.localhost:3000', + siteHostname: 'cbk.localhost', + apiUrl: 'http://cbk.localhost:3000', + apiHost: 'cbk.localhost:3000', +})) + +import { schema } from '@/graphql/v1/schema' + +const context = { session: { user: { id: 'user_1' } }, caller: null } + +describe('Portal.url', () => { + it('is the origin the portal is served on, scheme and port included', async () => { + const result = await graphql({ + schema, + source: ` + query { + portals(first: 1) { + edges { + node { + slug + url + } + } + } + } + `, + contextValue: context, + }) + + expect(result.errors).toBeUndefined() + // @note a hard-coded https would advertise an origin the local stack + // does not serve + expect(result.data.portals.edges[0].node).toEqual({ + slug: 'acme', + url: 'http://acme.cbk.localhost:3000', + }) + }) +}) diff --git a/platform/graphql/v1/schema.ts b/platform/graphql/v1/schema.ts index 624149c..b0ee0cf 100644 --- a/platform/graphql/v1/schema.ts +++ b/platform/graphql/v1/schema.ts @@ -32,7 +32,7 @@ import { getIntegrationVerification, } from '@/lib/integration.verification' import { OMIT_NULL, omit, omitNullExcept } from '@/lib/object' -import { getPortalFrontendHost } from '@/lib/portal.slug' +import { getPortalFrontendURL } from '@/lib/portal.slug' import { getRelatedUsers } from '@/lib/user.relation' import type { @@ -1880,7 +1880,7 @@ const Portal = builder.prismaObject('Portal', { // domain), so it is resolved here rather than persisted or guessed by // clients resolve: async (portal) => - portal.slug ? `https://${await getPortalFrontendHost(portal)}` : null, + portal.slug ? getPortalFrontendURL(portal) : null, }), blueprintId: t.exposeString('blueprintId', { description: 'The ID of the blueprint associated with the portal', diff --git a/platform/hooks/useBuilderExperience.jsx b/platform/hooks/useBuilderExperience.jsx index f600670..e3ec810 100644 --- a/platform/hooks/useBuilderExperience.jsx +++ b/platform/hooks/useBuilderExperience.jsx @@ -1,6 +1,6 @@ import { resolveBuilderExperience } from '@/lib/experience' -import useHostname from '@/hooks/useHostname' +import { useHostname } from '@/hooks/useHost' import usePartner from '@/hooks/usePartner' import useSearchParam from '@/hooks/useSearchParam' @@ -32,7 +32,7 @@ export const EXPERIENCE_SEARCH_PARAM = '_experience' * * @note neither the force nor the partner option reaches code that resolves the * experience outside React (the onboarding template steps via - * getDocumentHostname) - a known, accepted limitation. + * getDocumentHost) - a known, accepted limitation. */ export default function useBuilderExperience() { const hostname = useHostname() diff --git a/platform/hooks/useBuilderExperience.utest.jsx b/platform/hooks/useBuilderExperience.utest.jsx index 533ad06..7728dc8 100644 --- a/platform/hooks/useBuilderExperience.utest.jsx +++ b/platform/hooks/useBuilderExperience.utest.jsx @@ -1,4 +1,4 @@ -jest.mock('@/hooks/useHostname', () => jest.fn()) +jest.mock('@/hooks/useHost', () => ({ useHostname: jest.fn() })) jest.mock('@/hooks/usePartner', () => jest.fn()) jest.mock('@/hooks/useSearchParam', () => jest.fn()) @@ -7,7 +7,7 @@ jest.mock('@/config/site', () => ({ siteUrl: 'https://chatbotkit.com', })) -import useHostname from '@/hooks/useHostname' +import { useHostname } from '@/hooks/useHost' import usePartner from '@/hooks/usePartner' import useSearchParam from '@/hooks/useSearchParam' import useBuilderExperience, { diff --git a/platform/hooks/useExternalAPIURL.ts b/platform/hooks/useExternalAPIURL.ts index 8893229..393ff6c 100644 --- a/platform/hooks/useExternalAPIURL.ts +++ b/platform/hooks/useExternalAPIURL.ts @@ -2,17 +2,23 @@ import { useCallback } from 'react' import { getExternalAPIHostURL } from '@/lib/host' -import { useAPIHostname } from '@/hooks/useHostname' +import { useAPIHost } from '@/hooks/useHost' +import useHydrated from '@/hooks/useHydrated' /** * Returns a function that builds an external API URL using the API hostname * injected into the document by request-context setup. */ export default function useExternalAPIURL(): (path: string) => string { - const hostname = useAPIHostname() + const host = useAPIHost() + const hydrated = useHydrated() + // @note scheme changes must wait for hydration just like host changes return useCallback( - (path: string) => getExternalAPIHostURL(path, hostname), - [hostname] + (path: string) => + hydrated + ? getExternalAPIHostURL(path, host) + : getExternalAPIHostURL(path, host, { useRequestProtocol: false }), + [host, hydrated] ) } diff --git a/platform/hooks/useExternalAPIURL.utest.js b/platform/hooks/useExternalAPIURL.utest.js index 9ae1887..48a6807 100644 --- a/platform/hooks/useExternalAPIURL.utest.js +++ b/platform/hooks/useExternalAPIURL.utest.js @@ -1,13 +1,13 @@ import { getExternalAPIHostURL } from '@/lib/host' -import { useAPIHostname } from '@/hooks/useHostname' +import { useAPIHost } from '@/hooks/useHost' import useExternalAPIURL from './useExternalAPIURL' import { renderHook } from '@testing-library/react' -jest.mock('@/hooks/useHostname', () => ({ - useAPIHostname: jest.fn(), +jest.mock('@/hooks/useHost', () => ({ + useAPIHost: jest.fn(), })) jest.mock('@/lib/host', () => ({ getExternalAPIHostURL: jest.fn(), @@ -19,7 +19,7 @@ describe('useExternalAPIURL', () => { }) it('should build external API URL using resolved hostname', () => { - useAPIHostname.mockReturnValue('api.example.com') + useAPIHost.mockReturnValue('api.example.com') getExternalAPIHostURL.mockReturnValue('https://api.example.com/v1/ping') const { result } = renderHook(() => useExternalAPIURL()) @@ -34,7 +34,7 @@ describe('useExternalAPIURL', () => { }) it('should update callback behavior when hostname changes', () => { - useAPIHostname.mockReturnValue('api-first.example.com') + useAPIHost.mockReturnValue('api-first.example.com') getExternalAPIHostURL.mockReturnValueOnce( 'https://api-first.example.com/one' ) @@ -43,7 +43,7 @@ describe('useExternalAPIURL', () => { expect(result.current('/one')).toBe('https://api-first.example.com/one') - useAPIHostname.mockReturnValue('api-second.example.com') + useAPIHost.mockReturnValue('api-second.example.com') getExternalAPIHostURL.mockReturnValueOnce( 'https://api-second.example.com/two' ) diff --git a/platform/hooks/useExternalFrontendURL.ts b/platform/hooks/useExternalFrontendURL.ts index 8cde23a..0d6d858 100644 --- a/platform/hooks/useExternalFrontendURL.ts +++ b/platform/hooks/useExternalFrontendURL.ts @@ -2,17 +2,25 @@ import { useCallback } from 'react' import { getExternalFrontendHostURL } from '@/lib/host' -import useHostname from '@/hooks/useHostname' +import useHost from '@/hooks/useHost' +import useHydrated from '@/hooks/useHydrated' /** * Returns a function that builds an external frontend URL using the hostname * injected into the document by request-context setup. */ export default function useExternalFrontendURL(): (path: string) => string { - const hostname = useHostname() + const host = useHost() + const hydrated = useHydrated() + // @note the first render must use the server's scheme even when the page + // has the same hostname on another scheme; a host change alone cannot + // trigger React to repair that hydration mismatch return useCallback( - (path: string) => getExternalFrontendHostURL(path, hostname), - [hostname] + (path: string) => + hydrated + ? getExternalFrontendHostURL(path, host) + : getExternalFrontendHostURL(path, host, { useRequestProtocol: false }), + [host, hydrated] ) } diff --git a/platform/hooks/useExternalFrontendURL.utest.jsx b/platform/hooks/useExternalFrontendURL.utest.jsx index bc43b54..c9ba904 100644 --- a/platform/hooks/useExternalFrontendURL.utest.jsx +++ b/platform/hooks/useExternalFrontendURL.utest.jsx @@ -3,12 +3,12 @@ import useExternalFrontendURL from './useExternalFrontendURL' import '@testing-library/jest-dom' import { renderHook } from '@testing-library/react' -jest.mock('@/hooks/useHostname', () => ({ +jest.mock('@/hooks/useHost', () => ({ __esModule: true, default: jest.fn(() => 'example.chatbotkit.com'), })) -const useHostname = jest.requireMock('@/hooks/useHostname').default +const useHost = jest.requireMock('@/hooks/useHost').default describe('useExternalFrontendURL', () => { it('builds an https url on the resolved hostname', () => { @@ -20,7 +20,7 @@ describe('useExternalFrontendURL', () => { }) it('downgrades to http on localhost', () => { - useHostname.mockReturnValue('localhost:3000') + useHost.mockReturnValue('localhost:3000') const { result } = renderHook(() => useExternalFrontendURL()) diff --git a/platform/hooks/useExternalURL.hydration.utest.js b/platform/hooks/useExternalURL.hydration.utest.js new file mode 100644 index 0000000..f69d72e --- /dev/null +++ b/platform/hooks/useExternalURL.hydration.utest.js @@ -0,0 +1,168 @@ +/** + * @jest-environment node + */ + +/* eslint-disable @typescript-eslint/no-require-imports -- server and browser phases load separate module graphs */ + +import { createRequire } from 'node:module' + +const { JSDOM } = createRequire(require.resolve('jest-environment-jsdom'))( + 'jsdom' +) + +const ENV_KEYS = [ + 'SITE_URL', + 'API_URL', + 'STATIC_URL', + 'WIDGET_URL', + 'HOSTS_CONFIG', +] + +function loadProbe() { + const React = require('react') + const useFrontendURL = require('./useExternalFrontendURL').default + const useAPIURL = require('./useExternalAPIURL').default + + function Probe() { + const frontendURL = useFrontendURL() + const apiURL = useAPIURL() + + return React.createElement( + React.Fragment, + null, + React.createElement( + 'a', + { id: 'frontend', href: frontendURL('/hub/demo') }, + 'Frontend' + ), + React.createElement( + 'a', + { id: 'api', href: apiURL('/v1/models') }, + 'API' + ) + ) + } + + return { React, Probe } +} + +describe('external URL hydration across schemes', () => { + const previousEnv = Object.fromEntries( + ENV_KEYS.map((key) => [key, process.env[key]]) + ) + const previousActEnvironment = global.IS_REACT_ACT_ENVIRONMENT + + afterEach(() => { + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + + delete global.window + delete global.document + global.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment + }) + + it.each([ + ['http://console.example', 'https://console.example', undefined], + ['https://console.example', 'http://console.example', undefined], + ['https://console.example', 'http://console.example:3000', undefined], + ['http://console.example', 'http://shell.example:3000', undefined], + [ + 'http://console.example:3000', + 'http://console.example:3000', + 'https://console.example:9443', + ], + [ + 'https://console.example:8443', + 'https://console.example:8443', + 'http://console.example:3000', + ], + [ + 'http://console.example:3000', + 'http://console.example:3000', + 'https://localhost:9443', + ], + ])('hydrates %s on %s without leaving stale links', async (configuredOrigin, pageOrigin, apiOrigin) => { + for (const key of ENV_KEYS) { + delete process.env[key] + } + + process.env.SITE_URL = configuredOrigin + + if (apiOrigin) { + process.env.API_URL = apiOrigin + } + + let html + + jest.isolateModules(() => { + const { React, Probe } = loadProbe() + + html = require('react-dom/server').renderToString(React.createElement(Probe)) + }) + + const dom = new JSDOM(`
${html}
`, { + url: pageOrigin, + }) + + global.window = dom.window + global.document = dom.window.document + global.IS_REACT_ACT_ENVIRONMENT = true + + const pageHost = new URL(pageOrigin).host + + Object.assign(document.documentElement.dataset, { + siteUrl: configuredOrigin, + apiUrl: apiOrigin || configuredOrigin, + audience: pageHost, + siteHost: pageHost, + apiHost: apiOrigin ? new URL(apiOrigin).host : pageHost, + apiCleanRoutes: '0', + }) + document.cookie = `chatbotkit.host=${pageHost}` + delete process.env.SITE_URL + delete process.env.API_URL + + let React + let Probe + let hydrateRoot + let root + + jest.isolateModules(() => { + ;({ React, Probe } = loadProbe()) + ;({ hydrateRoot } = require('react-dom/client')) + }) + + const errors = [] + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation((...args) => errors.push(args)) + + try { + await React.act(async () => { + root = hydrateRoot(document.getElementById('root'), React.createElement(Probe), { + onRecoverableError: (error) => errors.push(error), + }) + }) + + expect(document.getElementById('frontend').getAttribute('href')).toBe( + `${pageOrigin}/hub/demo` + ) + expect(document.getElementById('api').getAttribute('href')).toBe( + `${apiOrigin || pageOrigin}/api/v1/models` + ) + expect(errors).toEqual([]) + } finally { + if (root) { + await React.act(async () => root.unmount()) + } + + errorSpy.mockRestore() + dom.window.close() + } + }) +}) diff --git a/platform/hooks/useHost.hydration.utest.jsx b/platform/hooks/useHost.hydration.utest.jsx new file mode 100644 index 0000000..8c2c86a --- /dev/null +++ b/platform/hooks/useHost.hydration.utest.jsx @@ -0,0 +1,225 @@ +/** + * @jest-environment @chatbotkit-dev/jest-jsdom + * @jest-environment-options {"url": "http://cbk-labs.localhost:3000/"} + */ + +/* eslint-disable @typescript-eslint/no-require-imports -- every phase loads the real config/site under a different environment through jest.isolateModules */ + +// @note the page lives on the runtime host, not the configured SITE_URL - the +// labs shell served by a deployment whose SITE_URL is the loopback origin. +// This is the layout that hid the bug: seeding the browser from +// window.location.origin already yields the post-hydration host, so React +// sees no change and leaves the server-rendered href untouched. + +const CONFIGURED_URL = 'http://127.0.0.1:3000' +const CONFIGURED_HOST = '127.0.0.1:3000' +const RUNTIME_HOST = 'cbk-labs.localhost:3000' + +const SERVER_HTML = `link` + +// @note getExternalAPIHost reads the request context store, which is +// server-only; useSiteHost never calls it, so a stub keeps the import inert +jest.mock('@/lib/host', () => ({ + getExternalAPIHost: jest.fn((host) => host), +})) + +const ORIGINAL_ENV = { + SITE_URL: process.env.SITE_URL, + STATIC_URL: process.env.STATIC_URL, + WIDGET_URL: process.env.WIDGET_URL, + API_URL: process.env.API_URL, +} + +function restoreEnv() { + for (const [name, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } + } +} + +/** + * The server phase: config/site takes the environment branch when no window + * exists, so the module is loaded with the global hidden. + */ +function loadServer() { + const descriptor = Object.getOwnPropertyDescriptor(global, 'window') + + Object.defineProperty(global, 'window', { + configurable: true, + value: undefined, + }) + + try { + let modules + + jest.isolateModules(() => { + const { renderToString } = require('react-dom/server') + + modules = { ...loadProbe(), renderToString } + }) + + return modules + } finally { + Object.defineProperty(global, 'window', descriptor) + } +} + +/** + * Loads the real config/site and useHost modules into the current registry - + * call it inside jest.isolateModules so each phase sees its own seed - and + * builds the probe anchor on that React instance. + */ +function loadProbe() { + const React = require('react') + const { useSiteHost } = require('./useHost') + + function Probe() { + const host = useSiteHost() + + return React.createElement('a', { href: `http://${host}/x` }, 'link') + } + + return { React, Probe, site: require('@/config/site') } +} + +/** + * Hydrates the server markup in a fresh container with the browser modules + * and returns the anchor once React has committed the layout effects. + */ +async function hydrate(html) { + const container = document.createElement('div') + + container.innerHTML = html + + document.body.appendChild(container) + + let React + let Probe + let hydrateRoot + let site + + jest.isolateModules(() => { + ;({ React, Probe, site } = loadProbe()) + ;({ hydrateRoot } = require('react-dom/client')) + }) + + global.IS_REACT_ACT_ENVIRONMENT = true + + const recoverable = [] + + let root + + await React.act(async () => { + root = hydrateRoot(container, React.createElement(Probe), { + onRecoverableError: (error) => recoverable.push(error), + }) + }) + + return { + anchor: container.querySelector('a'), + recoverable, + site, + unmount: () => React.act(() => root.unmount()), + } +} + +describe('useSiteHost hydration', () => { + let consoleError + + beforeEach(() => { + process.env.SITE_URL = CONFIGURED_URL + + delete process.env.STATIC_URL + delete process.env.WIDGET_URL + delete process.env.API_URL + + // @note only React's hydration mismatch report is expected noise; any + // other console.error still surfaces + consoleError = jest + .spyOn(console, 'error') + .mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('hydrat')) { + return + } + + throw new Error(`unexpected console.error: ${args.join(' ')}`) + }) + }) + + afterEach(() => { + consoleError.mockRestore() + + restoreEnv() + + delete document.documentElement.dataset.siteUrl + delete document.documentElement.dataset.siteHost + + document.body.innerHTML = '' + }) + + it('renders the configured site host on the server', () => { + const { React, Probe, site, renderToString } = loadServer() + + expect(site.siteHost).toBe(CONFIGURED_HOST) + + expect(renderToString(React.createElement(Probe))).toBe(SERVER_HTML) + }) + + describe('in the browser', () => { + beforeEach(() => { + // @note the browser bundle carries no SITE_URL + delete process.env.SITE_URL + }) + + it('patches the server-rendered href to the runtime host after hydration', async () => { + document.documentElement.dataset.siteUrl = CONFIGURED_URL + document.documentElement.dataset.siteHost = RUNTIME_HOST + + const { anchor, recoverable, site, unmount } = await hydrate(SERVER_HTML) + + expect(anchor.getAttribute('href')).toBe(`http://${RUNTIME_HOST}/x`) + + // the seed reproduced the server render, so hydration was clean + expect(site.siteHost).toBe(CONFIGURED_HOST) + expect(recoverable).toEqual([]) + expect(consoleError).not.toHaveBeenCalled() + + unmount() + }) + + it('keeps the href when the runtime host is the configured host', async () => { + document.documentElement.dataset.siteUrl = CONFIGURED_URL + document.documentElement.dataset.siteHost = CONFIGURED_HOST + + const { anchor, recoverable, unmount } = await hydrate(SERVER_HTML) + + expect(recoverable).toEqual([]) + expect(consoleError).not.toHaveBeenCalled() + + expect(anchor.getAttribute('href')).toBe(`http://${CONFIGURED_HOST}/x`) + + unmount() + }) + + // @note without the stamped origin the seed falls back to the page origin, + // which on the runtime host is already the post-hydration value: React + // reports the mismatch, does not patch attributes, and the layout effect + // sets the same host again - the stale server href survives. This is the + // failure the data-site-url attribute exists to prevent. + it('leaves the server href stale when seeded from the page origin instead', async () => { + document.documentElement.dataset.siteHost = RUNTIME_HOST + + const { anchor, site, unmount } = await hydrate(SERVER_HTML) + + expect(site.siteHost).toBe(RUNTIME_HOST) + expect(consoleError).toHaveBeenCalled() + + expect(anchor.getAttribute('href')).toBe(`http://${CONFIGURED_HOST}/x`) + + unmount() + }) + }) +}) diff --git a/platform/hooks/useHostname.tsx b/platform/hooks/useHost.tsx similarity index 59% rename from platform/hooks/useHostname.tsx rename to platform/hooks/useHost.tsx index 618598d..eae37c7 100644 --- a/platform/hooks/useHostname.tsx +++ b/platform/hooks/useHost.tsx @@ -4,35 +4,44 @@ import { useCallback, useState } from 'react' import { portalApex, spaceApex } from '@/config/apexes' import { BUILTIN_TYPE, + LABS_TYPE, MAIN_TYPE, PORTAL_TYPE, - appSlugToHostnameMap, + appSlugToHostMap, appSlugs, } from '@/config/apps' import { HOST_COOKIE_NAME } from '@/config/cookie' import { + siteHost, siteHostname, siteUrl, - staticHostname, - widgetHostname, + staticHost, + widgetHost, } from '@/config/site' import { parse } from '@/lib/cookie' import { isProduction } from '@/lib/env' import { getExternalAPIHost } from '@/lib/host' import { isLocalhost } from '@/lib/localhost' +import { hostToHostname, normalizeRequestHost } from '@/lib/host.parse' import useCookie from '@/hooks/useCookie' import useHydrated from '@/hooks/useHydrated' import useHydrationSafeLayoutEffect from '@/hooks/useHydrationSafeLayoutEffect' -export function useCookieHostname(): string { +// @note every value here is a host - hostname plus port when the deployment +// has one - matching the data-* attributes and the host cookie; reduce with +// hostToHostname where a hostname is wanted + +export function useCookieHost(): string { const cookie = useCookie(HOST_COOKIE_NAME) - return cookie || '' + // @note the cookie is client-held input: anything that is not a host is + // ignored rather than handed to a URL builder that would throw in render + return normalizeRequestHost(cookie) || '' } -export function useAudienceHostname(): string { +export function useAudienceHost(): string { // @note keep the initial render empty so hydration matches the server HTML, // then read the attribute in a layout effect - it lands before the browser // paints, so the resolved hostname is never visibly late @@ -45,46 +54,46 @@ export function useAudienceHostname(): string { return htmlAudience } -export function useSiteHostname(): string { - const [hostname, setHostname] = useState(siteHostname) +export function useSiteHost(): string { + const [host, setHost] = useState(siteHost) useHydrationSafeLayoutEffect(() => { - setHostname(document.documentElement.dataset.siteHost || siteHostname) + setHost(document.documentElement.dataset.siteHost || siteHost) }, []) - return hostname + return host } -export function useStaticHostname(): string { - const [hostname, setHostname] = useState(staticHostname) +export function useStaticHost(): string { + const [host, setHost] = useState(staticHost) useHydrationSafeLayoutEffect(() => { - setHostname(document.documentElement.dataset.staticHost || staticHostname) + setHost(document.documentElement.dataset.staticHost || staticHost) }, []) - return hostname + return host } -export function useWidgetHostname(): string { - const [hostname, setHostname] = useState(widgetHostname) +export function useWidgetHost(): string { + const [host, setHost] = useState(widgetHost) useHydrationSafeLayoutEffect(() => { - setHostname(document.documentElement.dataset.widgetHost || widgetHostname) + setHost(document.documentElement.dataset.widgetHost || widgetHost) }, []) - return hostname + return host } -export function useAPIHostname(): string { - const fallbackHostname = getExternalAPIHost(siteHostname) +export function useAPIHost(): string { + const fallbackHost = getExternalAPIHost(siteHost) - const [hostname, setHostname] = useState(fallbackHostname) + const [host, setHost] = useState(fallbackHost) useHydrationSafeLayoutEffect(() => { - setHostname(document.documentElement.dataset.apiHost || fallbackHostname) - }, [fallbackHostname]) + setHost(document.documentElement.dataset.apiHost || fallbackHost) + }, [fallbackHost]) - return hostname + return host } export function usePortalApex(): string { @@ -138,16 +147,16 @@ export function useApexHostURL(): (slug: string, apex: string) => string { } /** - * The app slug to hostname table with the runtime deployment hosts overlaid. + * The app slug to host table with the runtime deployment hosts overlaid. * The build-time constants carry no apex values in the browser - they read * server-only environment - so href resolution keyed off the constants alone * stops recognising app and portal hosts after hydration. The data-* * attributes are the runtime source, mirroring how the constants table is * built server-side. */ -export function useAppSlugToHostnameMap(): Readonly> { +export function useAppSlugToHostMap(): Readonly> { const [map, setMap] = - useState>>(appSlugToHostnameMap) + useState>>(appSlugToHostMap) useHydrationSafeLayoutEffect(() => { const dataset = document.documentElement.dataset @@ -155,11 +164,17 @@ export function useAppSlugToHostnameMap(): Readonly> { const runtimeAppApex = dataset.appApex || '' const runtimePortalApex = dataset.portalApex || '' const runtimeAppMainHost = dataset.appMainHost || '' + const runtimeAppLabsHost = dataset.appLabsHost || '' - const overlay: Record = { ...appSlugToHostnameMap } + const overlay: Record = { ...appSlugToHostMap } if (runtimeAppApex) { + // @note shell slugs (`:main`, `:labs`) answer on their own origins for (const slug of appSlugs) { + if (slug.startsWith(':')) { + continue + } + overlay[slug] = `${slug}.${runtimeAppApex}` } @@ -174,6 +189,10 @@ export function useAppSlugToHostnameMap(): Readonly> { overlay[MAIN_TYPE] = runtimeAppMainHost } + if (runtimeAppLabsHost) { + overlay[LABS_TYPE] = runtimeAppLabsHost + } + setMap(Object.freeze(overlay)) }, []) @@ -183,28 +202,36 @@ export function useAppSlugToHostnameMap(): Readonly> { // @note data-audience is set by the server on and reflects the // request host more accurately than the cookie, which may be stale -function resolveHostname( +function resolveHost( htmlAudience: string, cookie: string, - fallbackHostname: string + fallbackHost: string ): string { - let hostname = htmlAudience || cookie - - if (hostname === siteHostname) { - hostname = cookie + let host = htmlAudience || cookie + + // @note an audience that is exactly the bare site hostname carries no + // deployment identity, so the cookie may know better; an audience with the + // deployment's port is the public host and always wins over the cookie, + // which behind a proxy may hold an internal upstream + if (host === siteHostname) { + host = cookie } - if (isProduction) { - if (isLocalhost(hostname || siteHostname)) { - hostname = siteHostname + // @note a loopback host (localhost, 127.x) in production is a misconfigured + // request, so the site host stands in - unless the site itself is loopback + // (a local stack reached as localhost instead of 127.0.0.1), where the + // swap would send every link to a different origin with its own cookie jar + if (isProduction && !isLocalhost(siteHost)) { + if (isLocalhost(host || siteHost)) { + host = siteHost } } - if (!hostname) { - hostname = fallbackHostname + if (!host) { + host = fallbackHost } - return hostname + return host } /** @@ -212,12 +239,12 @@ function resolveHostname( * running. This is not the same as the `window.location.host` because it is * dependent on intermediary proxy servers. * - * This is the literal hostname not the site. + * This is the literal host - port included - not the site. */ -export default function useHostname(): string { - const cookie = useCookieHostname() +export default function useHost(): string { + const cookie = useCookieHost() - const htmlAudience = useAudienceHostname() + const htmlAudience = useAudienceHost() // @note neither source is readable while the server renders: the document // does not exist and useCookie reads the request cookie through Next's @@ -228,27 +255,36 @@ export default function useHostname(): string { const hydrated = useHydrated() - return resolveHostname( + return resolveHost( hydrated ? htmlAudience : '', hydrated ? cookie : '', - new URL(siteUrl).hostname + new URL(siteUrl).host ) } /** - * Plain (non-hook) variant of useHostname for code that runs outside the + * The hostname of the current host - see useHost. + */ +export function useHostname(): string { + return hostToHostname(useHost()) +} + +/** + * Plain (non-hook) variant of useHost for code that runs outside the * React render cycle, such as template tasks. The host cookie and the * data-audience attribute are set by the server and reflect the real request * host behind intermediary proxies - window.location is only a fallback. */ -export function getDocumentHostname(): string { +export function getDocumentHost(): string { if (typeof document === 'undefined') { - return new URL(siteUrl).hostname + return new URL(siteUrl).host } - const cookie = parse(document.cookie || '').get(HOST_COOKIE_NAME) || '' + const cookie = + normalizeRequestHost(parse(document.cookie || '').get(HOST_COOKIE_NAME)) || + '' const htmlAudience = document.documentElement.dataset.audience || '' - return resolveHostname(htmlAudience, cookie, window.location.hostname) + return resolveHost(htmlAudience, cookie, window.location.host) } diff --git a/platform/hooks/useHostname.utest.js b/platform/hooks/useHost.utest.js similarity index 63% rename from platform/hooks/useHostname.utest.js rename to platform/hooks/useHost.utest.js index 8f5531b..4f7ee76 100644 --- a/platform/hooks/useHostname.utest.js +++ b/platform/hooks/useHost.utest.js @@ -2,19 +2,20 @@ import { renderToString } from 'react-dom/server' import useCookie from './useCookie' -import useHostname, { - getDocumentHostname, - useAPIHostname, +import useHost, { + getDocumentHost, + useAPIHost, useApexHostURL, - useAppSlugToHostnameMap, - useAudienceHostname, - useCookieHostname, + useAppSlugToHostMap, + useAudienceHost, + useCookieHost, + useHostname, usePortalApex, - useSiteHostname, + useSiteHost, useSpaceApex, - useStaticHostname, - useWidgetHostname, -} from './useHostname' + useStaticHost, + useWidgetHost, +} from './useHost' import { renderHook } from '@testing-library/react' @@ -22,6 +23,7 @@ jest.mock('./useCookie', () => jest.fn()) let isProductionValue = false let siteUrlValue = 'https://default.example.com' +let siteHostValue = 'site.example.com:8443' jest.mock('@/lib/env', () => ({ get isProduction() { @@ -37,10 +39,16 @@ jest.mock('@/lib/host', () => ({ getExternalAPIHost: jest.fn(() => 'api.example.com'), })) +// @note the hosts carry a port so a hostname leaking into a host seam fails jest.mock('@/config/site', () => ({ siteHostname: 'site.example.com', + get siteHost() { + return siteHostValue + }, staticHostname: 'static.example.com', + staticHost: 'static.example.com:8443', widgetHostname: 'widgets.example.com', + widgetHost: 'widgets.example.com:8443', get siteUrl() { return siteUrlValue }, @@ -53,12 +61,13 @@ jest.mock('@/config/apexes', () => ({ jest.mock('@/config/apps', () => ({ MAIN_TYPE: ':main', + LABS_TYPE: ':labs', BUILTIN_TYPE: ':builtin', PORTAL_TYPE: ':portal', - appSlugs: ['chat', 'connect'], + appSlugs: ['chat', 'connect', ':main', ':labs'], // @note mirrors the browser bundle, where the map carries no apex-derived // entries because the constants read server-only environment - appSlugToHostnameMap: Object.freeze({}), + appSlugToHostMap: Object.freeze({}), })) jest.mock('@/config/cookie', () => ({ @@ -67,11 +76,12 @@ jest.mock('@/config/cookie', () => ({ const { isLocalhost } = require('@/lib/localhost') -describe('useHostname', () => { +describe('useHost', () => { beforeEach(() => { jest.clearAllMocks() siteUrlValue = 'https://default.example.com' + siteHostValue = 'site.example.com:8443' isProductionValue = false isLocalhost.mockReturnValue(false) @@ -83,7 +93,7 @@ describe('useHostname', () => { it('should return hostname from cookie', () => { useCookie.mockReturnValue('cookie.example.com') - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('cookie.example.com') }) @@ -91,7 +101,7 @@ describe('useHostname', () => { it('should return hostname from siteUrl when cookie is empty', () => { useCookie.mockReturnValue(null) - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('default.example.com') }) @@ -99,7 +109,7 @@ describe('useHostname', () => { it('should call useCookie with HOST_COOKIE_NAME', () => { useCookie.mockReturnValue('test.com') - renderHook(() => useHostname()) + renderHook(() => useHost()) expect(useCookie).toHaveBeenCalledWith('host_cookie') }) @@ -110,58 +120,70 @@ describe('useHostname', () => { isProductionValue = true }) - it('should replace localhost with siteHostname in production', () => { + it('should replace localhost with the site host in production', () => { useCookie.mockReturnValue('localhost') - isLocalhost.mockReturnValue(true) + isLocalhost.mockImplementation((value) => /^(localhost|127\.)/.test(value)) - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) - expect(result.current).toBe('site.example.com') + expect(result.current).toBe('site.example.com:8443') }) - it('should replace 127.0.0.1 with siteHostname in production', () => { + it('should replace 127.0.0.1 with the site host in production', () => { useCookie.mockReturnValue('127.0.0.1') - isLocalhost.mockReturnValue(true) + isLocalhost.mockImplementation((value) => /^(localhost|127\.)/.test(value)) - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) - expect(result.current).toBe('site.example.com') + expect(result.current).toBe('site.example.com:8443') }) it('should keep valid hostname in production', () => { useCookie.mockReturnValue('valid.example.com') isLocalhost.mockReturnValue(false) - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('valid.example.com') }) - it('should use siteHostname when cookie is null in production', () => { + it('keeps a loopback host when the site itself is loopback', () => { + isProductionValue = true + siteHostValue = '127.0.0.1:3000' + isLocalhost.mockImplementation((value) => /^(localhost|127\.)/.test(value)) + document.documentElement.dataset.audience = 'localhost:3000' useCookie.mockReturnValue(null) - isLocalhost.mockReturnValue(true) - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) - expect(result.current).toBe('site.example.com') + expect(result.current).toBe('localhost:3000') + }) + + it('falls back to the site url host when nothing is known in production', () => { + useCookie.mockReturnValue(null) + isLocalhost.mockImplementation((value) => /^(localhost|127\.)/.test(value)) + + const { result } = renderHook(() => useHost()) + + expect(result.current).toBe('default.example.com') }) }) describe('non-production mode', () => { it('should allow localhost hostname', () => { useCookie.mockReturnValue('localhost') - isLocalhost.mockReturnValue(true) + isLocalhost.mockImplementation((value) => /^(localhost|127\.)/.test(value)) - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('localhost') }) it('should allow 127.0.0.1 hostname', () => { useCookie.mockReturnValue('127.0.0.1') - isLocalhost.mockReturnValue(true) + isLocalhost.mockImplementation((value) => /^(localhost|127\.)/.test(value)) - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('127.0.0.1') }) @@ -171,7 +193,7 @@ describe('useHostname', () => { it('should handle undefined cookie', () => { useCookie.mockReturnValue(undefined) - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('default.example.com') }) @@ -179,7 +201,7 @@ describe('useHostname', () => { it('should handle empty string cookie', () => { useCookie.mockReturnValue('') - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('default.example.com') }) @@ -188,16 +210,16 @@ describe('useHostname', () => { siteUrlValue = 'https://example.com:3000' useCookie.mockReturnValue(null) - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) - expect(result.current).toBe('example.com') + expect(result.current).toBe('example.com:3000') }) it('should handle siteUrl with path', () => { siteUrlValue = 'https://example.com/path' useCookie.mockReturnValue(null) - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('example.com') }) @@ -206,7 +228,7 @@ describe('useHostname', () => { siteUrlValue = 'https://sub.example.com' useCookie.mockReturnValue(null) - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('sub.example.com') }) @@ -217,15 +239,33 @@ describe('useHostname', () => { document.documentElement.dataset.audience = 'html.example.com' useCookie.mockReturnValue('cookie.example.com') - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('html.example.com') }) + it('keeps a ported audience over the cookie even when it is the site host', () => { + document.documentElement.dataset.audience = 'site.example.com:8443' + useCookie.mockReturnValue('platform.internal:3000') + + const { result } = renderHook(() => useHost()) + + expect(result.current).toBe('site.example.com:8443') + }) + + it('lets the cookie refine a bare site hostname audience', () => { + document.documentElement.dataset.audience = 'site.example.com' + useCookie.mockReturnValue('brand.example.com') + + const { result } = renderHook(() => useHost()) + + expect(result.current).toBe('brand.example.com') + }) + it('should fall back to cookie when data-audience is absent', () => { useCookie.mockReturnValue('cookie.example.com') - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('cookie.example.com') }) @@ -234,7 +274,7 @@ describe('useHostname', () => { useCookie.mockReturnValue(null) siteUrlValue = 'https://fallback.example.com' - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('fallback.example.com') }) @@ -243,7 +283,7 @@ describe('useHostname', () => { document.documentElement.dataset.audience = 'html.example.com' useCookie.mockReturnValue('cookie.example.com') - renderHook(() => useHostname()) + renderHook(() => useHost()) expect(useCookie).toHaveBeenCalledWith('host_cookie') }) @@ -251,12 +291,12 @@ describe('useHostname', () => { it('should apply production localhost check to data-audience value', () => { isProductionValue = true document.documentElement.dataset.audience = 'localhost' - isLocalhost.mockReturnValue(true) + isLocalhost.mockImplementation((value) => /^(localhost|127\.)/.test(value)) useCookie.mockReturnValue(null) - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) - expect(result.current).toBe('site.example.com') + expect(result.current).toBe('site.example.com:8443') }) }) @@ -265,7 +305,7 @@ describe('useHostname', () => { useCookie.mockReturnValue(null) siteUrlValue = 'https://fallback.example.com' - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('fallback.example.com') }) @@ -274,7 +314,7 @@ describe('useHostname', () => { useCookie.mockReturnValue('cookie.example.com') siteUrlValue = 'https://fallback.example.com' - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(result.current).toBe('cookie.example.com') }) @@ -284,7 +324,7 @@ describe('useHostname', () => { it('should update when cookie changes', () => { useCookie.mockReturnValue('first.example.com') - const { result, rerender } = renderHook(() => useHostname()) + const { result, rerender } = renderHook(() => useHost()) expect(result.current).toBe('first.example.com') @@ -297,7 +337,7 @@ describe('useHostname', () => { it('should switch from cookie to fallback', () => { useCookie.mockReturnValue('cookie.example.com') - const { result, rerender } = renderHook(() => useHostname()) + const { result, rerender } = renderHook(() => useHost()) expect(result.current).toBe('cookie.example.com') @@ -309,7 +349,18 @@ describe('useHostname', () => { }) }) -describe('useCookieHostname', () => { +describe('useCookieHost', () => { + it.each(['a b:c', 'host:abc', 'example.com/path', 'x;domain=evil'])( + 'ignores a cookie that is not a host (%s)', + (value) => { + useCookie.mockReturnValue(value) + + const { result } = renderHook(() => useCookieHost()) + + expect(result.current).toBe('') + } + ) + beforeEach(() => { jest.clearAllMocks() }) @@ -317,7 +368,7 @@ describe('useCookieHostname', () => { it('should return the cookie value when set', () => { useCookie.mockReturnValue('cookie.example.com') - const { result } = renderHook(() => useCookieHostname()) + const { result } = renderHook(() => useCookieHost()) expect(result.current).toBe('cookie.example.com') }) @@ -325,7 +376,7 @@ describe('useCookieHostname', () => { it('should return empty string when cookie is null', () => { useCookie.mockReturnValue(null) - const { result } = renderHook(() => useCookieHostname()) + const { result } = renderHook(() => useCookieHost()) expect(result.current).toBe('') }) @@ -333,7 +384,7 @@ describe('useCookieHostname', () => { it('should return empty string when cookie is undefined', () => { useCookie.mockReturnValue(undefined) - const { result } = renderHook(() => useCookieHostname()) + const { result } = renderHook(() => useCookieHost()) expect(result.current).toBe('') }) @@ -341,13 +392,13 @@ describe('useCookieHostname', () => { it('should call useCookie with HOST_COOKIE_NAME', () => { useCookie.mockReturnValue('test.com') - renderHook(() => useCookieHostname()) + renderHook(() => useCookieHost()) expect(useCookie).toHaveBeenCalledWith('host_cookie') }) }) -describe('useAudienceHostname', () => { +describe('useAudienceHost', () => { beforeEach(() => { delete document.documentElement.dataset.audience }) @@ -355,13 +406,13 @@ describe('useAudienceHostname', () => { it('should return the data-audience value when set', () => { document.documentElement.dataset.audience = 'audience.example.com' - const { result } = renderHook(() => useAudienceHostname()) + const { result } = renderHook(() => useAudienceHost()) expect(result.current).toBe('audience.example.com') }) it('should return empty string when data-audience is not set', () => { - const { result } = renderHook(() => useAudienceHostname()) + const { result } = renderHook(() => useAudienceHost()) expect(result.current).toBe('') }) @@ -369,7 +420,7 @@ describe('useAudienceHostname', () => { it('should return empty string when data-audience is empty string', () => { document.documentElement.dataset.audience = '' - const { result } = renderHook(() => useAudienceHostname()) + const { result } = renderHook(() => useAudienceHost()) expect(result.current).toBe('') }) @@ -457,19 +508,21 @@ describe('useApexHostURL', () => { }) }) -describe('useAppSlugToHostnameMap', () => { +describe('useAppSlugToHostMap', () => { beforeEach(() => { delete document.documentElement.dataset.appApex delete document.documentElement.dataset.portalApex delete document.documentElement.dataset.appMainHost + delete document.documentElement.dataset.appLabsHost }) it('overlays the runtime deployment hosts from the document', () => { document.documentElement.dataset.appApex = 'apps.brand.example' document.documentElement.dataset.portalApex = 'portal.brand.example' document.documentElement.dataset.appMainHost = 'main.brand.example' + document.documentElement.dataset.appLabsHost = 'labs.brand.example' - const { result } = renderHook(() => useAppSlugToHostnameMap()) + const { result } = renderHook(() => useAppSlugToHostMap()) expect(result.current).toEqual({ chat: 'chat.apps.brand.example', @@ -477,11 +530,34 @@ describe('useAppSlugToHostnameMap', () => { ':builtin': 'apps.brand.example', ':portal': 'portal.brand.example', ':main': 'main.brand.example', + ':labs': 'labs.brand.example', + }) + }) + + it('overlays the labs shell host on its own, port included', () => { + document.documentElement.dataset.appLabsHost = 'cbk-labs.localhost:3000' + + const { result } = renderHook(() => useAppSlugToHostMap()) + + expect(result.current).toEqual({ + ':labs': 'cbk-labs.localhost:3000', + }) + }) + + it('never derives shell hosts from the app apex', () => { + document.documentElement.dataset.appApex = 'apps.brand.example' + + const { result } = renderHook(() => useAppSlugToHostMap()) + + expect(result.current).toEqual({ + chat: 'chat.apps.brand.example', + connect: 'connect.apps.brand.example', + ':builtin': 'apps.brand.example', }) }) it('keeps the constants table when the document carries no hosts', () => { - const { result } = renderHook(() => useAppSlugToHostnameMap()) + const { result } = renderHook(() => useAppSlugToHostMap()) expect(result.current).toEqual({}) }) @@ -489,7 +565,7 @@ describe('useAppSlugToHostnameMap', () => { it('overlays only the hosts the document names', () => { document.documentElement.dataset.portalApex = 'portal.brand.example' - const { result } = renderHook(() => useAppSlugToHostnameMap()) + const { result } = renderHook(() => useAppSlugToHostMap()) expect(result.current).toEqual({ ':portal': 'portal.brand.example', @@ -506,7 +582,7 @@ describe('useAppSlugToHostnameMap', () => { let ssrValue function Probe() { - ssrValue = useAppSlugToHostnameMap() + ssrValue = useAppSlugToHostMap() return null } @@ -517,81 +593,81 @@ describe('useAppSlugToHostnameMap', () => { }) }) -describe('configured widget hostname', () => { +describe('configured widget host', () => { beforeEach(() => { delete document.documentElement.dataset.widgetHost }) - it('should resolve the widget hostname from the document', () => { + it('should resolve the widget host from the document', () => { document.documentElement.dataset.widgetHost = 'widgets.brand.example' - const { result } = renderHook(() => useWidgetHostname()) + const { result } = renderHook(() => useWidgetHost()) expect(result.current).toBe('widgets.brand.example') }) - it('should fall back to the configured widget hostname', () => { - const { result } = renderHook(() => useWidgetHostname()) + it('should fall back to the configured widget host', () => { + const { result } = renderHook(() => useWidgetHost()) - expect(result.current).toBe('widgets.example.com') + expect(result.current).toBe('widgets.example.com:8443') }) }) -describe('configured site hostname', () => { +describe('configured site host', () => { beforeEach(() => { delete document.documentElement.dataset.siteHost }) - it('should resolve the site hostname from the document', () => { + it('should resolve the site host from the document', () => { document.documentElement.dataset.siteHost = 'brand.example' - const { result } = renderHook(() => useSiteHostname()) + const { result } = renderHook(() => useSiteHost()) expect(result.current).toBe('brand.example') }) - it('should fall back to the configured site hostname', () => { - const { result } = renderHook(() => useSiteHostname()) + it('should fall back to the configured site host', () => { + const { result } = renderHook(() => useSiteHost()) - expect(result.current).toBe('site.example.com') + expect(result.current).toBe('site.example.com:8443') }) }) -describe('configured static hostname', () => { +describe('configured static host', () => { beforeEach(() => { delete document.documentElement.dataset.staticHost }) - it('should resolve the static hostname from the document', () => { + it('should resolve the static host from the document', () => { document.documentElement.dataset.staticHost = 'static.brand.example' - const { result } = renderHook(() => useStaticHostname()) + const { result } = renderHook(() => useStaticHost()) expect(result.current).toBe('static.brand.example') }) - it('should fall back to the configured static hostname', () => { - const { result } = renderHook(() => useStaticHostname()) + it('should fall back to the configured static host', () => { + const { result } = renderHook(() => useStaticHost()) - expect(result.current).toBe('static.example.com') + expect(result.current).toBe('static.example.com:8443') }) }) -describe('configured API hostname', () => { +describe('configured API host', () => { beforeEach(() => { delete document.documentElement.dataset.apiHost }) - it('should resolve the API hostname from the document', () => { + it('should resolve the API host from the document', () => { document.documentElement.dataset.apiHost = 'api.brand.example' - const { result } = renderHook(() => useAPIHostname()) + const { result } = renderHook(() => useAPIHost()) expect(result.current).toBe('api.brand.example') }) - it('should fall back to the configured API hostname', () => { - const { result } = renderHook(() => useAPIHostname()) + it('should fall back to the configured API host', () => { + const { result } = renderHook(() => useAPIHost()) expect(result.current).toBe('api.example.com') }) @@ -613,7 +689,7 @@ describe('server rendering', () => { // doing so makes the first client render disagree and breaks hydration function Probe() { - return <>{useHostname()} + return <>{useHost()} } it('should not read data-audience while rendering', () => { @@ -641,14 +717,14 @@ describe('server rendering', () => { // the first client render reproduces the server HTML, and only then do // the layout effects resolve the real hostname - const { result } = renderHook(() => useHostname()) + const { result } = renderHook(() => useHost()) expect(serverHtml).toBe('default.example.com') expect(result.current).toBe('html.example.com') }) }) -describe('getDocumentHostname', () => { +describe('getDocumentHost', () => { beforeEach(() => { jest.clearAllMocks() @@ -664,26 +740,45 @@ describe('getDocumentHostname', () => { it('should resolve the hostname from the host cookie', () => { document.cookie = 'host_cookie=cookie.example.com' - expect(getDocumentHostname()).toBe('cookie.example.com') + expect(getDocumentHost()).toBe('cookie.example.com') }) it('should prefer data-audience over the cookie', () => { document.cookie = 'host_cookie=cookie.example.com' document.documentElement.dataset.audience = 'html.example.com' - expect(getDocumentHostname()).toBe('html.example.com') + expect(getDocumentHost()).toBe('html.example.com') }) - it('should fall back to the location hostname when neither is set', () => { - expect(getDocumentHostname()).toBe(window.location.hostname) + it('should fall back to the location host when neither is set', () => { + expect(getDocumentHost()).toBe(window.location.host) }) - it('should replace localhost with siteHostname in production', () => { + it('should replace localhost with the site host in production', () => { isProductionValue = true - isLocalhost.mockReturnValue(true) + isLocalhost.mockImplementation((value) => /^(localhost|127\.)/.test(value)) document.cookie = 'host_cookie=localhost' - expect(getDocumentHostname()).toBe('site.example.com') + expect(getDocumentHost()).toBe('site.example.com:8443') + }) +}) + +describe('useHostname', () => { + beforeEach(() => { + jest.clearAllMocks() + + delete document.documentElement.dataset.audience + }) + + it('reduces the resolved host to its hostname', () => { + useCookie.mockReturnValue('acme.portal.example:3000') + + const { result: host } = renderHook(() => useHost()) + const { result: hostname } = renderHook(() => useHostname()) + + // @note the host keeps the port, the hostname never does + expect(host.current).toBe('acme.portal.example:3000') + expect(hostname.current).toBe('acme.portal.example') }) }) diff --git a/platform/hooks/useRouter.jsx b/platform/hooks/useRouter.jsx index 99e59b3..7066243 100644 --- a/platform/hooks/useRouter.jsx +++ b/platform/hooks/useRouter.jsx @@ -1,4 +1,4 @@ -/* eslint-disable custom-eslint-rules/no-restricted-client-imports -- href resolution seam - the runtime hostname overlays via useHostname; the constants are the fallback */ +/* eslint-disable custom-eslint-rules/no-restricted-client-imports -- href resolution seam - the runtime host overlays via useHost; the constants are the fallback */ /* eslint-disable custom-eslint-rules/require-custom-use-router */ import { useCallback, useMemo } from 'react' @@ -19,14 +19,15 @@ import { isAppPathname, } from '@/lib/app.helpers' import { getExternalFrontendHostURL } from '@/lib/host' -import { tryDomain } from '@/lib/url' +import { hostToHostname } from '@/lib/host.parse' +import { tryHostname } from '@/lib/url' import { - useAppSlugToHostnameMap, - useAudienceHostname, - useCookieHostname, - useSiteHostname, -} from '@/hooks/useHostname' + useAppSlugToHostMap, + useAudienceHost, + useCookieHost, + useSiteHost, +} from '@/hooks/useHost' import i18n from '@/i18n.config' import base from '@/next.config.d/base.config' @@ -37,19 +38,39 @@ const TRANSFERRED_QUERY_KEYS = ['_supertools', '_widget', '_experience'] // --- Helpers --- -function appendTransferredQueryOptions(href, searchParams) { - if (!href || typeof href !== 'string') { - return href +/** + * The parsed form of an absolute href, null for anything else. + * + * @param {unknown} href + * @returns {URL|null} + */ +function parseAbsoluteHref(href) { + if (typeof href !== 'string' || !/^[a-z][a-z\d+.-]*:/i.test(href)) { + return null } - if (/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith('https://')) { - return href + try { + return new URL(href) + } catch { + return null } +} - if (href.startsWith('https://') && (!siteUrl || !href.startsWith(siteUrl))) { +function appendTransferredQueryOptions(href, searchParams, ownOrigins = []) { + if (!href || typeof href !== 'string') { return href } + // @note an absolute href only carries the options over when it stays on the + // origin the page is served on; foreign origins get the href untouched + if (/^[a-z][a-z\d+.-]*:/i.test(href)) { + const parsed = parseAbsoluteHref(href) + + if (!parsed || !ownOrigins.includes(parsed.origin)) { + return href + } + } + const transferEntries = TRANSFERRED_QUERY_KEYS.flatMap((key) => { const value = searchParams?.get?.(key) @@ -115,6 +136,7 @@ function appendTransferredQueryOptions(href, searchParams) { * isFallback: boolean, * isReady: boolean, * isPreview: boolean, + * host: string, * hostname: string, * isSite: boolean, * isAppHostname: boolean, @@ -143,15 +165,29 @@ export default function useRouter() { } } - const cookieHostname = useCookieHostname() - const audienceHostname = useAudienceHostname() - const siteHostnameRuntime = useSiteHostname() + // @note these are hosts - port included - as the data-* attributes and the + // host cookie carry them; the app tables hold hosts too and are looked up + // by hostname + + const cookieHost = useCookieHost() + const audienceHost = useAudienceHost() + const siteHostRuntime = useSiteHost() + + const host = cookieHost || audienceHost + const hostname = hostToHostname(host) + + // @note the origin the page is served on, known once the host is - the + // configured site url is not "own" on a shell, portal or partner host + const ownOrigins = useMemo( + () => (host ? [new URL(getExternalFrontendHostURL('/', host)).origin] : []), + [host] + ) // @note the build-time hostname table reads server-only environment, so in // the browser it carries no apex hosts - the runtime overlay keeps app and // portal hosts recognisable after hydration - const hostnameMap = useAppSlugToHostnameMap() + const hostMap = useAppSlugToHostMap() // @note only force an absolute http:// href up to https when the current // page is actually served over https. On an http deployment (local / @@ -167,16 +203,16 @@ export default function useRouter() { : siteUrl.startsWith('https://') const appSlug = useMemo(() => { - if (isAppHostname(cookieHostname, hostnameMap)) { - return getAppSlugByHostname(cookieHostname, hostnameMap) - } + for (const candidate of [cookieHost, audienceHost]) { + const candidateHostname = hostToHostname(candidate) - if (isAppHostname(audienceHostname, hostnameMap)) { - return getAppSlugByHostname(audienceHostname, hostnameMap) + if (isAppHostname(candidateHostname, hostMap)) { + return getAppSlugByHostname(candidateHostname, hostMap) + } } return null - }, [cookieHostname, audienceHostname, hostnameMap]) + }, [cookieHost, audienceHost, hostMap]) const normalizeHref = useCallback( (href) => { @@ -211,9 +247,9 @@ export default function useRouter() { } } - return appendTransferredQueryOptions(result, searchParams) + return appendTransferredQueryOptions(result, searchParams, ownOrigins) }, - [isSecure, pathname, searchParams] + [isSecure, pathname, searchParams, ownOrigins] ) const resolveHref = useCallback( @@ -242,7 +278,7 @@ export default function useRouter() { } if (!APP_TYPES.includes(appSlug)) { - for (const slug of Object.keys(hostnameMap)) { + for (const slug of Object.keys(hostMap)) { const lookup = `/${slug}` if (result === lookup) { @@ -262,12 +298,13 @@ export default function useRouter() { } } } else { - if (result?.startsWith?.(siteUrl)) { - result = result.slice(siteUrl.length) + // @note an absolute href on the page's own origin is a relative + // transition - compared as origins, so a look-alike host or a + // different port never gets its prefix cut off + const parsed = parseAbsoluteHref(result) - if (!result) { - result = '/' - } + if (parsed && ownOrigins.includes(parsed.origin)) { + result = `${parsed.pathname}${parsed.search}${parsed.hash}` || '/' } } } @@ -286,38 +323,40 @@ export default function useRouter() { } } - return appendTransferredQueryOptions(result, searchParams) + return appendTransferredQueryOptions(result, searchParams, ownOrigins) }, - [appSlug, isSecure, pathname, searchParams, hostnameMap] + [appSlug, isSecure, pathname, searchParams, hostMap, ownOrigins] ) const absoluteHref = useCallback( (href) => { - return getExternalFrontendHostURL( - normalizeHref(href), - cookieHostname || audienceHostname || undefined - ) + return getExternalFrontendHostURL(normalizeHref(href), host || undefined) }, - [normalizeHref, cookieHostname, audienceHostname] + [normalizeHref, host] ) const isKnownHref = useCallback( (href) => { - const domain = tryDomain(href) + // @note compare hostnames, not registrable domains: the domain of + // apps.chatbotkit.com is chatbotkit.com, so an app host only ever + // matched through the site entry, and never on its own + const hostname = tryHostname(href) - if (!domain) { + if (!hostname) { return false } - const knownHostnames = Object.values(hostnameMap).concat([ - siteHostnameRuntime, - ]) + const knownHostnames = Object.values(hostMap) + .concat([siteHostRuntime]) + .map(hostToHostname) return knownHostnames.some((knownHostname) => { - return domain === knownHostname || domain.endsWith(`.${knownHostname}`) + return ( + hostname === knownHostname || hostname.endsWith(`.${knownHostname}`) + ) }) }, - [siteHostnameRuntime, hostnameMap] + [siteHostRuntime, hostMap] ) const compareHref = useCallback( @@ -434,20 +473,26 @@ export default function useRouter() { return value } + case 'host': { + let value = host + + return value + } + case 'hostname': { - let value = cookieHostname + let value = hostname return value } case 'isSite': { - let value = cookieHostname === siteHostnameRuntime + let value = host === siteHostRuntime return value } case 'isAppHostname': { - let value = isAppHostname(cookieHostname || '', hostnameMap) + let value = isAppHostname(hostname, hostMap) return value } @@ -515,11 +560,13 @@ export default function useRouter() { pathname, - cookieHostname, + host, + + hostname, - siteHostnameRuntime, + siteHostRuntime, - hostnameMap, + hostMap, normalizeHref, resolveHref, diff --git a/platform/hooks/useRouter.utest.jsx b/platform/hooks/useRouter.utest.jsx index 939be93..738db0b 100644 --- a/platform/hooks/useRouter.utest.jsx +++ b/platform/hooks/useRouter.utest.jsx @@ -1,3 +1,4 @@ +/* eslint-disable custom-eslint-rules/require-custom-use-router -- this suite mocks the Next router wrapped by useRouter */ import useRouter from './useRouter' import { renderHook } from '@testing-library/react' @@ -12,7 +13,7 @@ jest.mock('next/navigation', () => ({ jest.mock('@/config/apps', () => ({ APP_TYPES: [':main', ':labs', ':builtin', ':portal', ':custom'], appSlugs: ['chat', 'connect'], - appSlugToHostnameMap: Object.freeze({}), + appSlugToHostMap: Object.freeze({}), })) jest.mock('@/config/site', () => ({ @@ -21,14 +22,16 @@ jest.mock('@/config/site', () => ({ })) jest.mock('@/lib/host', () => ({ - getExternalFrontendHostURL: jest.fn((path) => `https://front.example${path}`), + getExternalFrontendHostURL: jest.fn( + (path, host) => `https://${host || 'front.example'}${path}` + ), })) -jest.mock('@/hooks/useHostname', () => ({ - useAppSlugToHostnameMap: jest.fn(), - useAudienceHostname: jest.fn(), - useCookieHostname: jest.fn(), - useSiteHostname: jest.fn(), +jest.mock('@/hooks/useHost', () => ({ + useAppSlugToHostMap: jest.fn(), + useAudienceHost: jest.fn(), + useCookieHost: jest.fn(), + useSiteHost: jest.fn(), })) jest.mock('@/i18n.config', () => ({ @@ -41,19 +44,19 @@ jest.mock('@/next.config.d/base.config', () => ({ default: { basePath: '' }, })) -const { - useRouter: useNextRouter, - useSearchParams: useNextSearchParams, - useParams: useNextParams, - usePathname: useNextPathname, -} = require('next/navigation') +import { + useRouter as useNextRouter, + useSearchParams as useNextSearchParams, + useParams as useNextParams, + usePathname as useNextPathname, +} from 'next/navigation' -const { - useAppSlugToHostnameMap, - useAudienceHostname, - useCookieHostname, - useSiteHostname, -} = require('@/hooks/useHostname') +import { + useAppSlugToHostMap, + useAudienceHost, + useCookieHost, + useSiteHost, +} from '@/hooks/useHost' const push = jest.fn() @@ -62,16 +65,17 @@ function setup({ audienceHostname = '', hostnameMap = {}, pathname = '/', + searchParams = new URLSearchParams(), } = {}) { useNextRouter.mockReturnValue({ push, replace: jest.fn() }) - useNextSearchParams.mockReturnValue(new URLSearchParams()) + useNextSearchParams.mockReturnValue(searchParams) useNextParams.mockReturnValue({}) useNextPathname.mockReturnValue(pathname) - useCookieHostname.mockReturnValue(cookieHostname) - useAudienceHostname.mockReturnValue(audienceHostname) - useSiteHostname.mockReturnValue('site.example.com') - useAppSlugToHostnameMap.mockReturnValue(Object.freeze(hostnameMap)) + useCookieHost.mockReturnValue(cookieHostname) + useAudienceHost.mockReturnValue(audienceHostname) + useSiteHost.mockReturnValue('site.example.com') + useAppSlugToHostMap.mockReturnValue(Object.freeze(hostnameMap)) return renderHook(() => useRouter()).result.current } @@ -91,6 +95,8 @@ describe('useRouter href resolution by host', () => { it('strips the site url from absolute hrefs', () => { const router = setup({ cookieHostname: 'site.example.com' }) + // @note the runtime host is the page's own origin; nothing else is + expect(router.resolveHref('https://site.example.com')).toBe('/') expect(router.resolveHref('https://site.example.com/pricing')).toBe( '/pricing' ) @@ -177,6 +183,48 @@ describe('useRouter href resolution by host', () => { }) }) + describe('own origin on a foreign host', () => { + it('keeps a configured-site link absolute on a portal domain', () => { + const router = setup({ cookieHostname: 'acme.example' }) + + // @note siteUrl in this suite is https://site.example.com + expect(router.resolveHref('https://site.example.com/overview')).toBe( + 'https://site.example.com/overview' + ) + expect(router.resolveHref('https://acme.example/overview')).toBe( + '/overview' + ) + }) + + it('never cuts a look-alike host or another port down to a path', () => { + const router = setup({ cookieHostname: 'acme.example' }) + + expect(router.resolveHref('https://acme.example.evil/x')).toBe( + 'https://acme.example.evil/x' + ) + expect(router.resolveHref('https://acme.example:8443/x')).toBe( + 'https://acme.example:8443/x' + ) + expect(router.resolveHref('https://acme.example/x?a=1#b')).toBe( + '/x?a=1#b' + ) + }) + + it('transfers session options only onto the page origin', () => { + const router = setup({ + cookieHostname: 'acme.example', + searchParams: new URLSearchParams({ _experience: 'builder' }), + }) + + expect(router.normalizeHref('https://acme.example/x')).toBe( + 'https://acme.example/x?_experience=builder' + ) + expect(router.normalizeHref('https://site.example.com/x')).toBe( + 'https://site.example.com/x' + ) + }) + }) + describe('normalizeHref', () => { it('never strips the /apps prefix', () => { const router = setup({ @@ -198,5 +246,59 @@ describe('useRouter href resolution by host', () => { expect(router.isKnownHref('https://acme.portal.example/x')).toBe(true) expect(router.isKnownHref('https://unrelated.example/x')).toBe(false) }) + + it('knows a localhost deployment host by hostname, port and all', () => { + // @note the registrable domain of cbk.localhost is `localhost`, which + // would match nothing; the comparison is on hostnames + const router = setup({ + hostnameMap: { ':main': 'cbk.localhost:3000' }, + }) + + expect(router.isKnownHref('http://cbk.localhost:3000/x')).toBe(true) + expect(router.isKnownHref('http://apps.cbk.localhost:3000/x')).toBe(true) + expect(router.isKnownHref('http://other.localhost:3000/x')).toBe(false) + }) + }) +}) + +describe('useRouter on hosts that carry a port', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const portal = { + cookieHostname: 'acme-portal.portal.example:3000', + hostnameMap: { ':portal': 'portal.example:3000' }, + } + + it('recognises the portal by hostname and reports host and hostname apart', () => { + const router = setup(portal) + + // @note the app tables are looked up by hostname; passing the host as-is + // matches nothing, so the /apps prefix would survive + expect(router.resolveHref('/apps/chat/abc')).toBe('/chat/abc') + expect(router.isAppHostname).toBe(true) + + expect(router.host).toBe('acme-portal.portal.example:3000') + expect(router.hostname).toBe('acme-portal.portal.example') + expect(router.isSite).toBe(false) + }) + + it('resolves through a ported audience host without a cookie', () => { + const router = setup({ + audienceHostname: 'acme-portal.portal.example:3000', + hostnameMap: { ':portal': 'portal.example:3000' }, + }) + + expect(router.resolveHref('/apps/chat/abc')).toBe('/chat/abc') + expect(router.host).toBe('acme-portal.portal.example:3000') + }) + + it('builds absolute hrefs on the ported host', () => { + const router = setup(portal) + + expect(router.absoluteHref('/x')).toBe( + 'https://acme-portal.portal.example:3000/x' + ) }) }) diff --git a/platform/layouts/App.jsx b/platform/layouts/App.jsx index 69594c0..35fbdf1 100644 --- a/platform/layouts/App.jsx +++ b/platform/layouts/App.jsx @@ -15,6 +15,7 @@ import { FIVE_MINUTE_IN_MILLISECONDS } from '@chatbotkit-dev/time' import { CUSTOM_TYPE, PORTAL_TYPE, apps as configApps } from '@/config/apps' +import { getExternalFrontendHostURL } from '@/lib/host' import { getAppTypeByHostname } from '@/lib/app.helpers' import { isProduction } from '@/lib/env' import { toKebabCase, toTitleCase } from '@/lib/string' @@ -1317,7 +1318,11 @@ export function AppMain({ favicon={favicon} image={image} appManifest={appManifest} - baseUrl={router.hostname ? `https://${router.hostname}` : undefined} + baseUrl={ + router.host + ? getExternalFrontendHostURL('/', router.host) + : undefined + } /> {gtag ? : null} {/* the following code prevents the rubber-band effect */} diff --git a/platform/lib/action.exec.fetch.utest.js b/platform/lib/action.exec.fetch.utest.js index 4bc4460..f60852a 100644 --- a/platform/lib/action.exec.fetch.utest.js +++ b/platform/lib/action.exec.fetch.utest.js @@ -63,6 +63,7 @@ jest.mock('@/lib/fetch', () => { jest.mock('@/lib/context.store', () => ({ getContextContact: jest.fn(), + getContextFrontendHost: jest.fn(), getContextTimezone: jest.fn(), })) diff --git a/platform/lib/api.tool.url.ts b/platform/lib/api.tool.url.ts new file mode 100644 index 0000000..38a9d91 --- /dev/null +++ b/platform/lib/api.tool.url.ts @@ -0,0 +1,41 @@ +import { getExternalAPIHost } from '@/lib/host' + +/** + * Resolves the URL an in-app HTTP tool may call. Requests to the deployment's + * API host and same-origin requests are folded onto the page origin under + * /api, so they stay same-origin and carry the session; anything else is + * left as is for the caller to refuse. + */ +export function resolvePlatformApiUrl( + url: string, + location: { origin: string; host: string } = window.location +): URL { + const u = new URL(url, location.origin) + + // @note the page knows its request-affine API host from the document; the + // configured one is the fallback. Hosts carry their port on either side + const apiHost = + (typeof document !== 'undefined' && + document.documentElement.dataset.apiHost) || + getExternalAPIHost() + + // @note URL.host omits a default port, while a mapping may spell it out + const normalizedAPIHost = apiHost + ? new URL(`${u.protocol}//${apiHost}`).host + : undefined + + if (u.host === normalizedAPIHost || u.host === location.host) { + const localOrigin = new URL(location.origin) + + u.protocol = localOrigin.protocol + // @note assigning host alone retains the API port when the page uses its + // scheme's default port, so replace the hostname and port separately + u.hostname = localOrigin.hostname + u.port = localOrigin.port + u.pathname = u.pathname.startsWith('/api/') + ? u.pathname + : `/api${u.pathname}` + } + + return u +} diff --git a/platform/lib/api.tool.url.utest.js b/platform/lib/api.tool.url.utest.js new file mode 100644 index 0000000..563b82f --- /dev/null +++ b/platform/lib/api.tool.url.utest.js @@ -0,0 +1,107 @@ +import { resolvePlatformApiUrl } from '@/lib/api.tool.url' +import { getExternalAPIHost } from '@/lib/host' + +jest.mock('@/lib/host', () => ({ + getExternalAPIHost: jest.fn(), +})) + +describe('resolvePlatformApiUrl', () => { + const location = { + origin: 'https://console.example', + host: 'console.example', + } + + beforeEach(() => { + getExternalAPIHost.mockReturnValue('api.example') + }) + + it.each([ + ['/v1/models', 'https://console.example/api/v1/models'], + ['/api/v1/models', 'https://console.example/api/v1/models'], + [ + 'https://console.example/v1/models', + 'https://console.example/api/v1/models', + ], + ['https://api.example/v1/models', 'https://console.example/api/v1/models'], + [ + 'https://api.example/api/v1/models', + 'https://console.example/api/v1/models', + ], + ])('folds %s onto the page origin under /api', (url, expected) => { + expect(resolvePlatformApiUrl(url, location).href).toBe(expected) + }) + + it('prefers the API host stamped on the document', () => { + document.documentElement.dataset.apiHost = 'api.brand.example' + + try { + expect( + resolvePlatformApiUrl('https://api.brand.example/v1/x', location).href + ).toBe('https://console.example/api/v1/x') + } finally { + delete document.documentElement.dataset.apiHost + } + }) + + it('keeps a foreign origin untouched for the caller to refuse', () => { + expect( + resolvePlatformApiUrl('https://other.example/v1/x', location).href + ).toBe('https://other.example/v1/x') + }) + + it('normalizes an explicit default port in the mapped API host', () => { + document.documentElement.dataset.apiHost = 'api.brand.example:80' + + try { + expect( + resolvePlatformApiUrl('http://api.brand.example/v1/models', location) + .href + ).toBe('https://console.example/api/v1/models') + } finally { + delete document.documentElement.dataset.apiHost + } + }) + + it('does not accept another port on the mapped API hostname', () => { + document.documentElement.dataset.apiHost = 'api.brand.example:8443' + + try { + expect( + resolvePlatformApiUrl( + 'https://api.brand.example:9443/v1/models', + location + ).href + ).toBe('https://api.brand.example:9443/v1/models') + } finally { + delete document.documentElement.dataset.apiHost + } + }) + + it('keeps the page port when folding', () => { + getExternalAPIHost.mockReturnValue('api.example:8443') + + expect( + resolvePlatformApiUrl('https://api.example:8443/v1/x', { + origin: 'http://console.example:3000', + host: 'console.example:3000', + }).href + ).toBe('http://console.example:3000/api/v1/x') + }) + + it.each([ + ['https://api.example:8443', 'https://console.example'], + ['https://api.example:8443', 'http://console.example'], + ['http://api.example:8080', 'https://console.example'], + ['http://api.example:8080', 'http://console.example'], + ])('clears the API port when folding %s onto %s', (apiOrigin, pageOrigin) => { + getExternalAPIHost.mockReturnValue(new URL(apiOrigin).host) + + const resolved = resolvePlatformApiUrl(`${apiOrigin}/v1/x?a=1#result`, { + origin: pageOrigin, + host: new URL(pageOrigin).host, + }) + + expect(resolved.origin).toBe(pageOrigin) + expect(resolved.href).toBe(`${pageOrigin}/api/v1/x?a=1#result`) + }) +}) diff --git a/platform/lib/app.helpers.ts b/platform/lib/app.helpers.ts index 8bef3ec..740ae0d 100644 --- a/platform/lib/app.helpers.ts +++ b/platform/lib/app.helpers.ts @@ -2,17 +2,21 @@ import { APP_TYPES, BUILTIN_TYPE, CUSTOM_TYPE, - appSlugToHostnameMap, + appSlugToHostMap, appSlugToUrlMap, apps, } from '@/config/apps' import { siteUrl } from '@/config/site' +import { hostToHostname } from '@/lib/host.parse' import { tryHostname, tryPathname, tryUrl } from '@/lib/url' +// @note the tables hold hosts; lookups take a hostname and reduce the table +// entries to hostnames, so a port never takes part in the comparison + export function isAppHostname( hostname: string | { hostname: string }, - hostnameMap: Readonly> = appSlugToHostnameMap + hostMap: Readonly> = appSlugToHostMap ): boolean { const _hostname = typeof hostname === 'object' && hostname !== null @@ -23,8 +27,15 @@ export function isAppHostname( return false } - return Object.values(hostnameMap).some((appHostname) => { - return appHostname === _hostname || _hostname.endsWith(`.${appHostname}`) + const normalizedHostname = _hostname.toLowerCase() + + return Object.values(hostMap).some((host) => { + const appHostname = hostToHostname(host) + + return ( + appHostname === normalizedHostname || + normalizedHostname.endsWith(`.${appHostname}`) + ) }) } @@ -76,7 +87,7 @@ export function isAppUrl(url: string | URL): boolean { export function getAppSlugByHostname( hostname: string | { hostname: string }, - hostnameMap: Readonly> = appSlugToHostnameMap + hostMap: Readonly> = appSlugToHostMap ): string | null { const _hostname = typeof hostname === 'object' && hostname !== null @@ -87,9 +98,16 @@ export function getAppSlugByHostname( return null } + const normalizedHostname = _hostname.toLowerCase() + return ( - Object.entries(hostnameMap).find(([, appHostname]) => { - return appHostname === _hostname || _hostname.endsWith(`.${appHostname}`) + Object.entries(hostMap).find(([, host]) => { + const appHostname = hostToHostname(host) + + return ( + appHostname === normalizedHostname || + normalizedHostname.endsWith(`.${appHostname}`) + ) })?.[0] || null ) } @@ -122,7 +140,22 @@ export function getAppTypeByHostname( export function getAppManifestByHostname( hostname: string | { hostname: string } ): (typeof apps)[number] | null { - return apps.find((app) => app.host === hostname) || null + const _hostname = + typeof hostname === 'object' && hostname !== null + ? hostname.hostname + : hostname + + if (!_hostname || typeof _hostname !== 'string') { + return null + } + + const normalizedHostname = _hostname.toLowerCase() + + return ( + apps.find( + (app) => !!app.host && hostToHostname(app.host) === normalizedHostname + ) || null + ) } export function getAppConfigByHostname( diff --git a/platform/lib/app.helpers.utest.js b/platform/lib/app.helpers.utest.js index 7d69ee8..35947b6 100644 --- a/platform/lib/app.helpers.utest.js +++ b/platform/lib/app.helpers.utest.js @@ -1,5 +1,7 @@ import { siteUrl } from '@/config/site' +import { hostToHostname } from '@/lib/host.parse' + import { getAppConfigByHostname, getAppConfigBySlug, @@ -23,6 +25,9 @@ describe('isAppHostname', () => { ['apps.chatbotkit.com', true], ['test123.chatbotkit.app', true], ['test123.chatbotkit.agency', true], + // @note a host is not a hostname - callers reduce with hostToHostname + ['labs.chatbotkit.com:3000', false], + ['APPS.chatbotkit.com', true], [siteUrl, false], ])('validates domain %s correctly', (domain, expected) => { expect(isAppHostname(domain)).toBe(expected) @@ -120,6 +125,34 @@ describe('isAppUrl', () => { }) }) +describe('getAppManifestByHostname', () => { + it('finds a shell whose origin carries a port by its hostname', () => { + const previous = process.env.APP_LABS_ORIGIN + + process.env.APP_LABS_ORIGIN = 'http://cbk-labs.localhost:3000' + + try { + jest.isolateModules(() => { + const helpers = jest.requireActual('@/lib/app.helpers') + + expect(helpers.getAppManifestByHostname('cbk-labs.localhost')?.slug).toBe( + ':labs' + ) + expect(helpers.getAppManifestByHostname('CBK-Labs.localhost')?.slug).toBe( + ':labs' + ) + // @note a host is not a hostname + expect(helpers.getAppManifestByHostname('cbk-labs.localhost:3000')).toBe( + null + ) + expect(helpers.getAppConfigByHostname('cbk-labs.localhost')).toBeTruthy() + }) + } finally { + process.env.APP_LABS_ORIGIN = previous + } + }) +}) + describe('getAppSlugByHostname', () => { it('should return correct slug for known hostnames', () => { expect(getAppSlugByHostname('chat.chatbotkit.app')).toBe('chat') @@ -127,6 +160,21 @@ describe('getAppSlugByHostname', () => { expect(getAppSlugByHostname('task.chatbotkit.app')).toBe('task') }) + it('matches a hostname against a table that carries ports', () => { + const table = { ':labs': 'cbk-labs.localhost:3000' } + + expect(isAppHostname('cbk-labs.localhost', table)).toBe(true) + expect(getAppSlugByHostname('cbk-labs.localhost', table)).toBe(':labs') + expect(isAppHostname('cbk-labs.localhost.attacker', table)).toBe(false) + + // @note a host has to be reduced first; passed as-is it never matches + expect(isAppHostname('cbk-labs.localhost:3000', table)).toBe(false) + expect(getAppSlugByHostname('labs.chatbotkit.com:3000')).toBe(null) + expect( + getAppSlugByHostname(hostToHostname('labs.chatbotkit.com:3000')) + ).toBe(':labs') + }) + it('should return null for unknown hostnames', () => { expect(getAppSlugByHostname('example.com')).toBe(null) expect(getAppSlugByHostname('chatbotkit.com')).toBe(null) diff --git a/platform/lib/app.router.app.config.ts b/platform/lib/app.router.app.config.ts index d55c5ef..7975a33 100644 --- a/platform/lib/app.router.app.config.ts +++ b/platform/lib/app.router.app.config.ts @@ -35,25 +35,26 @@ import debug from '@/lib/debug' import { merge, omit } from '@/lib/object' import { getPortalGlobalConfig } from '@/lib/portal.config' import { getPortalSlugFromHostname } from '@/lib/portal.hostname' +import { hostToHostname } from '@/lib/host.parse' import type { ZodObject, ZodRawShape } from '@/lib/zod.schema' import z, { partialObjectParseAsync, tryParseAsync } from '@/lib/zod.schema' /** - * The exact hosts the app shells answer on, derived from their configured + * The exact hostnames the app shells answer on, derived from their configured * origins - empty when no shell has an origin. */ -const shellHostnames = [appMainHost, appLabsHost].filter( - (hostname): hostname is string => !!hostname -) +const shellHostnames = [appMainHost, appLabsHost] + .filter((host): host is string => !!host) + .map(hostToHostname) function getContextAppHostname(): string | null { - const hosts = [getContextFrontendHost(), getContextRequestHost()].filter( - (host): host is string => !!host - ) + const hostnames = [getContextFrontendHost(), getContextRequestHost()] + .filter((host): host is string => !!host) + .map(hostToHostname) // @note a custom frontend domain is public identity while the request host // retains the platform app or portal hostname used for internal routing - return hosts.find((host) => isAppHostname(host)) || null + return hostnames.find((hostname) => isAppHostname(hostname)) || null } async function getPortalBySlug(slug: string): Promise { @@ -91,16 +92,16 @@ export async function getPublicAppConfig(): Promise( // app hostname { - const host = getContextAppHostname() + const hostname = getContextAppHostname() - if (host) { - debug(`resolved request host`, { host }).log( + if (hostname) { + debug(`resolved request hostname`, { hostname }).log( 'app.router.app.config.getUserAppConfig' ) - if (isAppHostname(host)) { + if (isAppHostname(hostname)) { debug(`getting user app config for app hostname`, { app }).log( 'app.router.app.config.getUserAppConfig' ) // portals { - const portalSlug = getPortalSlugFromHostname(host) + const portalSlug = getPortalSlugFromHostname(hostname) if (portalSlug) { debug(`getting user app config for portal with slug`, { @@ -424,15 +425,15 @@ export async function getUserAppConfig( // the app shell hosts (from the deployment's hostname table) { - if (shellHostnames.includes(host)) { - debug(`getting user app config for an app shell host`, { - host, + if (shellHostnames.includes(hostname)) { + debug(`getting user app config for an app shell hostname`, { + hostname, app, }).log('app.router.app.config.getUserAppConfig') - const appManifest = getAppManifestByHostname(host) - const appGlobal = getAppGlobalByHostname(host) - const appConfig = getAppConfigByHostname(host) + const appManifest = getAppManifestByHostname(hostname) + const appGlobal = getAppGlobalByHostname(hostname) + const appConfig = getAppConfigByHostname(hostname) if (appManifest) { const totalConfig = merge( @@ -482,7 +483,7 @@ export async function getUserAppConfig( // hosted apps under the standalone app apex { - const appSlug = getAppSlugByHostname(host) + const appSlug = getAppSlugByHostname(hostname) if (appSlug) { debug(`getting user app config for app with slug`, { @@ -563,7 +564,7 @@ export async function getUserAppConfig( // @note surface the requested app's own manifest config (e.g. chat's // `models`/`sources`/`save`) so its server actions see the same config on - // the dashboard host that they already get on the app's own hostname, + // the dashboard hostname that they already get on the app's own hostname, // where `getPublicConfig` below would otherwise expose only public display // fields. Kept below the dashboard defaults so `apps`/`layout` still win. @@ -584,7 +585,7 @@ export async function getUserAppConfig( }, // @note give the dashboard a sidebar so apps render the app navigation - // chrome - matching the apps.chatbotkit.com host config. Without this + // chrome - matching the apps.chatbotkit.com hostname config. Without this // only apps that pass explicit sidebarItems (e.g. inbox) show a sidebar. // The App layout still hides it automatically when embedded. layout: { diff --git a/platform/lib/app.router.app.config.utest.js b/platform/lib/app.router.app.config.utest.js index b84560d..df626c8 100644 --- a/platform/lib/app.router.app.config.utest.js +++ b/platform/lib/app.router.app.config.utest.js @@ -865,3 +865,31 @@ describe('getUserAppConfig', () => { }) }) }) + +describe('getPublicAppConfig on a request host that carries a port', () => { + beforeEach(() => { + jest.clearAllMocks() + + getContextFrontendHost.mockReturnValue(null) + getContextRequestHost.mockReturnValue('portal.chatbotkit.com:3000') + isAppHostname.mockReturnValue(true) + getPortalSlugFromHostname.mockReturnValue(null) + getAppSlugByHostname.mockReturnValue(null) + getPublicConfig.mockImplementation((config) => config) + getShadowConfig.mockImplementation((config) => config) + merge.mockImplementation((...configs) => Object.assign({}, ...configs)) + }) + + it('reduces the host to a hostname before every lookup', async () => { + await getPublicAppConfig() + + // @note the app and portal tables hold hostnames; a port would match + // nothing and the request would fall through to the platform config + expect(isAppHostname).toHaveBeenCalledWith('portal.chatbotkit.com') + expect(isAppHostname).not.toHaveBeenCalledWith('portal.chatbotkit.com:3000') + expect(getPortalSlugFromHostname).toHaveBeenCalledWith( + 'portal.chatbotkit.com' + ) + expect(getAppSlugByHostname).toHaveBeenCalledWith('portal.chatbotkit.com') + }) +}) diff --git a/platform/lib/app.router.app.manifest.hosts.utest.js b/platform/lib/app.router.app.manifest.hosts.utest.js new file mode 100644 index 0000000..554b100 --- /dev/null +++ b/platform/lib/app.router.app.manifest.hosts.utest.js @@ -0,0 +1,52 @@ +import { getAppManifestPath } from '@/lib/app.router.app.manifest' +import { setupHeadersContext } from '@/lib/context.setup' +import { + executeInContext, + getContextFrontendHost, + getContextRequestHost, +} from '@/lib/context.store' + +jest.mock('@/config/origins', () => ({ + appMainHost: 'apps.manifest.localhost:4300', + appLabsHost: 'labs.manifest.localhost:4300', +})) + +jest.mock('@/config/hosts', () => ({ + ...jest.requireActual('@/config/hosts'), + hostsConfig: { + shell: { + match: ['apps.manifest.localhost:4300', 'labs.manifest.localhost:4300'], + site: 'console.manifest.localhost:4300', + api: 'console.manifest.localhost:4300', + static: 'console.manifest.localhost:4300', + widgets: 'console.manifest.localhost:4300', + }, + }, +})) + +describe('manifest discovery with request host mappings', () => { + it.each(['apps', 'labs'])( + 'keeps the manifest link on the mapped %s shell', + async (shell) => { + await executeInContext(async () => { + const host = `${shell}.manifest.localhost:4300` + + setupHeadersContext(new Headers({ host })) + + expect(getContextRequestHost()).toBe(host) + expect(getContextFrontendHost()).toBe('console.manifest.localhost:4300') + expect(getAppManifestPath()).toBe('/app.webmanifest') + }) + } + ) + + it('does not advertise an app manifest on a platform-only host', async () => { + await executeInContext(async () => { + setupHeadersContext( + new Headers({ host: 'console.manifest.localhost:4300' }) + ) + + expect(getAppManifestPath()).toBeNull() + }) + }) +}) diff --git a/platform/lib/app.router.app.manifest.ts b/platform/lib/app.router.app.manifest.ts index cd5b44d..341c465 100644 --- a/platform/lib/app.router.app.manifest.ts +++ b/platform/lib/app.router.app.manifest.ts @@ -3,6 +3,7 @@ import '@/lib/scope.server' import { siteHostname } from '@/config/site' import { isAppHostname } from '@/lib/app.helpers' +import { hostToHostname } from '@/lib/host.parse' import { getContextFrontendHost, getContextRequestHost, @@ -11,10 +12,17 @@ import { export function getAppManifestPath(app?: string): string | null { app // @note app is not used and only here for future-proofing - const host = - getContextFrontendHost() || getContextRequestHost() || siteHostname + // @note a mapped frontend may name the site while the request still routes + // to an app shell, so check both identities as app configuration does + const hostnames = [getContextFrontendHost(), getContextRequestHost()] + .map(hostToHostname) + .filter(Boolean) - if (isAppHostname(host)) { + if ( + (hostnames.length ? hostnames : [siteHostname]).some((hostname) => + isAppHostname(hostname) + ) + ) { return `/app.webmanifest` } else { return null diff --git a/platform/lib/app.router.app.manifest.utest.js b/platform/lib/app.router.app.manifest.utest.js index a30b57b..d4e0413 100644 --- a/platform/lib/app.router.app.manifest.utest.js +++ b/platform/lib/app.router.app.manifest.utest.js @@ -29,6 +29,14 @@ describe('getAppManifestPath', () => { getContextRequestHost.mockReturnValue(null) }) + it('reduces a context host with a port to a hostname before the lookup', () => { + getContextRequestHost.mockReturnValue('cbk-labs.localhost:3000') + isAppHostname.mockReturnValue(true) + + expect(getAppManifestPath()).toBe('/app.webmanifest') + expect(isAppHostname).toHaveBeenCalledWith('cbk-labs.localhost') + }) + describe('basic functionality', () => { it('should return manifest path for app hostnames', () => { getContextRequestHost.mockReturnValue('chat.chatbotkit.app') diff --git a/platform/lib/call.ts b/platform/lib/call.ts index f12571f..debb5aa 100644 --- a/platform/lib/call.ts +++ b/platform/lib/call.ts @@ -110,7 +110,7 @@ export async function call( if ( PIPEDREAM_RELATIVE_APPS.some((app) => - targetUrl.host.endsWith(app) + targetUrl.hostname.endsWith(app) ) ) { destination = targetUrl.pathname + targetUrl.search diff --git a/platform/lib/call.utest.js b/platform/lib/call.utest.js index 5e5bc48..06a229e 100644 --- a/platform/lib/call.utest.js +++ b/platform/lib/call.utest.js @@ -809,3 +809,49 @@ describe('call', () => { }) }) }) + +describe('Pipedream relative apps on a host that carries a port', () => { + it('folds the target down to its path by hostname', async () => { + const mockFetch = jest.requireMock('@/lib/fetch').default + const mockTryVerify = jest.requireMock('@/lib/jwt').tryVerify + const mockTtlCache = jest.requireMock('@/lib/cache').ttlCache + const { decode } = jest.requireActual('@/lib/b64') + + mockFetch.mockResolvedValue(new Response('OK', { status: 200 })) + mockTtlCache.mockImplementation((key, ttl, fn) => fn()) + mockTryVerify.mockResolvedValue({ + type: 'pipedream_access_token', + projectId: 'test-project-id', + externalUserId: 'user-123', + accountId: 'account-456', + secretId: 'secret-789', + environment: 'production', + }) + + prisma.secret.findUnique = jest.fn().mockResolvedValue({ + id: 'secret-789', + name: 'Test Secret', + }) + jest + .requireMock('@/lib/secret.oauth') + .getSecretOAuthConfig.mockResolvedValue({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }) + jest + .requireMock('@/lib/oauth.authorization') + .getClientCredentialsGrantCredentials.mockResolvedValue({ + accessToken: 'pipedream-access-token', + }) + + await call('https://acme.zendesk.com:8443/api/v2/tickets?x=1', { + headers: { authorization: 'Bearer jwt.token' }, + }) + + const calledUrl = mockFetch.mock.calls[0][0] + const encoded = calledUrl.pathname.split('/proxy/')[1] + + // @note a port on the target does not make it a foreign host + expect(decode(encoded)).toBe('/api/v2/tickets?x=1') + }) +}) diff --git a/platform/lib/context.setup.ts b/platform/lib/context.setup.ts index 98eb0eb..475abc1 100644 --- a/platform/lib/context.setup.ts +++ b/platform/lib/context.setup.ts @@ -25,38 +25,10 @@ import { getUserAgentHeader, } from '@/lib/header' import { injectInternalAssertionContext } from '@/lib/header.assertion' +import { hostToHostname, normalizeRequestHost } from '@/lib/host.parse' import { isIpAddress } from '@/lib/ip' import { getQuery } from '@/lib/query.get' -/** - * Resolves the untrusted request host at the request-normalization boundary. - * Portal-originated URLs must prefer the separately authenticated frontend - * host; this value remains ordinary request metadata. - */ -function normalizeRequestHost(value: string | null): string | null { - if (!value) { - return null - } - - try { - const url = new URL(`https://${value.trim()}`) - - if ( - url.username || - url.password || - url.pathname !== '/' || - url.search || - url.hash - ) { - return null - } - - return url.host - } catch { - return null - } -} - function normalizeRequestProtocol(value: string | null): string | null { const protocol = value?.trim().toLowerCase() @@ -70,8 +42,17 @@ function shouldTrustProxyHeaders(): boolean { function injectMappedHosts(): void { const host = getContextFrontendHost() || getContextRequestHost() + // @note an entry naming the exact host wins, so mappings may differ by + // port; otherwise selection is by hostname, as proxy host routing is, so a + // mapping may list its hosts without the port the deployment is reached on + const hostname = hostToHostname(host) + const mappings = Object.values(hostsConfig) + const mapping = host - ? Object.values(hostsConfig).find(({ match }) => match.includes(host)) + ? mappings.find(({ match }) => match.includes(host)) || + mappings.find(({ match }) => + match.some((candidate) => hostToHostname(candidate) === hostname) + ) : undefined setContextStaticHost(mapping?.static) diff --git a/platform/lib/context.setup.utest.js b/platform/lib/context.setup.utest.js index ed87553..45cb468 100644 --- a/platform/lib/context.setup.utest.js +++ b/platform/lib/context.setup.utest.js @@ -245,6 +245,83 @@ describe('context.setup', () => { ) }) + it('should select a mapping by hostname when the request host carries a port', async () => { + await withRuntime( + { vercel: false, hostsConfig }, + async ({ context, contextSetup }) => { + await context.executeInContext(async () => { + contextSetup.setupHeadersContext( + new Headers({ host: 'api.example.com:3000' }) + ) + + expect(context.getContextAPIHost()).toBe('api.example.com') + expect(context.getContextStaticHost()).toBe('static.example.com') + }) + } + ) + }) + + it('should prefer the mapping that names the exact host over a hostname match', async () => { + await withRuntime( + { + vercel: false, + hostsConfig: { + a: { + match: ['console.example:8080'], + site: 'console.example:8080', + api: 'console.example:8080', + static: 'static-a.example:8080', + widgets: 'widgets-a.example:8080', + }, + b: { + match: ['console.example:8443'], + site: 'console.example:8443', + api: 'console.example:8443', + static: 'static-b.example:8443', + widgets: 'widgets-b.example:8443', + }, + }, + }, + async ({ context, contextSetup }) => { + await context.executeInContext(async () => { + contextSetup.setupHeadersContext( + new Headers({ host: 'console.example:8443' }) + ) + + expect(context.getContextStaticHost()).toBe('static-b.example:8443') + }) + } + ) + }) + + it('should select a mapping whose match entries carry a port', async () => { + await withRuntime( + { + vercel: false, + hostsConfig: { + local: { + match: ['cbk.localhost:3000'], + site: 'cbk.localhost:3000', + api: 'cbk.localhost:3000', + static: 'cbk-static.localhost:3000', + widgets: 'cbk-widgets.localhost:3000', + }, + }, + }, + async ({ context, contextSetup }) => { + await context.executeInContext(async () => { + contextSetup.setupHeadersContext( + new Headers({ host: 'cbk.localhost' }) + ) + + expect(context.getContextStaticHost()).toBe( + 'cbk-static.localhost:3000' + ) + }) + } + ) + }) + it('should select hosts from the authenticated frontend assertion', async () => { await withRuntime( { vercel: false, hostsConfig }, diff --git a/platform/lib/host.browser.port80.utest.js b/platform/lib/host.browser.port80.utest.js new file mode 100644 index 0000000..9ffa8b3 --- /dev/null +++ b/platform/lib/host.browser.port80.utest.js @@ -0,0 +1,50 @@ +/** + * @jest-environment @chatbotkit-dev/jest-jsdom + * @jest-environment-options {"url": "http://api.brand.example/"} + */ + +// @note a mapped host may spell the default http port; the browser never +// does, so the comparison has to normalise under the page's scheme + +function loadHost() { + let host + + jest.isolateModules(() => { + host = jest.requireActual('@/lib/host') + }) + + return host +} + +describe('lib/host in the browser on a mapped host spelled with :80', () => { + const previous = process.env.SITE_URL + + beforeEach(() => { + delete process.env.SITE_URL + document.documentElement.dataset.siteUrl = 'http://console.example' + // @note the mapping serves the site on this host too, so the API lives + // under /api - the stamped runtime site host says so + document.documentElement.dataset.siteHost = 'api.brand.example:80' + }) + + afterAll(() => { + delete document.documentElement.dataset.siteHost + + if (previous === undefined) { + delete process.env.SITE_URL + } else { + process.env.SITE_URL = previous + } + }) + + it('keeps http and drops the default port for the page host', () => { + const host = loadHost() + + expect(host.getExternalFrontendHostURL('/hub/demo', 'api.brand.example:80')).toBe( + 'http://api.brand.example/hub/demo' + ) + expect(host.getExternalAPIHostURL('/v1/models', 'api.brand.example:80')).toBe( + 'http://api.brand.example/api/v1/models' + ) + }) +}) diff --git a/platform/lib/host.browser.scheme.utest.js b/platform/lib/host.browser.scheme.utest.js new file mode 100644 index 0000000..a9c1d55 --- /dev/null +++ b/platform/lib/host.browser.scheme.utest.js @@ -0,0 +1,45 @@ +/** + * @jest-environment @chatbotkit-dev/jest-jsdom + * @jest-environment-options {"url": "http://console.example:3000/"} + */ + +// @note the page is the site's hostname on another port and scheme; links to +// the page's own host must keep the scheme the page was reached on + +function loadHost() { + let host + + jest.isolateModules(() => { + host = jest.requireActual('@/lib/host') + }) + + return host +} + +describe('lib/host in the browser on an alternate port of the site', () => { + const previous = process.env.SITE_URL + + beforeEach(() => { + delete process.env.SITE_URL + document.documentElement.dataset.siteUrl = 'https://console.example' + }) + + afterAll(() => { + if (previous === undefined) { + delete process.env.SITE_URL + } else { + process.env.SITE_URL = previous + } + }) + + it('keeps http for the page host and https for the configured site', () => { + const host = loadHost() + + expect(host.getExternalFrontendHostURL('/tokens', 'console.example:3000')).toBe( + 'http://console.example:3000/tokens' + ) + expect(host.getExternalFrontendHostURL('/tokens', 'console.example')).toBe( + 'https://console.example/tokens' + ) + }) +}) diff --git a/platform/lib/host.browser.utest.js b/platform/lib/host.browser.utest.js new file mode 100644 index 0000000..45c3c17 --- /dev/null +++ b/platform/lib/host.browser.utest.js @@ -0,0 +1,87 @@ +/** + * @jest-environment @chatbotkit-dev/jest-jsdom + * @jest-environment-options {"url": "http://192.168.1.10:3000/"} + */ + +// @note the browser seeds config/site from the origins the server stamped on +// ; these cases cover what the page then builds for itself + +const ORIGIN_KEYS = ['SITE_URL', 'STATIC_URL', 'WIDGET_URL', 'API_URL'] + +function loadHost() { + let host + + jest.isolateModules(() => { + host = jest.requireActual('@/lib/host') + }) + + return host +} + +describe('lib/host in the browser', () => { + const previous = Object.fromEntries( + ORIGIN_KEYS.map((key) => [key, process.env[key]]) + ) + + beforeEach(() => { + for (const key of ORIGIN_KEYS) { + delete process.env[key] + } + + document.documentElement.dataset.siteUrl = 'http://localhost:3000' + delete document.documentElement.dataset.apiUrl + delete document.documentElement.dataset.siteHost + delete document.documentElement.dataset.apiHost + delete document.documentElement.dataset.apiCleanRoutes + }) + + afterAll(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + }) + + it('keeps the page scheme for an alternate address reached over http', () => { + const host = loadHost() + + expect(host.getExternalFrontendHostURL('/x', '192.168.1.10:3000')).toBe( + 'http://192.168.1.10:3000/x' + ) + }) + + it('follows the API route policy the server stamped for its API host', () => { + document.documentElement.dataset.siteUrl = 'https://console.example' + document.documentElement.dataset.apiUrl = 'https://api.example:8443' + document.documentElement.dataset.apiHost = 'api.example:8443' + // @note the mapping that makes api.example a site hostname lives on the + // server only; without the stamp the browser would emit a clean route + document.documentElement.dataset.apiCleanRoutes = '0' + + const host = loadHost() + + expect(host.getExternalAPIHostURL('/v1/models', 'api.example:8443')).toBe( + 'https://api.example:8443/api/v1/models' + ) + + document.documentElement.dataset.apiCleanRoutes = '1' + + expect(loadHost().getExternalAPIHostURL('/v1/models', 'api.example:8443')).toBe( + 'https://api.example:8443/v1/models' + ) + }) + + it('keeps the /api prefix on a mapped api.* origin shared with the site', () => { + document.documentElement.dataset.siteUrl = 'https://platform.example.com' + document.documentElement.dataset.siteHost = 'api.brand.example' + + const host = loadHost() + + expect(host.getExternalAPIHostURL('/v1/models', 'api.brand.example')).toBe( + 'https://api.brand.example/api/v1/models' + ) + }) +}) diff --git a/platform/lib/host.parse.ts b/platform/lib/host.parse.ts new file mode 100644 index 0000000..48cfbb7 --- /dev/null +++ b/platform/lib/host.parse.ts @@ -0,0 +1,56 @@ +// @note dependency-free on purpose: the proxy bundle and the client both use +// this module, and lib/url pulls in the domain parser + +/** + * A request host as the request context records it: trimmed, lower-cased, + * port kept unless it is the default https port, and rejected outright when + * it smuggles credentials, a path, a query or a fragment. + */ +export function normalizeRequestHost( + value: string | null | undefined +): string | null { + if (!value) { + return null + } + + try { + const url = new URL(`https://${value.trim()}`) + + if ( + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) { + return null + } + + // @note the URL parser tolerates characters that would end a Set-Cookie + // or a CSP directive early (`;`, `,`, `=`); a host never contains them + return /^(?:\[[0-9a-f:.]+\]|[a-z0-9-]+(?:\.[a-z0-9-]+)*\.?)(?::\d{1,5})?$/.test( + url.host + ) + ? url.host + : null + } catch { + return null + } +} + +/** + * The hostname of a host - a host carries the port, a hostname never does. + * Lower-cased, IPv6 brackets kept; an unparsable host keeps its port-less + * prefix. + */ +export function hostToHostname(host: string | null | undefined): string { + if (!host) { + return '' + } + + try { + return new URL(`http://${host}`).hostname + } catch { + return host.split(':')[0].toLowerCase() + } +} diff --git a/platform/lib/host.parse.utest.js b/platform/lib/host.parse.utest.js new file mode 100644 index 0000000..93e3b4a --- /dev/null +++ b/platform/lib/host.parse.utest.js @@ -0,0 +1,39 @@ +import { hostToHostname, normalizeRequestHost } from '@/lib/host.parse' + +describe('hostToHostname', () => { + it.each([ + ['cbk-labs.localhost:3000', 'cbk-labs.localhost'], + ['CBK-Labs.localhost', 'cbk-labs.localhost'], + ['labs.chatbotkit.com', 'labs.chatbotkit.com'], + ['127.0.0.1:3000', '127.0.0.1'], + ['[::1]:3000', '[::1]'], + ['', ''], + [null, ''], + [undefined, ''], + ])('reduces %s to %s', (host, hostname) => { + expect(hostToHostname(host)).toBe(hostname) + }) + + it('keeps the port-less prefix of a host the URL parser rejects', () => { + expect(hostToHostname('bad host:3000')).toBe('bad host') + }) +}) + +describe('normalizeRequestHost', () => { + it.each([ + [' CBK.localhost:3000 ', 'cbk.localhost:3000'], + ['example.com:443', 'example.com'], + ['example.com:80', 'example.com:80'], + ['user:pw@example.com', null], + ['example.com/path', null], + ['example.com?x=1', null], + ['example.com;domain=evil', null], + ['example.com,other', null], + ['[::1]:3000', '[::1]:3000'], + ['example.com.', 'example.com.'], + ['', null], + [null, null], + ])('normalises %s to %s', (value, expected) => { + expect(normalizeRequestHost(value)).toBe(expected) + }) +}) diff --git a/platform/lib/host.ts b/platform/lib/host.ts index a7375d7..6d65f48 100644 --- a/platform/lib/host.ts +++ b/platform/lib/host.ts @@ -1,11 +1,13 @@ +import { hosts } from '@/config/hosts' import { - apiHostname, + apiHost, apiUrl, + siteHost, siteHostname, siteUrl, - staticHostname, + staticHost, staticUrl, - widgetHostname, + widgetHost, widgetUrl, } from '@/config/site' @@ -18,6 +20,7 @@ import { getContextWidgetHost, } from '@/lib/context.store' import { isDevelopment, isTest } from '@/lib/env' +import { hostToHostname } from '@/lib/host.parse' import { isLocalhost } from '@/lib/localhost' import { z } from 'zod' @@ -51,12 +54,31 @@ const env = z }) /** - * Builds a URL on a host, picking the scheme the host actually answers on. - * Loopback and `*.localhost` hosts are plain http (the community stack has - * no TLS), the site host follows SITE_URL, the request host follows the - * request scheme. Anything else is https. + * The host a URL has under a given scheme - a default port for that scheme + * (`:80` for http, `:443` for https) is dropped, as the browser drops it. */ -function buildHostURL(path: string, base: string): string { +function hostUnder(url: URL, protocol: string): string { + const candidate = new URL(url) + + candidate.protocol = protocol + + return candidate.host +} + +type HostURLOptions = { + // @note hydration uses configured schemes until the client takes over + useRequestProtocol?: boolean +} + +/** + * Builds a URL using the configured scheme or the scheme of its request. + * Loopback and `*.localhost` hosts use plain HTTP. + */ +function buildHostURL( + path: string, + base: string, + { useRequestProtocol = true }: HostURLOptions = {} +): string { const url = new URL(path, base) if ( @@ -65,15 +87,54 @@ function buildHostURL(path: string, base: string): string { url.hostname.endsWith('.localhost') ) { url.protocol = 'http:' - } else if (url.hostname === siteHostname) { + } else if ( + useRequestProtocol && + typeof window !== 'undefined' && + hostUnder(url, window.location.protocol) === window.location.host + ) { + // @note in the browser the page itself is the request; its own host - + // an alternate port or address of the site, a LAN address - keeps the + // scheme it was reached on, ahead of what the site url would say + url.protocol = window.location.protocol + } else if (hostUnder(url, new URL(siteUrl).protocol) === siteHost) { url.protocol = new URL(siteUrl).protocol - } else if (url.host === getContextRequestHost()) { - url.protocol = `${getContextRequestProtocol() || 'https'}:` + } else { + const requestHost = useRequestProtocol ? getContextRequestHost() : undefined + const requestProtocol = requestHost + ? `${getContextRequestProtocol() || 'https'}:` + : undefined + + if ( + requestHost && + requestProtocol && + hostUnder(url, requestProtocol) === + new URL(`${requestProtocol}//${requestHost}`).host + ) { + // @note compare both hosts under the request scheme, which drops :80 on + // http even when only the mapping explicitly names that default port + url.protocol = requestProtocol + } else if (url.hostname === siteHostname) { + url.protocol = new URL(siteUrl).protocol + } } return url.toString() } +/** + * The frontend host the deployment resolved for this request: the request + * context on the server, the value it stamped on the document in the browser. + */ +function getResolvedFrontendHost(): string | undefined { + return ( + getContextFrontendHost() || + (typeof document !== 'undefined' + ? document.documentElement.dataset.siteHost + : undefined) || + getExternalFrontendHost() + ) +} + /** * Gets the local host based on the environment. When in development, it will * use the NGROK_HOST or LOCAL_HOST environment variables. Otherwise, it will @@ -172,35 +233,38 @@ export function getExternalFrontendHost(): string { */ export function getExternalFrontendHostURL( path: string = '/', - host: string = getExternalFrontendHost() + host: string = getExternalFrontendHost(), + options?: HostURLOptions ): string { - return buildHostURL(path, `https://${host}`) + return buildHostURL(path, `https://${host}`, options) } /** * Gets the deployment's externally reachable static host. */ export function getExternalStaticHost(): string { - return getContextStaticHost() || staticHostname + return getContextStaticHost() || staticHost } /** * Gets a URL on the deployment's externally reachable static host. */ -export function getExternalStaticHostURL(path: string = '/'): string { - const host = getExternalStaticHost() - - return new URL( - path, - host === staticHostname ? staticUrl : `https://${host}` - ).toString() +export function getExternalStaticHostURL( + path: string = '/', + host: string = getExternalStaticHost() +): string { + // @note a configured origin is used verbatim - its scheme is explicit; + // a mapped host has none, so it goes through the scheme inference + return host === staticHost + ? new URL(path, staticUrl).toString() + : buildHostURL(path, `https://${host}`) } /** * Gets the request-affine host for private MCP widget bundles. */ export function getExternalWidgetHost(): string { - return getContextWidgetHost() || widgetHostname + return getContextWidgetHost() || widgetHost } /** @@ -209,10 +273,9 @@ export function getExternalWidgetHost(): string { export function getExternalWidgetHostURL(path: string = '/'): string { const host = getExternalWidgetHost() - return new URL( - path, - host === widgetHostname ? widgetUrl : `https://${host}` - ).toString() + return host === widgetHost + ? new URL(path, widgetUrl).toString() + : buildHostURL(path, `https://${host}`) } /** @@ -264,24 +327,56 @@ export function getExternalAPIHost(host?: string): string { host = host ?? getExternalHost() - const siteHost = siteHostname.startsWith('api.') - ? siteHostname.slice(4) - : siteHostname.startsWith('next.') - ? siteHostname.slice(5) - : siteHostname - - const bareHost = host.startsWith('api.') - ? host.slice(4) - : host.startsWith('next.') - ? host.slice(5) + // @note site-family membership is a hostname question: the request may + // arrive on any port, the API is still the configured one + const stripFamilyPrefix = (hostname: string): string => + hostname.startsWith('api.') + ? hostname.slice(4) + : hostname.startsWith('next.') + ? hostname.slice(5) + : hostname + + return stripFamilyPrefix(hostToHostname(host)) === + stripFamilyPrefix(siteHostname) + ? apiHost : host +} - // @note keep hostname-only callers working while also matching the configured - // port carried by request hosts and background callbacks - return bareHost === siteHost || - (configuredSite.port && bareHost === `${siteHost}:${configuredSite.port}`) - ? apiHostname - : host +/** + * Whether an API host serves the API at its root (`/v1`) rather than under + * `/api`. api.* hosts do by convention, except when the API hostname is also + * a site hostname - configured (API_URL on SITE_URL's hostname), mapped (a + * HOSTS_CONFIG site target) or the resolved frontend host - in which case + * the API still lives under /api. The proxy makes the same call by hostname, + * ports aside, so the two must agree. + * + * @note the mapping table is server-only, so the server stamps its decision + * for the resolved API host on the document and the browser reads it back + */ +export function servesCleanAPIRoutes(host: string): boolean { + if (typeof document !== 'undefined') { + const { apiHost: stampedHost, apiCleanRoutes } = + document.documentElement.dataset + + if ( + stampedHost === host && + (apiCleanRoutes === '1' || apiCleanRoutes === '0') + ) { + return apiCleanRoutes === '1' + } + } + + const hostname = hostToHostname(host) + + const isSiteHostname = + hostname === siteHostname || + hosts.site.some((site) => hostToHostname(site) === hostname) || + hostname === hostToHostname(getResolvedFrontendHost()) + + // @note api.* is the spelling convention for clean routes; the browser + // knows the configured hosts from the document and no mappings, which is + // why the server stamps its decision for the resolved host + return host.startsWith('api.') && !isSiteHostname } /** @@ -291,10 +386,11 @@ export function getExternalAPIHost(host?: string): string { */ export function getExternalAPIHostURL( path: string = '/', - host: string = getExternalAPIHost() + host: string = getExternalAPIHost(), + options?: HostURLOptions ): string { if ( - !host.startsWith('api.') && + !servesCleanAPIRoutes(host) && !path.startsWith('/api/') && !path.startsWith('/.well-known') && !path.startsWith('/oauth') @@ -302,5 +398,15 @@ export function getExternalAPIHostURL( path = `/api${path.startsWith('/') ? '' : '/'}${path}` } - return buildHostURL(path, host === apiHostname ? apiUrl : `https://${host}`) + // @note a separate API origin has an explicit scheme, like static and + // widget origins; the site fallback still follows its request after hydration + if (host === apiHost && apiUrl !== siteUrl) { + return new URL(path, apiUrl).toString() + } + + return buildHostURL( + path, + host === apiHost ? apiUrl : `https://${host}`, + options + ) } diff --git a/platform/lib/host.utest.js b/platform/lib/host.utest.js index d728500..8e0068e 100644 --- a/platform/lib/host.utest.js +++ b/platform/lib/host.utest.js @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-require-imports */ -import { siteHostname, siteUrl } from '@/config/site' +import { siteUrl } from '@/config/site' const externalAPIHostCases = [ { @@ -134,6 +134,7 @@ const externalAPIHostURLCases = [ ] const ENV_KEYS = [ + 'HOSTS_CONFIG', 'NODE_ENV', 'TARGET_ENV', 'VERCEL_ENV', @@ -176,6 +177,7 @@ function loadHostScenario({ testStaticUrl, testWidgetUrl, integrationTestBaseUrl, + hostsConfig, } = {}) { const previousEnv = Object.fromEntries( ENV_KEYS.map((key) => [key, process.env[key]]) @@ -197,6 +199,7 @@ function loadHostScenario({ setEnv('STATIC_URL', testStaticUrl) setEnv('WIDGET_URL', testWidgetUrl) setEnv('_ITEST_CHATBOTKIT_BASE_URL', integrationTestBaseUrl) + setEnv('HOSTS_CONFIG', hostsConfig ? JSON.stringify(hostsConfig) : undefined) jest.resetModules() @@ -222,6 +225,42 @@ function loadHostScenario({ } describe('host selection', () => { + it.each(['api.brand.example:8080', 'other.brand.example'])( + 'does not infer HTTP from a different request host %s', + (requestHost) => { + const host = loadHostScenario({ + testSiteUrl: 'http://console.example', + requestHost, + requestProtocol: 'http', + frontendHost: 'api.brand.example:80', + }) + + expect(host.getExternalFrontendHostURL('/hub/demo')).toBe( + 'https://api.brand.example:80/hub/demo' + ) + } + ) + + it.each(['api.brand.example', 'api.brand.example:80'])( + 'normalizes the default HTTP port against request host %s', + (requestHost) => { + const host = loadHostScenario({ + testSiteUrl: 'http://console.example', + requestHost, + requestProtocol: 'http', + frontendHost: 'api.brand.example:80', + contextAPIHost: 'api.brand.example:80', + }) + + expect(host.getExternalFrontendHostURL('/hub/demo')).toBe( + 'http://api.brand.example/hub/demo' + ) + expect(host.getExternalAPIHostURL('/v1/models')).toBe( + 'http://api.brand.example/api/v1/models' + ) + } + ) + it.each([ 'platform.example.com', 'api.platform.example.com', @@ -233,7 +272,7 @@ describe('host selection', () => { }) expect(host.getExternalAPIHost(explicitHost)).toBe( - 'api.platform.example.com' + 'api.platform.example.com:9443' ) expect( host.getExternalAPIHostURL( @@ -511,6 +550,80 @@ describe('external static host', () => { }) }) +describe('a site host spelled with its default port', () => { + it('resolves to the site scheme with the port dropped', () => { + const host = loadHostScenario({ testSiteUrl: 'http://console.example' }) + + expect(host.getExternalFrontendHostURL('/x', 'console.example:80')).toBe( + 'http://console.example/x' + ) + }) +}) + +describe('configured static and widget origins keep their scheme', () => { + it.each([ + ['http://platform.example:3000', 'https://platform.example:9443'], + ['https://platform.example:8443', 'http://platform.example:3000'], + ['http://platform.example:3000', 'https://localhost:9443'], + ['http://platform.example:3000', 'https://[::1]:9443'], + ['http://platform.example:3000', 'https://api.platform.example:9443'], + ['http://platform.example:3000', 'https://platform.example'], + ['https://platform.example:8443', 'http://platform.example'], + ['http://platform.example:3000', 'https://127.0.0.1:9443'], + ['http://platform.example:3000', 'https://api.localhost:9443'], + ])('honors API_URL %s -> %s just like asset origins', (testSiteUrl, origin) => { + const host = loadHostScenario({ + testSiteUrl, + apiUrl: origin, + testStaticUrl: origin, + testWidgetUrl: origin, + }) + const api = new URL(origin) + const apiPath = api.hostname.startsWith('api.') + ? '/v1/models' + : '/api/v1/models' + + expect(host.getExternalStaticHostURL('/asset.js')).toBe( + `${origin}/asset.js` + ) + expect(host.getExternalWidgetHostURL('/bundle.js')).toBe( + `${origin}/bundle.js` + ) + expect(host.getExternalAPIHostURL('/v1/models', api.host)).toBe( + `${origin}${apiPath}` + ) + }) + + it('does not infer the site scheme onto an explicit STATIC_URL', () => { + const host = loadHostScenario({ + testSiteUrl: 'http://platform.example:3000', + testStaticUrl: 'https://platform.example:9443', + }) + + expect(host.getExternalStaticHostURL('/asset.js')).toBe( + 'https://platform.example:9443/asset.js' + ) + }) +}) + +describe('external static and widget URLs on plain http', () => { + it('keeps a mapped localhost target on http, port included', () => { + const host = loadHostScenario({ + testSiteUrl: 'http://cbk.localhost:3000', + testStaticUrl: '', + contextStaticHost: 'cbk-static.localhost:3000', + contextWidgetHost: 'cbk-widgets.localhost:3000', + }) + + expect(host.getExternalStaticHostURL('/asset.js')).toBe( + 'http://cbk-static.localhost:3000/asset.js' + ) + expect(host.getExternalWidgetHostURL('/bundle.js')).toBe( + 'http://cbk-widgets.localhost:3000/bundle.js' + ) + }) +}) + describe('injected host context', () => { const primaryHosts = { site: 'console.example.com', @@ -627,7 +740,8 @@ describe('getExternalAPIHost', () => { it('serves the API on the site host by default', () => { const host = loadHostScenario() - expect(host.getExternalAPIHost()).toBe(siteHostname) + // @note a host - the site port comes along with it + expect(host.getExternalAPIHost()).toBe(new URL(siteUrl).host) }) it('resolves chatbotkit.com to api.chatbotkit.com in production', () => { @@ -695,6 +809,109 @@ describe('getExternalAPIHost', () => { ) }) + it('keeps the /api prefix on a mapped api.* origin shared with the site', () => { + const host = loadHostScenario({ + nodeEnv: 'production', + targetEnv: 'production', + testSiteUrl: 'https://platform.example.com', + apiUrl: 'https://platform.example.com', + frontendHost: 'api.brand.example', + contextAPIHost: 'api.brand.example', + }) + + expect(host.getExternalAPIHostURL('/v1/models')).toBe( + 'https://api.brand.example/api/v1/models' + ) + }) + + it('keeps the /api prefix when mapped site and api targets share a hostname on different ports', () => { + const mapping = { + brand: { + match: ['api.example:3000', 'api.example:8443'], + site: 'api.example:3000', + api: 'api.example:8443', + static: 'static.example', + widgets: 'widgets.example', + }, + } + + // @note with a request: the mapping resolved both hosts + const withRequest = loadHostScenario({ + nodeEnv: 'production', + targetEnv: 'production', + testSiteUrl: 'https://console.example', + apiUrl: 'https://api.example:8443', + frontendHost: 'api.example:3000', + contextAPIHost: 'api.example:8443', + hostsConfig: mapping, + }) + + expect(withRequest.getExternalAPIHostURL('/v1/models')).toBe( + 'https://api.example:8443/api/v1/models' + ) + + // @note without one: the configured API host is still a mapped site + // hostname, which the proxy never classifies as an API host + const background = loadHostScenario({ + nodeEnv: 'production', + targetEnv: 'production', + testSiteUrl: 'https://console.example', + apiUrl: 'https://api.example:8443', + hostsConfig: mapping, + }) + + expect(background.getExternalAPIHostURL('/v1/models')).toBe( + 'https://api.example:8443/api/v1/models' + ) + }) + + it('keeps the /api prefix when a mapping shares the configured API host with its site', () => { + const host = loadHostScenario({ + nodeEnv: 'production', + targetEnv: 'production', + testSiteUrl: 'https://console.example', + apiUrl: 'https://api.example', + frontendHost: 'api.example', + contextAPIHost: 'api.example', + }) + + expect(host.getExternalAPIHostURL('/v1/models')).toBe( + 'https://api.example/api/v1/models' + ) + }) + + it('keeps the /api prefix on a shared site that is named api.*', () => { + const host = loadHostScenario({ + nodeEnv: 'production', + targetEnv: 'production', + testSiteUrl: 'https://api.example.com:8443', + apiUrl: '', + }) + + expect(host.getExternalAPIHostURL('/v1/models')).toBe( + 'https://api.example.com:8443/api/v1/models' + ) + }) + + it('recognises the site family on any port', () => { + const host = loadHostScenario({ + nodeEnv: 'production', + targetEnv: 'production', + testSiteUrl: 'http://cbk.localhost:3000', + apiUrl: 'http://cbk.localhost:3000', + }) + + expect(host.getExternalAPIHost('cbk.localhost:8080')).toBe( + 'cbk.localhost:3000' + ) + expect(host.getExternalAPIHost('api.cbk.localhost')).toBe( + 'cbk.localhost:3000' + ) + expect(host.getExternalAPIHost('other.localhost:3000')).toBe( + 'other.localhost:3000' + ) + }) + it('passes foreign hosts through untouched with API_URL set', () => { const host = loadHostScenario({ nodeEnv: 'production', diff --git a/platform/lib/mcp.widget.ts b/platform/lib/mcp.widget.ts index 9fcf220..623f76f 100644 --- a/platform/lib/mcp.widget.ts +++ b/platform/lib/mcp.widget.ts @@ -3,6 +3,7 @@ import { siteHostname, staticHostname, widgetHostname } from '@/config/site' import debug from '@/lib/debug' import standardFetch from '@/lib/egress.fetch' import { withNextCache } from '@/lib/fetch' +import { hostToHostname } from '@/lib/host.parse' import { getExternalFrontendHost, getExternalStaticHost, @@ -42,12 +43,16 @@ const ALLOWED_WIDGET_DOMAINS = [ * module load. */ export function getAllowedWidgetDomains(): Set { + // @note the runtime seams return hosts; the allowlist is matched against + // URL hostnames, so the port is dropped here return new Set([ ...ALLOWED_WIDGET_DOMAINS, - getExternalFrontendHost(), - getExternalStaticHost(), - getExternalWidgetHost(), + ...[ + getExternalFrontendHost(), + getExternalStaticHost(), + getExternalWidgetHost(), + ].map(hostToHostname), ]) } diff --git a/platform/lib/mcp.widget.utest.js b/platform/lib/mcp.widget.utest.js index 3707935..ebdf1aa 100644 --- a/platform/lib/mcp.widget.utest.js +++ b/platform/lib/mcp.widget.utest.js @@ -79,6 +79,17 @@ describe('mcp.widget', () => { ) }) + it('drops the port from mapped runtime hosts', () => { + const { getExternalWidgetHost } = require('@/lib/host') + + getExternalWidgetHost.mockReturnValueOnce('runtime-widgets.example:8443') + + expect(getAllowedWidgetDomains()).toContain('runtime-widgets.example') + expect(getAllowedWidgetDomains()).not.toContain( + 'runtime-widgets.example:8443' + ) + }) + it('does not duplicate a runtime domain already in the baseline', () => { const { getExternalStaticHost } = require('@/lib/host') diff --git a/platform/lib/partner.auth.ts b/platform/lib/partner.auth.ts index bec50ee..e7971d7 100644 --- a/platform/lib/partner.auth.ts +++ b/platform/lib/partner.auth.ts @@ -28,16 +28,16 @@ const EmailProvider: typeof EmailProviderType = .default ?? (_EmailProvider as unknown as typeof EmailProviderType) export async function getPartnerAuthInitialAdapter( - host: string + hostname: string ): Promise { - debug(`getPartnerAuthInitialAdapter`, { host }).log( + debug(`getPartnerAuthInitialAdapter`, { hostname }).log( 'partner.auth.getPartnerAuthInitialAdapter' ) - const slug = getPartnerSlugFromHostname(host) + const slug = getPartnerSlugFromHostname(hostname) if (!slug) { - debug(`partner not found`, { host }).log( + debug(`partner not found`, { hostname }).log( 'partner.auth.getPartnerAuthInitialAdapter' ) @@ -47,7 +47,7 @@ export async function getPartnerAuthInitialAdapter( // @note validate slug doesn't contain unexpected characters or data if (!/^[a-zA-Z0-9-]+$/.test(slug)) { - debug(`invalid slug format`, { host, slug }).log( + debug(`invalid slug format`, { hostname, slug }).log( 'portal.auth.getPortalAuthInitialAdapter' ) @@ -267,16 +267,16 @@ export async function getPartnerAuthInitialAdapter( } export async function getPartnerAuthProviders( - host: string + hostname: string ): Promise { - debug(`getPartnerAuthProviders`, { host }).log( + debug(`getPartnerAuthProviders`, { hostname }).log( 'partner.auth.getPartnerAuthProviders' ) - const slug = getPartnerSlugFromHostname(host) + const slug = getPartnerSlugFromHostname(hostname) if (!slug) { - debug(`partner not found`, { host }).log( + debug(`partner not found`, { hostname }).log( 'partner.auth.getPartnerAuthProviders' ) @@ -395,9 +395,9 @@ export async function getPartnerAuthProviders( } export async function getPartnerAuthInitialCallbacks( - host: string + hostname: string ): Promise { - debug(`getPartnerAuthInitialCallbacks`, { host }).log( + debug(`getPartnerAuthInitialCallbacks`, { hostname }).log( 'partner.auth.getPartnerAuthInitialCallbacks' ) diff --git a/platform/lib/partner.helpers.ts b/platform/lib/partner.helpers.ts index 25afd72..94a43f5 100644 --- a/platform/lib/partner.helpers.ts +++ b/platform/lib/partner.helpers.ts @@ -53,7 +53,7 @@ export async function getPartnerByIdentifier( export async function getPartnerByHostname( hostname: string ): Promise { - const slug = getPartnerSlugFromHostname((hostname || '').split(':')[0]) + const slug = getPartnerSlugFromHostname(hostname || '') if (!slug) { return null @@ -96,6 +96,6 @@ export function getPartnerSlugFromHostname(hostname: string): string | null { return null } -export function isPartnerHost(host: string): boolean { - return getPartnerSlugFromHostname(host) !== null +export function isPartnerHostname(hostname: string): boolean { + return getPartnerSlugFromHostname(hostname) !== null } diff --git a/platform/lib/partner.helpers.utest.js b/platform/lib/partner.helpers.utest.js index 92c6960..6cb95e7 100644 --- a/platform/lib/partner.helpers.utest.js +++ b/platform/lib/partner.helpers.utest.js @@ -4,11 +4,12 @@ jest.mock('@/config/apexes', () => ({ partnersApex: 'chatbotkit.partners', })) +import { hostToHostname } from '@/lib/host.parse' import { getPartnerByHostname, getPartnerByIdentifier, getPartnerSlugFromHostname, - isPartnerHost, + isPartnerHostname, } from '@/lib/partner.helpers' jest.mock('@chatbotkit-dev/partners', () => ({ @@ -284,67 +285,67 @@ describe('partner helper functions', () => { }) }) - describe('isPartnerHost', () => { + describe('isPartnerHostname', () => { describe('valid partner hosts', () => { it('should return true for valid partner hostname', () => { - expect(isPartnerHost('aperture.chatbotkit.partners')).toBe(true) + expect(isPartnerHostname('aperture.chatbotkit.partners')).toBe(true) }) it('should return true for different partner hostname', () => { - expect(isPartnerHost('faro.chatbotkit.partners')).toBe(true) + expect(isPartnerHostname('faro.chatbotkit.partners')).toBe(true) }) it('should return true for test partner hostname', () => { - expect(isPartnerHost('testpartner.chatbotkit.partners')).toBe(true) + expect(isPartnerHostname('testpartner.chatbotkit.partners')).toBe(true) }) it('should return true for custom partner domain', () => { - expect(isPartnerHost('backend.acme.dev')).toBe(true) + expect(isPartnerHostname('backend.acme.dev')).toBe(true) }) }) describe('invalid hosts', () => { it('should return false for non-partner hostname', () => { - expect(isPartnerHost('example.com')).toBe(false) + expect(isPartnerHostname('example.com')).toBe(false) }) it('should return false for chatbotkit.com', () => { - expect(isPartnerHost('www.chatbotkit.com')).toBe(false) + expect(isPartnerHostname('www.chatbotkit.com')).toBe(false) }) it('should return false for empty string', () => { - expect(isPartnerHost('')).toBe(false) + expect(isPartnerHostname('')).toBe(false) }) it('should return false for non-existent partner', () => { - expect(isPartnerHost('nonexistent.chatbotkit.partners')).toBe(false) + expect(isPartnerHostname('nonexistent.chatbotkit.partners')).toBe(false) }) it('should return false for hostname with wrong TLD', () => { - expect(isPartnerHost('aperture.chatbotkit.com')).toBe(false) + expect(isPartnerHostname('aperture.chatbotkit.com')).toBe(false) }) it('should return false for malformed hostname', () => { - expect(isPartnerHost('not-a-hostname')).toBe(false) + expect(isPartnerHostname('not-a-hostname')).toBe(false) }) }) describe('edge cases', () => { it('should return false for hostname with multiple subdomains', () => { - expect(isPartnerHost('sub.aperture.chatbotkit.partners')).toBe(false) + expect(isPartnerHostname('sub.aperture.chatbotkit.partners')).toBe(false) }) it('should return false for hostname with port', () => { - expect(isPartnerHost('aperture.chatbotkit.partners:8080')).toBe(false) + expect(isPartnerHostname('aperture.chatbotkit.partners:8080')).toBe(false) }) it('should return false for uppercase hostname', () => { - expect(isPartnerHost('APERTURE.chatbotkit.partners')).toBe(false) + expect(isPartnerHostname('APERTURE.chatbotkit.partners')).toBe(false) }) it('should throw for null-ish values', () => { - expect(() => isPartnerHost(null)).toThrow() - expect(() => isPartnerHost(undefined)).toThrow() + expect(() => isPartnerHostname(null)).toThrow() + expect(() => isPartnerHostname(undefined)).toThrow() }) }) @@ -352,7 +353,7 @@ describe('partner helper functions', () => { it('should return same result as checking slug !== null', () => { const hostname = 'aperture.chatbotkit.partners' const hasSlug = getPartnerSlugFromHostname(hostname) !== null - const isPartner = isPartnerHost(hostname) + const isPartner = isPartnerHostname(hostname) expect(isPartner).toBe(hasSlug) }) @@ -360,7 +361,7 @@ describe('partner helper functions', () => { it('should be consistent for invalid hostname', () => { const hostname = 'invalid.example.com' const hasSlug = getPartnerSlugFromHostname(hostname) !== null - const isPartner = isPartnerHost(hostname) + const isPartner = isPartnerHostname(hostname) expect(isPartner).toBe(hasSlug) expect(isPartner).toBe(false) @@ -369,7 +370,7 @@ describe('partner helper functions', () => { it('should be consistent for non-existent partner', () => { const hostname = 'nonexistent.chatbotkit.partners' const hasSlug = getPartnerSlugFromHostname(hostname) !== null - const isPartner = isPartnerHost(hostname) + const isPartner = isPartnerHostname(hostname) expect(isPartner).toBe(hasSlug) expect(isPartner).toBe(false) @@ -400,8 +401,13 @@ describe('partner helper functions', () => { }) }) - it('should handle hostname with port', async () => { - const partner = await getPartnerByHostname('backend.acme.dev:443') + it('takes a hostname - callers reduce a host first', async () => { + // @note a host is not a hostname; passed as-is nothing matches + expect(await getPartnerByHostname('backend.acme.dev:443')).toBeNull() + + const partner = await getPartnerByHostname( + hostToHostname('backend.acme.dev:443') + ) expect(partner).toEqual({ id: 'cm4ts8opg1i9euawcdv8ewj70', diff --git a/platform/lib/portal.auth.ts b/platform/lib/portal.auth.ts index 2d53c86..32ba6bd 100644 --- a/platform/lib/portal.auth.ts +++ b/platform/lib/portal.auth.ts @@ -23,6 +23,7 @@ import { getRootDomain } from '@/lib/domain' import { captureError } from '@/lib/error' import { logAudit } from '@/lib/log' import memcache from '@/lib/memcache' +import { hostToHostname } from '@/lib/host.parse' import { notifyEmailLogin } from '@/lib/notify' import { getPortalGlobalConfig } from '@/lib/portal.config' import { @@ -55,16 +56,16 @@ function userInPortalConfigs( } export async function getPortalAuthInitialAdapter( - host: string + hostname: string ): Promise { - debug(`getPortalAuthInitialAdapter`, { host }).log( + debug(`getPortalAuthInitialAdapter`, { hostname }).log( 'portal.auth.getPortalAuthInitialAdapter' ) - const slug = getPortalSlugFromHostname(host) + const slug = getPortalSlugFromHostname(hostname) if (!slug) { - debug(`portal not found`, { host }).log( + debug(`portal not found`, { hostname }).log( 'portal.auth.getPortalAuthInitialAdapter' ) @@ -74,7 +75,7 @@ export async function getPortalAuthInitialAdapter( // @note validate slug doesn't contain unexpected characters or data if (!/^[a-zA-Z0-9-]+$/.test(slug)) { - debug(`invalid slug format`, { host, slug }).log( + debug(`invalid slug format`, { hostname, slug }).log( 'portal.auth.getPortalAuthInitialAdapter' ) @@ -92,7 +93,7 @@ export async function getPortalAuthInitialAdapter( } catch (error) { // @note catch Prisma errors and log for debugging - debug(`prisma error finding portal`, { host, slug, error }).log( + debug(`prisma error finding portal`, { hostname, slug, error }).log( 'portal.auth.getPortalAuthInitialAdapter' ) @@ -102,7 +103,7 @@ export async function getPortalAuthInitialAdapter( } if (!portal) { - debug(`portal not found`, { host }).log( + debug(`portal not found`, { hostname }).log( 'portal.auth.getPortalAuthInitialAdapter' ) @@ -370,16 +371,16 @@ export async function getPortalAuthInitialAdapter( } export async function getPortalAuthProviders( - host: string + hostname: string ): Promise { - debug(`getPortalAuthProviders`, { host }).log( + debug(`getPortalAuthProviders`, { hostname }).log( 'portal.auth.getPortalAuthProviders' ) - const slug = getPortalSlugFromHostname(host) + const slug = getPortalSlugFromHostname(hostname) if (!slug) { - debug(`portal not found`, { host }).log( + debug(`portal not found`, { hostname }).log( 'portal.auth.getPortalAuthProviders' ) @@ -388,7 +389,7 @@ export async function getPortalAuthProviders( // @note validate slug doesn't contain unexpected characters or data if (!/^[a-zA-Z0-9-]+$/.test(slug)) { - debug(`invalid slug format`, { host, slug }).log( + debug(`invalid slug format`, { hostname, slug }).log( 'portal.auth.getPortalAuthProviders' ) @@ -405,7 +406,7 @@ export async function getPortalAuthProviders( }) } catch (error) { // @note catch Prisma errors and log for debugging - debug(`prisma error finding portal`, { host, slug, error }).log( + debug(`prisma error finding portal`, { hostname, slug, error }).log( 'portal.auth.getPortalAuthProviders' ) @@ -415,7 +416,7 @@ export async function getPortalAuthProviders( } if (!portal) { - debug(`portal not found`, { host }).log( + debug(`portal not found`, { hostname }).log( 'portal.auth.getPortalAuthProviders' ) @@ -481,7 +482,11 @@ export async function getPortalAuthProviders( if (process.env.SKIP_VERIFICATION_REQUEST) { log(`skipping verification request`) } else { - const effectiveHost = getRootDomain(getContextFrontendHost() || host) + // @note the frontend host may carry a port; the domain parser wants + // a hostname + const effectiveHost = getRootDomain( + hostToHostname(getContextFrontendHost()) || hostname + ) const sidebar = typeof portalConfig?.layout?.sidebar === 'object' @@ -555,9 +560,9 @@ export async function getPortalAuthProviders( } export async function getPortalAuthInitialCallbacks( - host: string + hostname: string ): Promise { - debug(`getPortalAuthInitialCallbacks`, { host }).log( + debug(`getPortalAuthInitialCallbacks`, { hostname }).log( 'portal.auth.getPortalAuthInitialCallbacks' ) diff --git a/platform/lib/portal.auth.utest.js b/platform/lib/portal.auth.utest.js index 2294f52..212bcf3 100644 --- a/platform/lib/portal.auth.utest.js +++ b/platform/lib/portal.auth.utest.js @@ -764,3 +764,46 @@ describe('getPortalAuthInitialCallbacks', () => { expect(result).toBe(true) }) }) + +describe('sendVerificationRequest on a frontend host that carries a port', () => { + it('derives the mail domain from the hostname', async () => { + jest.clearAllMocks() + + getPortalSlugFromHostname.mockReturnValue('test-portal') + prisma.portal.findUnique.mockResolvedValue({ + id: 'portal123', + userId: 'user123', + slug: 'test-portal', + name: 'Test Portal', + config: {}, + }) + prisma.user.findUnique.mockResolvedValue({ + id: 'user123', + email: 'owner@example.com', + }) + userInConfig.mockReturnValue(true) + notifyEmailLogin.mockResolvedValue() + isPortalHostname.mockReturnValue(true) + + const providers = await getPortalAuthProviders('test-portal.chatbotkit.agency') + + getContextFrontendHost.mockReturnValue('custom.example.com:3000') + getRootDomain.mockReturnValue('example.com') + isPortalRootHostname.mockReturnValue(false) + isPortalHostname.mockReturnValue(false) + createEmailTransport.mockReturnValue({ send: jest.fn() }) + + await providers[0].options.sendVerificationRequest({ + identifier: 'user@example.com', + url: 'https://test-portal.chatbotkit.agency/api/auth/callback', + token: 'abc123', + }) + + // @note the domain parser wants a hostname; the port would make it + // unparsable + expect(getRootDomain).toHaveBeenCalledWith('custom.example.com') + expect(createEmailTransport).toHaveBeenCalledWith( + 'notifications@example.com' + ) + }) +}) diff --git a/platform/lib/portal.hostname.ts b/platform/lib/portal.hostname.ts index 236e96c..853993c 100644 --- a/platform/lib/portal.hostname.ts +++ b/platform/lib/portal.hostname.ts @@ -1,9 +1,6 @@ import { portalApex } from '@/config/apexes' -import { - getContextFrontendHost, - getContextRequestHost, -} from '@/lib/context.store' +import { getContextRequestHost } from '@/lib/context.store' // @note without a configured portal apex no hostname is a portal hostname @@ -43,21 +40,12 @@ export function getPortalSlugFromHostname(hostname: string): string | null { } export function getPortalSlug(): string | null { - const hostname = getContextRequestHost() + const host = getContextRequestHost() - if (!hostname) { - return null - } - - return getPortalSlugFromHostname(hostname) -} - -export function getPortalURL(path?: string): URL | null { - const hostname = getContextFrontendHost() || getContextRequestHost() - - if (!hostname) { + if (!host) { return null } - return new URL(path || '', `https://${hostname}`) + // @note the slug helper reduces the host itself + return getPortalSlugFromHostname(host) } diff --git a/platform/lib/portal.hostname.utest.js b/platform/lib/portal.hostname.utest.js index bc87112..75600a5 100644 --- a/platform/lib/portal.hostname.utest.js +++ b/platform/lib/portal.hostname.utest.js @@ -2,7 +2,6 @@ import { getPortalSlug, getPortalSlugFromHostname, - getPortalURL, isPortalHostname, } from './portal.hostname' @@ -233,54 +232,4 @@ describe('portal.hostname', () => { }) }) - describe('getPortalURL', () => { - it('should construct URL from request hostname with path', () => { - getContextRequestHost.mockReturnValue('myportal.chatbotkit.agency') - - const url = getPortalURL('/some/path') - - expect(url).toBeInstanceOf(URL) - expect(url.href).toBe('https://myportal.chatbotkit.agency/some/path') - }) - - it('should construct URL from request hostname without path', () => { - getContextRequestHost.mockReturnValue('test.chatbotkit.agency') - - const url = getPortalURL() - - expect(url).toBeInstanceOf(URL) - expect(url.href).toBe('https://test.chatbotkit.agency/') - }) - - it('should prefer frontend hostname over request hostname', () => { - getContextFrontendHost.mockReturnValue('frontend.chatbotkit.agency') - getContextRequestHost.mockReturnValue('backend.chatbotkit.agency') - - const url = getPortalURL('/path') - - expect(url.href).toBe('https://frontend.chatbotkit.agency/path') - }) - - it('should return null when no hostname', () => { - getContextRequestHost.mockReturnValue(null) - - expect(getPortalURL()).toBeNull() - }) - - it('should handle paths with query strings', () => { - getContextRequestHost.mockReturnValue('portal.chatbotkit.agency') - - const url = getPortalURL('/path?query=value') - - expect(url.href).toBe('https://portal.chatbotkit.agency/path?query=value') - }) - - it('should handle empty path parameter', () => { - getContextRequestHost.mockReturnValue('portal.chatbotkit.agency') - - const url = getPortalURL('') - - expect(url.href).toBe('https://portal.chatbotkit.agency/') - }) - }) }) diff --git a/platform/lib/portal.origin.utest.js b/platform/lib/portal.origin.utest.js new file mode 100644 index 0000000..769dafc --- /dev/null +++ b/platform/lib/portal.origin.utest.js @@ -0,0 +1,30 @@ +import { getPortalFrontendHost, getPortalFrontendURL } from '@/lib/portal.slug' +import { getPortalGlobalConfig } from '@/lib/portal.config' + +jest.mock('@/config/apexes', () => ({ portalApex: 'portal.example' })) +jest.mock('@/config/site', () => ({ + siteUrl: 'http://console.example:3000', + siteHost: 'console.example:3000', + siteHostname: 'console.example', +})) +jest.mock('@/lib/portal.config', () => ({ + getPortalGlobalConfig: jest.fn(async () => null), +})) + +describe('portal origins', () => { + it('inherits the site scheme and port for deployment-issued portals', async () => { + const portal = { slug: 'acme', userId: 'owner' } + + await expect(getPortalFrontendHost(portal)).resolves.toBe('acme.portal.example:3000') + await expect(getPortalFrontendURL(portal)).resolves.toBe('http://acme.portal.example:3000') + }) + + it('does not copy the site port onto a partner custom domain', async () => { + getPortalGlobalConfig.mockResolvedValue({ domain: 'customer.example' }) + + const portal = { slug: 'acme-customer-example', userId: 'owner' } + + await expect(getPortalFrontendHost(portal)).resolves.toBe('acme.customer.example') + await expect(getPortalFrontendURL(portal)).resolves.toBe('https://acme.customer.example') + }) +}) diff --git a/platform/lib/portal.slug.ts b/platform/lib/portal.slug.ts index 54c50f5..34f3881 100644 --- a/platform/lib/portal.slug.ts +++ b/platform/lib/portal.slug.ts @@ -1,15 +1,18 @@ import { portalApex } from '@/config/apexes' -import { siteHostname } from '@/config/site' +import { siteUrl } from '@/config/site' import { getPortalGlobalConfig } from '@/lib/portal.config' +import { getExternalFrontendHostURL } from '@/lib/host' -/** - * Get frontend host from portal object - */ -export async function getPortalFrontendHost(portal: { +type PortalIdentity = { slug: string userId: string -}): Promise { +} + +/** + * Gets the portal origin, including the deployment's scheme and port. + */ +export async function getPortalFrontendURL(portal: PortalIdentity): Promise { // @note a partner portal configuration may name a custom domain; resolving // it through the portal owner prevents an unrelated account from claiming // the mapping by choosing a matching slug @@ -25,12 +28,26 @@ export async function getPortalFrontendHost(portal: { ? portal.slug.slice(0, -suffix.length) : portal.slug - return prefix ? `${prefix}.${domain}` : domain + const host = prefix ? `${prefix}.${domain}` : domain + + return new URL(getExternalFrontendHostURL('/', host)).origin } // @note the default pattern uses the deployment's portal apex, falling back // to the site host itself when no apex is configured - a controlled name is // better than minting one the operator does not own - return `${portal.slug}.${portalApex ?? siteHostname}` + const site = new URL(siteUrl) + const host = portalApex + ? `${portalApex}${site.port ? `:${site.port}` : ''}` + : site.host + + return new URL(`${site.protocol}//${portal.slug}.${host}`).origin +} + +/** + * Gets the portal host for request context and hostname lookups. + */ +export async function getPortalFrontendHost(portal: PortalIdentity): Promise { + return new URL(await getPortalFrontendURL(portal)).host } diff --git a/platform/lib/portal.slug.utest.js b/platform/lib/portal.slug.utest.js index af02202..a6d8166 100644 --- a/platform/lib/portal.slug.utest.js +++ b/platform/lib/portal.slug.utest.js @@ -1,4 +1,25 @@ // @note the suite pins the portal apex independently of deployment data +// @note a portless production-style site, so the derived portal hosts do not +// depend on whatever SITE_URL the shell exports +jest.mock('@/config/site', () => { + const siteUrl = 'https://chatbotkit.com' + + return { + siteUrl, + siteHostname: 'chatbotkit.com', + siteHost: 'chatbotkit.com', + staticUrl: siteUrl, + staticHostname: 'chatbotkit.com', + staticHost: 'chatbotkit.com', + widgetUrl: siteUrl, + widgetHostname: 'chatbotkit.com', + widgetHost: 'chatbotkit.com', + apiUrl: siteUrl, + apiHostname: 'chatbotkit.com', + apiHost: 'chatbotkit.com', + } +}) + jest.mock('@/config/apexes', () => ({ __esModule: true, portalApex: 'chatbotkit.agency', @@ -200,3 +221,34 @@ describe('Portal Slug Utilities', () => { }) }) }) + +describe('getPortalFrontendHost without a portal apex', () => { + it('falls back to the site host, port included', async () => { + let getPortalFrontendHostWithoutApex + + // @note the suite's top-level apexes mock is already instantiated and + // would win over a doMock; a reset lets the isolated load see the new one + jest.resetModules() + + jest.isolateModules(() => { + jest.doMock('@/config/apexes', () => ({ + __esModule: true, + portalApex: undefined, + })) + jest.doMock('@/config/site', () => ({ + siteHost: 'cbk.localhost:3000', + siteHostname: 'cbk.localhost', + siteUrl: 'http://cbk.localhost:3000', + })) + + getPortalFrontendHostWithoutApex = + jest.requireActual('@/lib/portal.slug').getPortalFrontendHost + }) + + // @note the minted name has to be reachable, so it is the site host as + // configured - on a Compose stack that carries the port + await expect( + getPortalFrontendHostWithoutApex({ slug: 'my-portal' }) + ).resolves.toBe('my-portal.cbk.localhost:3000') + }) +}) diff --git a/platform/lib/proxy.routing.utest.js b/platform/lib/proxy.routing.utest.js index 75cf313..4ef1a61 100644 --- a/platform/lib/proxy.routing.utest.js +++ b/platform/lib/proxy.routing.utest.js @@ -254,8 +254,10 @@ describe.each(['', '/platform'])( 'config/site.js', 'config/hosts.js', 'config/origins.js', + 'config/cookie.ts', 'config/debug.ts', 'lib/portal.hostname.ts', + 'lib/host.parse.ts', 'lib/context.store.js', 'lib/debug.ts', 'lib/redact.secrets.ts', diff --git a/platform/lib/secret.proxy.ts b/platform/lib/secret.proxy.ts index 858f5d2..7e48f1c 100644 --- a/platform/lib/secret.proxy.ts +++ b/platform/lib/secret.proxy.ts @@ -96,51 +96,51 @@ export function isAllowedEgressUrl(rawUrl: string): boolean { } // strip IPv6 brackets (`new URL('http://[::1]/').hostname` === '[::1]') - const host = url.hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '') + const hostname = url.hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '') // IPv6: hosts are hex:colon, so prefix checks cannot collide with DNS names - if (host.includes(':')) { + if (hostname.includes(':')) { // IPv4-mapped / -compatible IPv6 embeds a v4 address (e.g. // `::ffff:169.254.169.254`, `::ffff:7f00:1`) and routes to it - block it so // it cannot smuggle an internal v4 destination past the checks below - if (host.includes('.') || host.startsWith('::ffff:')) { + if (hostname.includes('.') || hostname.startsWith('::ffff:')) { return false } return !( - host === '::1' || // loopback - host === '::' || // unspecified - host.startsWith('fe80:') || // link-local - host.startsWith('fc') || // unique-local fc00::/7 - host.startsWith('fd') + hostname === '::1' || // loopback + hostname === '::' || // unspecified + hostname.startsWith('fe80:') || // link-local + hostname.startsWith('fc') || // unique-local fc00::/7 + hostname.startsWith('fd') ) } // reject non-canonical IPv4 literals the platform resolver still expands to an // internal address but which slip past the textual prefix checks below: hex // (`0x7f000001`), bare-decimal (`2130706433`), octal/leading-zero - // (`0177.0.0.1`) and short forms (`127.1`). Any all-numeric / dotted host must + // (`0177.0.0.1`) and short forms (`127.1`). Any all-numeric / dotted hostname must // be a canonical dotted-quad to proceed. - if (/^0x[0-9a-f]+$/i.test(host)) { + if (/^0x[0-9a-f]+$/i.test(hostname)) { return false } - if (/^[0-9.]+$/.test(host) && !isCanonicalDottedIPv4(host)) { + if (/^[0-9.]+$/.test(hostname) && !isCanonicalDottedIPv4(hostname)) { return false } // IPv4 / DNS if ( - host === 'localhost' || - host.endsWith('.localhost') || - host.endsWith('.internal') || - host.endsWith('.local') || - host === '0.0.0.0' || - host.startsWith('127.') || - host.startsWith('10.') || - host.startsWith('192.168.') || - host.startsWith('169.254.') || - /^172\.(1[6-9]|2\d|3[01])\./.test(host) + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname.endsWith('.internal') || + hostname.endsWith('.local') || + hostname === '0.0.0.0' || + hostname.startsWith('127.') || + hostname.startsWith('10.') || + hostname.startsWith('192.168.') || + hostname.startsWith('169.254.') || + /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ) { return false } diff --git a/platform/lib/space.site.serve.hosts.utest.js b/platform/lib/space.site.serve.hosts.utest.js new file mode 100644 index 0000000..b48c966 --- /dev/null +++ b/platform/lib/space.site.serve.hosts.utest.js @@ -0,0 +1,76 @@ +import prisma from '@/prisma/client' + +import { setupHeadersContext } from '@/lib/context.setup' +import { executeInContext } from '@/lib/context.store' +import { resolveSpaceSiteConfigByHost } from '@/lib/space.site.serve' + +jest.mock('@/prisma/client', () => ({ + __esModule: true, + default: { spaceSite: { findUnique: jest.fn() } }, +})) + +jest.mock('@/config/apexes', () => ({ + ...jest.requireActual('@/config/apexes'), + spaceApex: 'spaces.mapping.localhost', +})) + +jest.mock('@/config/hosts', () => ({ + ...jest.requireActual('@/config/hosts'), + hostsConfig: { + custom: { + match: ['acme.spaces.mapping.localhost:4300'], + site: 'console.mapping.localhost:4300', + api: 'console.mapping.localhost:4300', + static: 'console.mapping.localhost:4300', + widgets: 'console.mapping.localhost:4300', + }, + frontend: { + match: ['upstream.spaces.mapping.localhost:4300'], + site: 'public.spaces.mapping.localhost:4300', + api: 'console.mapping.localhost:4300', + static: 'console.mapping.localhost:4300', + widgets: 'console.mapping.localhost:4300', + }, + }, +})) + +describe('space site lookup with host mappings', () => { + beforeEach(() => { + jest.clearAllMocks() + prisma.spaceSite.findUnique.mockResolvedValue({ + spaceId: 'space-mapped', + prefix: 'public', + index: 'index.html', + notFound: '404.html', + }) + }) + + it.each([ + ['acme.spaces.mapping.localhost:4300', 'acme'], + ['upstream.spaces.mapping.localhost:4300', 'public'], + ])('resolves %s using the recognized site identity', async (host, slug) => { + await executeInContext(async () => { + setupHeadersContext(new Headers({ host })) + + expect(await resolveSpaceSiteConfigByHost()).toEqual({ + spaceId: 'space-mapped', + prefix: 'public', + index: 'index.html', + notFound: '404.html', + }) + expect(prisma.spaceSite.findUnique).toHaveBeenCalledTimes(1) + expect(prisma.spaceSite.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { slug } }) + ) + }) + }) + + it('keeps unknown hosts out of the space lookup', async () => { + await executeInContext(async () => { + setupHeadersContext(new Headers({ host: 'unrelated.localhost:4300' })) + + expect(await resolveSpaceSiteConfigByHost()).toEqual({}) + expect(prisma.spaceSite.findUnique).not.toHaveBeenCalled() + }) + }) +}) diff --git a/platform/lib/space.site.serve.ts b/platform/lib/space.site.serve.ts index 10cb5ce..7c56406 100644 --- a/platform/lib/space.site.serve.ts +++ b/platform/lib/space.site.serve.ts @@ -5,6 +5,7 @@ import { getContextFrontendHost, getContextRequestHost, } from '@/lib/context.store' +import { hostToHostname } from '@/lib/host.parse' import { captureException } from '@/lib/error' import fetch from '@/lib/fetch' import { getSpaceSiteSlug } from '@/lib/space.site' @@ -60,16 +61,20 @@ function withTrailingSlash(pathname: string): string { } /** - * Extracts the request host as a bare domain (no port), lower-cased. + * The space-site hostname, with the frontend identity taking precedence. */ -export function getSpaceSiteHost(): string | null { - const host = getContextFrontendHost() || getContextRequestHost() - - if (!host) { - return null - } - - return host.split(':')[0].trim().toLowerCase() || null +export function getSpaceSiteHostname(): string | null { + const hostnames = [getContextFrontendHost(), getContextRequestHost()] + .map(hostToHostname) + .filter(Boolean) + + // @note a mapped frontend can name the platform site while the request + // retains the space-site slug selected by host routing + return ( + hostnames.find((hostname) => getSpaceSiteSlug(hostname)) || + hostnames[0] || + null + ) } /** @@ -106,13 +111,13 @@ export function getSpaceSiteMountBaseHref(req: Request): string { * backing `spaceId` - never owner or other private fields. */ export async function resolveSpaceSiteConfigByHost(): Promise { - const host = getSpaceSiteHost() + const hostname = getSpaceSiteHostname() - if (!host) { + if (!hostname) { return {} } - const slug = getSpaceSiteSlug(host) + const slug = getSpaceSiteSlug(hostname) if (!slug) { return {} diff --git a/platform/lib/space.site.serve.utest.ts b/platform/lib/space.site.serve.utest.ts index 182c245..9f28b1a 100644 --- a/platform/lib/space.site.serve.utest.ts +++ b/platform/lib/space.site.serve.utest.ts @@ -4,7 +4,7 @@ import { ensureCharset, getContentTypeForPath, getSitePathCandidates, - getSpaceSiteHost, + getSpaceSiteHostname, getSpaceSiteMountBaseHref, injectHtmlBase, normalizeSiteStoragePath, @@ -44,7 +44,7 @@ function req(url = `https://acme.chatbotkit.space${MOUNT}`): Request { return { url } as unknown as Request } -describe('getSpaceSiteHost', () => { +describe('getSpaceSiteHostname', () => { beforeEach(() => { jest.clearAllMocks() getContextFrontendHost.mockReturnValue(null) @@ -55,19 +55,19 @@ describe('getSpaceSiteHost', () => { getContextFrontendHost.mockReturnValue('ACME.chatbotkit.space:443') getContextRequestHost.mockReturnValue('internal:3000') - expect(getSpaceSiteHost()).toBe('acme.chatbotkit.space') + expect(getSpaceSiteHostname()).toBe('acme.chatbotkit.space') }) it('falls back to the request host in context', () => { getContextRequestHost.mockReturnValue('docs.chatbotkit.space') - expect(getSpaceSiteHost()).toBe('docs.chatbotkit.space') + expect(getSpaceSiteHostname()).toBe('docs.chatbotkit.space') }) it('returns null when there is no host', () => { getContextRequestHost.mockReturnValue(null) - expect(getSpaceSiteHost()).toBeNull() + expect(getSpaceSiteHostname()).toBeNull() }) }) diff --git a/platform/lib/space.site.ts b/platform/lib/space.site.ts index eb34ccb..2613d1e 100644 --- a/platform/lib/space.site.ts +++ b/platform/lib/space.site.ts @@ -52,18 +52,18 @@ const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ * under the space-site apex. A multi-level host such as `a.b.` is * rejected because a site slug always occupies exactly one DNS label. */ -export function getSpaceSiteSlug(host: string): string | null { +export function getSpaceSiteSlug(hostname: string): string | null { if (!SPACE_SITE_APEX) { return null } const suffix = `.${SPACE_SITE_APEX}` - if (!host.endsWith(suffix)) { + if (!hostname.endsWith(suffix)) { return null } - const slug = host.slice(0, -suffix.length) + const slug = hostname.slice(0, -suffix.length) return slug && !slug.includes('.') ? slug : null } diff --git a/platform/lib/token.ts b/platform/lib/token.ts index 5f6b91a..17090b5 100644 --- a/platform/lib/token.ts +++ b/platform/lib/token.ts @@ -1,6 +1,7 @@ import { appSlugs } from '@/config/apps' import { isAppHostname } from '@/lib/app.helpers' +import { hostToHostname } from '@/lib/host.parse' import { API_AUDIENCE, APP_AUDIENCE, @@ -210,7 +211,7 @@ export const payloadVerifiers: Record = { // portal session const host = [getContextFrontendHost(), getContextRequestHost()].find( - (candidate) => candidate && isAppHostname(candidate) + (candidate) => candidate && isAppHostname(hostToHostname(candidate)) ) if (host) { diff --git a/platform/lib/token.utest.js b/platform/lib/token.utest.js index bbba385..6fd29f7 100644 --- a/platform/lib/token.utest.js +++ b/platform/lib/token.utest.js @@ -254,6 +254,12 @@ describe('APP_AUDIENCE host selection', () => { expect(throwNotAuthorized).toHaveBeenCalled() }) + + it('accepts an app session on an app host that carries a port', async () => { + await verify(null, 'quench-qsbx-ai.chatbotkit.agency:3000') + + expect(throwNotAuthorized).not.toHaveBeenCalled() + }) }) describe('getPayloadVerifier', () => { diff --git a/platform/lib/twilio.webhook.origin.utest.js b/platform/lib/twilio.webhook.origin.utest.js new file mode 100644 index 0000000..f0df9ff --- /dev/null +++ b/platform/lib/twilio.webhook.origin.utest.js @@ -0,0 +1,23 @@ +import { getTwilioIntegrationWebhook } from '@/lib/twilio.webhook' + +jest.mock('@/config/site', () => ({ + siteUrl: 'https://api.example.com', + siteHost: 'api.example.com', + siteHostname: 'api.example.com', + apiUrl: 'https://api.example.com', + apiHost: 'api.example.com', +})) + +describe('Twilio deployment URLs', () => { + it('uses /api when the api.* hostname also serves the site', () => { + expect(getTwilioIntegrationWebhook('demo', 'api.example.com')).toBe( + 'https://api.example.com/api/v1/integration/twilio/demo/webhook#tt=15000&rp=5xx' + ) + }) + + it('uses HTTP and keeps the port on a local deployment', () => { + expect(getTwilioIntegrationWebhook('demo', 'cbk.localhost:3000')).toBe( + 'http://cbk.localhost:3000/api/v1/integration/twilio/demo/webhook#tt=15000&rp=5xx' + ) + }) +}) diff --git a/platform/lib/twilio.webhook.ts b/platform/lib/twilio.webhook.ts index 9c75bf9..f970cd9 100644 --- a/platform/lib/twilio.webhook.ts +++ b/platform/lib/twilio.webhook.ts @@ -1,15 +1,16 @@ +import { getExternalAPIHostURL } from '@/lib/host' + /** * Generates a webhook URL for a Twilio integration */ export function getTwilioIntegrationWebhook( twilioIntegrationId: string, - host?: string + host?: string, + getAPIURL: (path: string) => string = (path) => + getExternalAPIHostURL(path, host) ): string { const url = new URL( - `${ - host?.startsWith('api.') ? '' : '/api' - }/v1/integration/twilio/${twilioIntegrationId}/webhook`, - `https://${host}` + getAPIURL(`/v1/integration/twilio/${twilioIntegrationId}/webhook`) ) url.hash = new URLSearchParams({ diff --git a/platform/lib/twilio.webhook.utest.js b/platform/lib/twilio.webhook.utest.js index 5ca670e..87fb3be 100644 --- a/platform/lib/twilio.webhook.utest.js +++ b/platform/lib/twilio.webhook.utest.js @@ -1,4 +1,19 @@ import { getTwilioIntegrationWebhook } from '@/lib/twilio.webhook' +import { getExternalAPIHostURL } from '@/lib/host' + +jest.mock('@/config/site', () => ({ + siteUrl: 'https://chatbotkit.com', + siteHost: 'chatbotkit.com', + siteHostname: 'chatbotkit.com', + apiUrl: 'https://api.chatbotkit.com', + apiHost: 'api.chatbotkit.com', +})) +jest.mock('@/config/hosts', () => ({ + hosts: { + site: [], + api: ['api.test.chatbotkit.com', 'api.example.com'], + }, +})) describe('twilio.webhook', () => { describe('getTwilioIntegrationWebhook', () => { @@ -131,20 +146,19 @@ describe('twilio.webhook', () => { const result = getTwilioIntegrationWebhook('test-id', 'localhost:3000') expect(result).toBe( - 'https://localhost:3000/api/v1/integration/twilio/test-id/webhook#tt=15000&rp=5xx' + 'http://localhost:3000/api/v1/integration/twilio/test-id/webhook#tt=15000&rp=5xx' ) }) }) describe('edge cases', () => { - it('should handle undefined host by using default URL behavior', () => { + it('should use the deployment API when the host is omitted', () => { const twilioIntegrationId = 'test-id' const result = getTwilioIntegrationWebhook(twilioIntegrationId) - // URL constructor uses 'undefined' as string when undefined is passed - expect(result).toContain('https://undefined/') - expect(result).toContain('/api/v1/integration/twilio/test-id/webhook') + expect(new URL(result).origin).toBe(new URL(getExternalAPIHostURL()).origin) + expect(result).toContain('/v1/integration/twilio/test-id/webhook') }) it('should handle empty string as integration ID', () => { @@ -175,13 +189,17 @@ describe('twilio.webhook', () => { expect(() => new URL(result)).not.toThrow() }) - it('should always use https protocol', () => { - const hosts = ['chatbotkit.com', 'api.test.com', 'localhost'] + it('should use HTTPS remotely and HTTP on localhost', () => { + const hosts = [ + ['chatbotkit.com', 'https:'], + ['api.test.com', 'https:'], + ['localhost', 'http:'], + ] - hosts.forEach((host) => { + hosts.forEach(([host, protocol]) => { const result = getTwilioIntegrationWebhook('test-id', host) - expect(result).toMatch(/^https:\/\//) + expect(new URL(result).protocol).toBe(protocol) }) }) diff --git a/platform/next.config.d/host.config.js b/platform/next.config.d/host.config.js deleted file mode 100644 index fcd59d9..0000000 --- a/platform/next.config.d/host.config.js +++ /dev/null @@ -1,25 +0,0 @@ -/* eslint-disable import/no-anonymous-default-export */ -// @ts-check - -/** @type {import('next').NextConfig} */ -export default { - async headers() { - return [ - { - source: '/:path*', - has: [ - { - type: 'host', - value: '(?.+?)', - }, - ], - headers: [ - { - key: 'set-cookie', - value: 'chatbotkit.host=:host; Path=/; Secure; SameSite=Lax', - }, - ], - }, - ] - }, -} diff --git a/platform/pages/_document.jsx b/platform/pages/_document.jsx index 8f3d6cb..9868ef8 100644 --- a/platform/pages/_document.jsx +++ b/platform/pages/_document.jsx @@ -3,7 +3,14 @@ import NextDocument, { Head, Html, Main, NextScript } from 'next/document' import { appApex, partnersApex, portalApex, spaceApex } from '@/config/apexes' import { appLabsHost, appMainHost } from '@/config/origins' -import { siteHostname } from '@/config/site' +import { + apiUrl, + siteHost, + siteUrl, + staticUrl, + widgetUrl, +} from '@/config/site' +import { hostToHostname } from '@/lib/host.parse' import { setupRequestContext } from '@/lib/context.setup' import { @@ -16,6 +23,7 @@ import { getExternalFrontendHost, getExternalStaticHost, getExternalWidgetHost, + servesCleanAPIRoutes, } from '@/lib/host' import { getPartnerByIdentifier, @@ -40,13 +48,13 @@ export default class Document extends NextDocument { ? getContextFrontendHost() || getContextRequestHost() : null - _host = _host || siteHostname + _host = _host || siteHost } let _partner = null { - const _hostname = (_host || '').split(':')[0] + const _hostname = hostToHostname(_host) const partnerSlug = getPartnerSlugFromHostname(_hostname) @@ -68,11 +76,27 @@ export default class Document extends NextDocument { return { ...initialProps, - _host: _host, - _siteHost: getExternalFrontendHost(), + // @note the configured origins and the hosts derived from them do not + // depend on a request, so they are always stamped - a prerendered page + // otherwise leaves the browser seeding the static, widget and API + // hosts from the page origin, and that carries into client-side + // navigation. Only the request host itself needs a request; without + // one the browser falls back to the host cookie and the page origin + _siteUrl: siteUrl, + _staticUrl: staticUrl, + _widgetUrl: widgetUrl, + _apiUrl: apiUrl, _staticHost: getExternalStaticHost(), _widgetHost: getExternalWidgetHost(), _apiHost: getExternalAPIHost(), + _apiCleanRoutes: servesCleanAPIRoutes(getExternalAPIHost()) ? '1' : '0', + + ...(ctx.req + ? { + _host: _host, + _siteHost: getExternalFrontendHost(), + } + : {}), _appApex: appApex, _portalApex: portalApex, @@ -92,10 +116,15 @@ export default class Document extends NextDocument { { + const siteUrl = 'http://cbk.localhost:3000' + const staticUrl = 'http://cbk-static.localhost:3000' + const widgetUrl = 'http://cbk-widgets.localhost:3000' + const apiUrl = 'http://cbk.localhost:3000' + + return { + siteUrl, + siteHostname: new URL(siteUrl).hostname, + siteHost: new URL(siteUrl).host, + staticUrl, + staticHostname: new URL(staticUrl).hostname, + staticHost: new URL(staticUrl).host, + widgetUrl, + widgetHostname: new URL(widgetUrl).hostname, + widgetHost: new URL(widgetUrl).host, + apiUrl, + apiHostname: new URL(apiUrl).hostname, + apiHost: new URL(apiUrl).host, + } +}) + +jest.mock('next/document', () => { + class NextDocument { + static async getInitialProps() { + return { html: '', head: [], styles: [] } + } + } + + return { + __esModule: true, + default: NextDocument, + Head: () => null, + Html: () => null, + Main: () => null, + NextScript: () => null, + } +}) + +jest.mock('@/lib/partner.helpers', () => ({ + getPartnerByIdentifier: jest.fn(async () => null), + getPartnerSlugFromHostname: jest.fn(() => null), +})) + +jest.mock('@/components/GlobalRoot', () => () => null) + +function request(host) { + return { + method: 'GET', + url: '/', + query: {}, + headers: host ? { host } : {}, + } +} + +describe('Document.getInitialProps', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('stamps the configured origins and the request host on a request', async () => { + const props = await Document.getInitialProps({ + req: request('cbk-labs.localhost:3000'), + }) + + expect(props).toMatchObject({ + _host: 'cbk-labs.localhost:3000', + _siteUrl: siteUrl, + _staticUrl: staticUrl, + _widgetUrl: widgetUrl, + _apiUrl: apiUrl, + }) + }) + + it('stamps the configured origins but no request host on a prerender', async () => { + const props = await Document.getInitialProps({}) + + // @note a build-time render has no request: the origins do not depend + // on one and keep the browser from seeding them from the page origin, + // while the request host itself falls back to the cookie and the page + expect(props).toMatchObject({ + _siteUrl: siteUrl, + _staticUrl: staticUrl, + _widgetUrl: widgetUrl, + _apiUrl: apiUrl, + }) + + for (const key of ['_host', '_siteHost']) { + expect(props).not.toHaveProperty(key) + } + + expect(props).toHaveProperty('_partner', null) + }) + + it('falls back to the configured site host, port included', async () => { + const props = await Document.getInitialProps({ req: request() }) + + expect(props._host).toBe(siteHost) + }) + + it('looks the partner up by hostname, IPv6 literals included', async () => { + await Document.getInitialProps({ req: request('[::1]:3000') }) + + // @note splitting on the first colon would hand `[` to the partner table + expect(getPartnerSlugFromHostname).toHaveBeenCalledWith('[::1]') + expect(getPartnerByIdentifier).not.toHaveBeenCalled() + }) +}) diff --git a/platform/pages/api/auth/[...nextauth].js b/platform/pages/api/auth/[...nextauth].js index baf6b14..14e1a13 100644 --- a/platform/pages/api/auth/[...nextauth].js +++ b/platform/pages/api/auth/[...nextauth].js @@ -32,13 +32,14 @@ import { getPartnerAuthInitialCallbacks, getPartnerAuthProviders, } from '@/lib/partner.auth' -import { isPartnerHost } from '@/lib/partner.helpers' +import { isPartnerHostname } from '@/lib/partner.helpers' import { getPortalAuthInitialAdapter, getPortalAuthInitialCallbacks, getPortalAuthProviders, } from '@/lib/portal.auth' import { isPortalHostname } from '@/lib/portal.hostname' +import { hostToHostname } from '@/lib/host.parse' // @ts-expect-error because NextAuth does not support ESM const NextAuth = _NextAuth.default @@ -48,13 +49,17 @@ const NextAuth = _NextAuth.default * @returns {Promise} */ export async function getInitialAdapter(host) { + // @note portal and partner identity is a hostname; the dispatch host may + // carry a port + const hostname = hostToHostname(host) + switch (true) { - case host && isPortalHostname(host): { - return await getPortalAuthInitialAdapter(host) + case hostname && isPortalHostname(hostname): { + return await getPortalAuthInitialAdapter(hostname) } - case host && isPartnerHost(host): { - return await getPartnerAuthInitialAdapter(host) + case hostname && isPartnerHostname(hostname): { + return await getPartnerAuthInitialAdapter(hostname) } default: { @@ -68,13 +73,17 @@ export async function getInitialAdapter(host) { * @returns {Promise} */ export async function getProviders(host) { + // @note portal and partner identity is a hostname; the dispatch host may + // carry a port + const hostname = hostToHostname(host) + switch (true) { - case host && isPortalHostname(host): { - return await getPortalAuthProviders(host) + case hostname && isPortalHostname(hostname): { + return await getPortalAuthProviders(hostname) } - case host && isPartnerHost(host): { - return await getPartnerAuthProviders(host) + case hostname && isPartnerHostname(hostname): { + return await getPartnerAuthProviders(hostname) } default: { @@ -88,13 +97,17 @@ export async function getProviders(host) { * @returns {Promise} */ export async function getInitialCallbacks(host) { + // @note portal and partner identity is a hostname; the dispatch host may + // carry a port + const hostname = hostToHostname(host) + switch (true) { - case host && isPortalHostname(host): { - return await getPortalAuthInitialCallbacks(host) + case hostname && isPortalHostname(hostname): { + return await getPortalAuthInitialCallbacks(hostname) } - case host && isPartnerHost(host): { - return await getPartnerAuthInitialCallbacks(host) + case hostname && isPartnerHostname(hostname): { + return await getPartnerAuthInitialCallbacks(hostname) } default: { diff --git a/platform/pages/api/auth/_nextauth.utest.js b/platform/pages/api/auth/_nextauth.utest.js index 300de7b..7eb532b 100644 --- a/platform/pages/api/auth/_nextauth.utest.js +++ b/platform/pages/api/auth/_nextauth.utest.js @@ -25,7 +25,7 @@ jest.mock('@/lib/context.store', () => ({ })) jest.mock('@/lib/method', () => ({ withAny: (fn) => fn })) jest.mock('@/lib/partner.auth', () => ({})) -jest.mock('@/lib/partner.helpers', () => ({ isPartnerHost: () => false })) +jest.mock('@/lib/partner.helpers', () => ({ isPartnerHostname: () => false })) jest.mock('@/lib/portal.auth', () => ({ getPortalAuthProviders: jest.fn() })) jest.mock('@/lib/portal.hostname', () => ({ isPortalHostname: jest.fn(() => false) })) @@ -199,3 +199,31 @@ describe('auth surface dispatch host', () => { expect(getPortalAuthProviders).not.toHaveBeenCalled() }) }) + +describe('auth surface dispatch on a host that carries a port', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('dispatches portal providers by hostname', async () => { + const portalProviders = [{ id: 'email' }] + + isPortalHostname.mockImplementation( + (hostname) => hostname === 'zelektro-glimps-group.chatbotkit.agency' + ) + getPortalAuthProviders.mockResolvedValue(portalProviders) + + // @note the dispatch host is the Host header, port included; portal + // identity is a hostname + await expect( + getProviders('zelektro-glimps-group.chatbotkit.agency:3000') + ).resolves.toBe(portalProviders) + + expect(isPortalHostname).toHaveBeenCalledWith( + 'zelektro-glimps-group.chatbotkit.agency' + ) + expect(getPortalAuthProviders).toHaveBeenCalledWith( + 'zelektro-glimps-group.chatbotkit.agency' + ) + }) +}) diff --git a/platform/pages/api/v1/_spec.utest.js b/platform/pages/api/v1/_spec.utest.js index 7a0d514..fc83d0c 100644 --- a/platform/pages/api/v1/_spec.utest.js +++ b/platform/pages/api/v1/_spec.utest.js @@ -1,9 +1,10 @@ import { + getContextAPIHost, getContextFrontendHost, getContextRequestHost, getContextRequestProtocol, } from '@/lib/context.store' -import { getExternalAPIHost } from '@/lib/host' +import { getExternalAPIHost, getExternalFrontendHostURL } from '@/lib/host' import handler from './spec' @@ -14,6 +15,7 @@ jest.mock('fs', () => ({ })) jest.mock('@/lib/context.store', () => ({ + getContextAPIHost: jest.fn(), getContextFrontendHost: jest.fn(), getContextRequestHost: jest.fn(), getContextRequestProtocol: jest.fn(), @@ -31,6 +33,22 @@ describe('/api/v1/spec', () => { getContextRequestProtocol.mockReturnValue(undefined) }) + it.each([ + ['api.mapped.localhost:4300', '/v1/spec', '/v1'], + ['api.mapped.localhost:4300', '/api/v1/spec', '/v1'], + ['mapped.localhost:4300', '/v1/spec', '/api/v1'], + ])('advertises the mapped API %s from %s', async (apiHost, pathname, apiPath) => { + fs.readFileSync.mockReturnValueOnce(JSON.stringify({ openapi: '3.0.0' })) + getContextFrontendHost.mockReturnValue('mapped.localhost:4300') + getContextRequestHost.mockReturnValue(apiHost) + getContextAPIHost.mockReturnValue(apiHost) + + const response = await handler(new Request(`http://${apiHost}${pathname}`)) + const body = await response.json() + + expect(body.servers).toEqual([{ url: `http://${apiHost}${apiPath}` }]) + }) + it('returns spec with server URL using frontend host and context protocol', async () => { fs.readFileSync.mockReturnValueOnce( JSON.stringify({ @@ -69,6 +87,18 @@ describe('/api/v1/spec', () => { expect(body.servers).toEqual([{ url: 'https://api.example.com/api/v1' }]) }) + it('stays on http for a loopback deployment without a request scheme', async () => { + fs.readFileSync.mockReturnValueOnce(JSON.stringify({ openapi: '3.0.0' })) + getContextRequestHost.mockReturnValue('cbk.localhost:3000') + + const response = await handler( + new Request('http://ignored.example.com/api/v1/spec') + ) + const body = await response.json() + + expect(body.servers).toEqual([{ url: 'http://cbk.localhost:3000/api/v1' }]) + }) + it('uses platform default host when no host headers are available', async () => { fs.readFileSync.mockReturnValueOnce( JSON.stringify({ @@ -81,6 +111,9 @@ describe('/api/v1/spec', () => { ) const body = await response.json() - expect(body.servers).toEqual([{ url: `https://${getExternalAPIHost()}/` }]) + // @note the scheme follows the deployment for its own host + expect(body.servers).toEqual([ + { url: getExternalFrontendHostURL('/', getExternalAPIHost()) }, + ]) }) }) diff --git a/platform/pages/api/v1/integration/mcpserver/[mcpserverIntegrationId]/_mcp.utest.js b/platform/pages/api/v1/integration/mcpserver/[mcpserverIntegrationId]/_mcp.utest.js index 247a701..7f44a20 100644 --- a/platform/pages/api/v1/integration/mcpserver/[mcpserverIntegrationId]/_mcp.utest.js +++ b/platform/pages/api/v1/integration/mcpserver/[mcpserverIntegrationId]/_mcp.utest.js @@ -124,6 +124,10 @@ jest.mock('@/lib/mcp.widget', () => ({ () => new Set(['unpkg.com', 'cdn.jsdelivr.net']) ), parseWidgetUiValue: jest.fn(() => null), + normalizeWidgetUiValue: jest.fn(), + resolveWidgetManifestUrl: jest.fn(), + fetchWidgetManifest: jest.fn(), + getCdnBundleUrl: jest.fn(), })) jest.mock('@/lib/oauth.jwt', () => ({ @@ -157,6 +161,8 @@ jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({ jest.mock('@modelcontextprotocol/sdk/types.js', () => ({ ListToolsRequestSchema: 'ListToolsRequestSchema', CallToolRequestSchema: 'CallToolRequestSchema', + ListResourcesRequestSchema: 'ListResourcesRequestSchema', + ReadResourceRequestSchema: 'ReadResourceRequestSchema', })) describe('MCP server integration endpoint', () => { @@ -1436,3 +1442,124 @@ describe('MCP server integration endpoint', () => { }) }) }) + +describe('widget CSP on a deployment whose widget host carries a port', () => { + let mockIntegration + + beforeEach(() => { + jest.clearAllMocks() + + const { verifyOAuthToken, isTokenRevoked, hasScope } = + require('@/lib/oauth.jwt') + + verifyOAuthToken.mockResolvedValue(null) + isTokenRevoked.mockResolvedValue(false) + hasScope.mockImplementation(() => true) + + mockServerSetRequestHandler.mockClear() + mockServerConnect.mockClear().mockResolvedValue(undefined) + mockTransportHandleRequest.mockClear().mockResolvedValue(undefined) + + const { + getAbilityFunctionName, + getAbilityFunctionDescription, + getAbilityFunctionParameters, + } = require('@/lib/ability.function') + + getAbilityFunctionName.mockImplementation((ability) => ability.name) + getAbilityFunctionDescription.mockImplementation(() => 'Test description') + getAbilityFunctionParameters.mockImplementation(() => ({ + type: 'object', + properties: {}, + })) + + const { + getContextFrontendHost, + getContextWidgetHost, + } = require('@/lib/context.store') + + getContextFrontendHost.mockReturnValue('cbk.localhost:3000') + getContextWidgetHost.mockReturnValue('cbk-widgets.localhost:3000') + + const { + parseWidgetUiValue, + normalizeWidgetUiValue, + resolveWidgetManifestUrl, + fetchWidgetManifest, + getCdnBundleUrl, + } = require('@/lib/mcp.widget') + + parseWidgetUiValue.mockReturnValue('card') + normalizeWidgetUiValue.mockReturnValue({ widget: 'card' }) + resolveWidgetManifestUrl.mockReturnValue( + 'http://cbk-widgets.localhost:3000/card/manifest.json' + ) + fetchWidgetManifest.mockResolvedValue({ tagName: 'x-card' }) + getCdnBundleUrl.mockReturnValue( + 'http://cbk-widgets.localhost:3000/card/bundle.js' + ) + + mockIntegration = { + id: 'integration-123', + name: 'Test MCP Server', + userId: 'user-123', + user: { id: 'user-123', email: 'user@example.com' }, + accessToken: 'valid-token-123', + skillset: { + id: 'skillset-123', + abilities: [ + { + id: 'ability-1', + name: 'test_ability', + description: 'Test ability description', + instruction: 'Test instruction', + meta: { mcp: { ui: 'card' } }, + }, + ], + }, + } + }) + + async function readResource(uri) { + prisma.mcpserverIntegration.findUnique.mockResolvedValue(mockIntegration) + + const { req, res } = createMocks({ + method: 'POST', + query: { mcpserverIntegrationId: 'integration-123', client: 'chatgpt' }, + headers: { authorization: 'Bearer valid-token-123' }, + body: {}, + }) + + await handler(req, res) + + const readResourceHandler = mockServerSetRequestHandler.mock.calls.find( + (call) => call[0] === 'ReadResourceRequestSchema' + )?.[1] + + expect(readResourceHandler).toBeDefined() + + return readResourceHandler({ params: { uri } }) + } + + it('allows the mapped widget origin as served for the universal widget', async () => { + const { contents } = await readResource('ui://widget/frame') + + // @note the allowlist holds hostnames and is minted as https; the + // deployment's own widget origin has to come in with its scheme and port + expect(contents[0]._meta['openai/widgetCSP'].resource_domains).toContain( + 'http://cbk-widgets.localhost:3000' + ) + }) + + it('allows the origin a tool widget bundle loads from', async () => { + const { contents } = await readResource('ui://widget/test_ability') + + expect(contents[0]._meta['openai/widgetCSP'].resource_domains).toEqual( + expect.arrayContaining([ + 'https://unpkg.com', + 'https://cdn.jsdelivr.net', + 'http://cbk-widgets.localhost:3000', + ]) + ) + }) +}) diff --git a/platform/pages/api/v1/integration/mcpserver/[mcpserverIntegrationId]/mcp.ts b/platform/pages/api/v1/integration/mcpserver/[mcpserverIntegrationId]/mcp.ts index 37f720e..ff11841 100644 --- a/platform/pages/api/v1/integration/mcpserver/[mcpserverIntegrationId]/mcp.ts +++ b/platform/pages/api/v1/integration/mcpserver/[mcpserverIntegrationId]/mcp.ts @@ -30,6 +30,7 @@ import { getExternalFrontendHostURL, getExternalHostURL, getExternalStaticHostURL, + getExternalWidgetHostURL, } from '@/lib/host' import type { JsonSchemaObject } from '@/lib/jsonschema' import { logEvent } from '@/lib/log' @@ -153,10 +154,18 @@ export default async function handler( const externalStaticHostURL = new URL(getExternalStaticHostURL()) .origin - const allowedWidgetOrigins = Array.from( - getAllowedWidgetDomains(), - (domain) => `https://${domain}` - ) + // @note the allowlist holds hostnames; the deployment's own origins + // come from the URL builders, which carry the scheme and port + const externalWidgetHostURL = new URL(getExternalWidgetHostURL()) + .origin + + const allowedWidgetOrigins = [ + ...Array.from( + getAllowedWidgetDomains(), + (domain) => `https://${domain}` + ), + externalWidgetHostURL, + ] const mcpserverIntegration = await prisma.mcpserverIntegration.findUnique({ @@ -621,10 +630,15 @@ export default async function handler( 'openai/widgetDomain': 'https://chatgpt.com', 'openai/widgetCSP': { connect_domains: ['https://chatgpt.com'], - resource_domains: [ - 'https://unpkg.com', - 'https://cdn.jsdelivr.net', - ], + // @note the bundle loads from the validated manifest's + // origin, which may be the deployment itself + resource_domains: Array.from( + new Set([ + 'https://unpkg.com', + 'https://cdn.jsdelivr.net', + new URL(cdnUrl).origin, + ]) + ), }, // @note add description if provided to reduce model narration ...(widgetConfig.description && { diff --git a/platform/pages/api/v1/integration/slack/[slackIntegrationId]/_queue.utest.js b/platform/pages/api/v1/integration/slack/[slackIntegrationId]/_queue.utest.js index a0122db..bd5cff6 100644 --- a/platform/pages/api/v1/integration/slack/[slackIntegrationId]/_queue.utest.js +++ b/platform/pages/api/v1/integration/slack/[slackIntegrationId]/_queue.utest.js @@ -50,6 +50,27 @@ import { // @note partner ownership is covered by portal.config.utest.js; the // custom-domain cases here pin only frontend host propagation +// @note a portless production-style site, so the derived portal hosts do not +// depend on whatever SITE_URL the shell exports +jest.mock('@/config/site', () => { + const siteUrl = 'https://chatbotkit.com' + + return { + siteUrl, + siteHostname: 'chatbotkit.com', + siteHost: 'chatbotkit.com', + staticUrl: siteUrl, + staticHostname: 'chatbotkit.com', + staticHost: 'chatbotkit.com', + widgetUrl: siteUrl, + widgetHostname: 'chatbotkit.com', + widgetHost: 'chatbotkit.com', + apiUrl: siteUrl, + apiHostname: 'chatbotkit.com', + apiHost: 'chatbotkit.com', + } +}) + jest.mock('@/lib/portal.config', () => ({ getPortalGlobalConfig: jest.fn((portal) => portal.slug.endsWith('-acme-dev') ? { domain: 'acme.dev' } : null @@ -198,6 +219,8 @@ jest.mock('@/lib/slack.references', () => ({ jest.mock('@/lib/context.store', () => ({ setContextUser: jest.fn(), setContextFrontendHost: jest.fn(), + getContextRequestHost: jest.fn(), + getContextRequestProtocol: jest.fn(), })) jest.mock('@/lib/session.context', () => ({ updateSessionStore: jest.fn() })) diff --git a/platform/pages/api/v1/spec.ts b/platform/pages/api/v1/spec.ts index 97b8970..2f2b21c 100644 --- a/platform/pages/api/v1/spec.ts +++ b/platform/pages/api/v1/spec.ts @@ -1,10 +1,15 @@ // @ts-check import { + getContextAPIHost, getContextFrontendHost, getContextRequestHost, getContextRequestProtocol, } from '@/lib/context.store' -import { getExternalAPIHost } from '@/lib/host' +import { + getExternalAPIHost, + getExternalAPIHostURL, + getExternalFrontendHostURL, +} from '@/lib/host' import { withGet } from '@/lib/method' import { ok } from '@/lib/response' @@ -20,20 +25,34 @@ export default withGet(async function (req) { const spec = JSON.parse(specContent) + const mappedAPIHost = getContextAPIHost() + const host = + mappedAPIHost || getContextFrontendHost() || getContextRequestHost() || getExternalAPIHost() - const protocol = getContextRequestProtocol() || 'https' + // @note without a request scheme the host decides: loopback and the site + // host follow the deployment, anything else is https + const protocol = + getContextRequestProtocol() || + new URL(getExternalFrontendHostURL('/', host)).protocol.slice(0, -1) const requestUrl = new URL(req.url) const basePath = requestUrl.pathname.replace(/\/spec$/, '') - const serverUrl = new URL(basePath, `${protocol}://${host}`) + // @note a mapping may serve its API separately from its frontend, with a + // different route prefix from the origin on which the spec was requested + const serverUrl = mappedAPIHost + ? getExternalAPIHostURL( + basePath.replace(/^\/api(?=\/|$)/, ''), + mappedAPIHost + ) + : new URL(basePath, `${protocol}://${host}`).toString() spec.servers = [ { - url: serverUrl.toString(), + url: serverUrl, }, ] diff --git a/platform/pages/api/v1/team/[teamId]/membership/[teamMembershipId]/invite/_resend.utest.js b/platform/pages/api/v1/team/[teamId]/membership/[teamMembershipId]/invite/_resend.utest.js index 67ee229..5c6d646 100644 --- a/platform/pages/api/v1/team/[teamId]/membership/[teamMembershipId]/invite/_resend.utest.js +++ b/platform/pages/api/v1/team/[teamId]/membership/[teamMembershipId]/invite/_resend.utest.js @@ -266,3 +266,38 @@ describe('POST /api/v1/team/[teamId]/membership/[teamMembershipId]/invite/resend }) }) }) + +describe('partner branding on a frontend host that carries a port', () => { + it('looks the partner up by hostname', async () => { + const { getContextFrontendHost } = require('@/lib/context.store') + const { getPartnerByHostname } = require('@/lib/partner.helpers') + + mockReset(prisma) + jest.clearAllMocks() + + getContextFrontendHost.mockReturnValue('partner.example.com:3000') + getPartnerByHostname.mockResolvedValue(null) + + prisma.team.findUniqueByIdentifier.mockResolvedValue({ + id: 'team_abc', + userId: 'user_123', + name: 'My Team', + description: 'A test team', + }) + prisma.teamMembership.findFirst.mockResolvedValue({ + id: 'membership_xyz', + teamId: 'team_abc', + email: 'invited@example.com', + }) + notifyTeamInvitation.mockResolvedValue(undefined) + + await handler( + { query: { teamId: 'team_abc', teamMembershipId: 'membership_xyz' } }, + { user: { id: 'user_123' } } + ) + + // @note partner identity is a hostname; passed with the port nothing + // would match and the invitation would lose its branding + expect(getPartnerByHostname).toHaveBeenCalledWith('partner.example.com') + }) +}) diff --git a/platform/pages/api/v1/team/[teamId]/membership/[teamMembershipId]/invite/resend.ts b/platform/pages/api/v1/team/[teamId]/membership/[teamMembershipId]/invite/resend.ts index aacf7bd..0df9bf3 100644 --- a/platform/pages/api/v1/team/[teamId]/membership/[teamMembershipId]/invite/resend.ts +++ b/platform/pages/api/v1/team/[teamId]/membership/[teamMembershipId]/invite/resend.ts @@ -8,6 +8,7 @@ import { captureException } from '@/lib/error' import schema, { withSchema } from '@/lib/joi.handler' import { withPost } from '@/lib/method' import { notifyTeamInvitation } from '@/lib/notify' +import { hostToHostname } from '@/lib/host.parse' import { getPartnerByHostname, partnerToEmailBranding, @@ -53,7 +54,7 @@ export default withPost( getContextFrontendHost() || getContextRequestHost() || undefined const partner = host - ? ((await getPartnerByHostname(host)) ?? undefined) + ? ((await getPartnerByHostname(hostToHostname(host))) ?? undefined) : undefined // @note a partner carries the transport that sends as its own diff --git a/platform/pages/api/v1/team/[teamId]/membership/_create.utest.js b/platform/pages/api/v1/team/[teamId]/membership/_create.utest.js index 37fc9d5..0e89514 100644 --- a/platform/pages/api/v1/team/[teamId]/membership/_create.utest.js +++ b/platform/pages/api/v1/team/[teamId]/membership/_create.utest.js @@ -63,6 +63,7 @@ jest.mock('@/lib/notify', () => ({ jest.mock('@/lib/error', () => ({ captureException: jest.fn(), + setTag: jest.fn(), })) jest.mock('@/lib/partner.helpers', () => ({ @@ -159,3 +160,41 @@ describe('/api/v1/team/[teamId]/membership/create', () => { ).rejects.toThrow('"email" must be a valid email') }) }) + +describe('partner branding on a frontend host that carries a port', () => { + it('looks the partner up by hostname', async () => { + const { + executeInContext, + setContextFrontendHost, + } = require('@/lib/context.store') + const { getPartnerByHostname } = require('@/lib/partner.helpers') + + jest.clearAllMocks() + + getPartnerByHostname.mockResolvedValue(null) + prisma.team.findUniqueByIdentifier.mockResolvedValue({ + id: 'team_1', + userId: 'user_1', + name: 'My Team', + description: 'desc', + }) + prisma.teamMembership.upsert.mockResolvedValue({ id: 'tm_1' }) + + await executeInContext(async () => { + setContextFrontendHost('partner.example.com:3000') + + await handler( + { query: { teamId: 'team_1' }, headers: {} }, + { user: { id: 'user_1' } }, + { + name: 'Member Name', + email: 'member@example.com', + } + ) + }) + + // @note partner identity is a hostname; passed with the port nothing + // would match and the invitation would lose its branding + expect(getPartnerByHostname).toHaveBeenCalledWith('partner.example.com') + }) +}) diff --git a/platform/pages/api/v1/team/[teamId]/membership/create.js b/platform/pages/api/v1/team/[teamId]/membership/create.js index f82ec7e..1d725b9 100644 --- a/platform/pages/api/v1/team/[teamId]/membership/create.js +++ b/platform/pages/api/v1/team/[teamId]/membership/create.js @@ -11,6 +11,7 @@ import { withLimits } from '@/lib/limit.handler' import { logAudit } from '@/lib/log' import { withPost } from '@/lib/method' import { notifyTeamInvitation } from '@/lib/notify' +import { hostToHostname } from '@/lib/host.parse' import { getPartnerByHostname, partnerToEmailBranding, @@ -107,7 +108,7 @@ export default withPost( getContextFrontendHost() || getContextRequestHost() || undefined const partner = host - ? ((await getPartnerByHostname(host)) ?? undefined) + ? ((await getPartnerByHostname(hostToHostname(host))) ?? undefined) : undefined // @note a partner carries the transport that sends as its own diff --git a/platform/pages/auto/widget/_frame.utest.js b/platform/pages/auto/widget/_frame.utest.js new file mode 100644 index 0000000..db364aa --- /dev/null +++ b/platform/pages/auto/widget/_frame.utest.js @@ -0,0 +1,89 @@ +/** + * @jest-environment node + */ +import { getPartnerByHostname } from '@/lib/partner.helpers' + +import { getServerSideProps } from './frame' + +jest.mock('@/config/widget', () => ({ + autoWidgetModel: 'gpt-4o', + autoWidgetUserId: 'auto-user', +})) +jest.mock('@/lib/session.get', () => ({ + getSoftSession: jest.fn(async () => ({ user: { id: 'user_1' } })), +})) +jest.mock('@/lib/user.type', () => ({ + isEffectivePartnerAccount: jest.fn(async () => false), +})) +jest.mock('@/lib/partner.helpers', () => ({ + getPartnerByHostname: jest.fn(async () => null), +})) +jest.mock('@/lib/conversation.create', () => ({ + createConversation: jest.fn(async () => ({ id: 'conv_1' })), +})) +jest.mock('@/lib/bot.conversation', () => ({ + getConversationDetails: jest.fn((details) => details), +})) +jest.mock( + '@/pages/api/v1/conversation/[conversationId]/session/create', + () => ({ createConversationSessionToken: jest.fn(async () => 'token') }) +) +jest.mock('@/pages/integrations/widget/[widgetIntegrationId]/frame', () => ({ + __esModule: true, + default: () => null, +})) +jest.mock('@/hooks/useIsTop', () => jest.fn(() => true)) + +function context(host, method = 'GET') { + const headers = {} + const body = [] + + return { + query: { type: 'dashboard-assistant' }, + req: { method, url: '/auto/widget/frame', query: {}, headers: { host } }, + res: { + setHeader: jest.fn((name, value) => { + headers[name] = value + }), + write: jest.fn((chunk) => body.push(chunk)), + end: jest.fn(), + headers, + body, + }, + } +} + +describe('auto widget frame getServerSideProps', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('allows the embedding origin with the scheme and port the deployment serves', async () => { + const ctx = context('cbk.localhost:3000') + + await getServerSideProps(ctx) + + expect(ctx.res.headers['Content-Security-Policy']).toBe( + "frame-ancestors 'self' http://cbk.localhost:3000" + ) + }) + + it('brands a partner by hostname and quotes its origin as served', async () => { + getPartnerByHostname.mockResolvedValue({ name: 'AgenticOS' }) + + const { createConversation } = jest.requireMock('@/lib/conversation.create') + const ctx = context('backend.acme.localhost:3000', 'POST') + + await getServerSideProps(ctx) + + expect(getPartnerByHostname).toHaveBeenCalledWith('backend.acme.localhost') + expect(createConversation).toHaveBeenCalledWith( + 'auto-user', + expect.objectContaining({ + backstory: expect.stringContaining( + 'partner origin: http://backend.acme.localhost:3000' + ), + }) + ) + }) +}) diff --git a/platform/pages/auto/widget/frame.jsx b/platform/pages/auto/widget/frame.jsx index 77aac4e..bcc44e3 100644 --- a/platform/pages/auto/widget/frame.jsx +++ b/platform/pages/auto/widget/frame.jsx @@ -2,7 +2,9 @@ import { template as t } from '@chatbotkit-dev/template' import { ONE_DAY_IN_SECONDS, getStartOfDay } from '@chatbotkit-dev/time' -import { siteHostname } from '@/config/site' +import { siteHost } from '@/config/site' +import { getExternalFrontendHostURL } from '@/lib/host' +import { hostToHostname } from '@/lib/host.parse' import { autoWidgetModel, autoWidgetUserId } from '@/config/widget' import { getConversationDetails } from '@/lib/bot.conversation' @@ -53,7 +55,13 @@ export async function getServerSideProps(context) { } const frontendHost = - getContextFrontendHost() || getContextRequestHost() || siteHostname + getContextFrontendHost() || getContextRequestHost() || siteHost + + // @note the origin the embedding page is served from - scheme and port + // follow the deployment, not a hard-coded https + const frontendOrigin = new URL( + getExternalFrontendHostURL('/', frontendHost) + ).origin const session = await getSoftSession(context.req, context.res) @@ -390,10 +398,10 @@ export async function getServerSideProps(context) { let backstory = widgetIntegration.backstory { - const partner = await getPartnerByHostname(frontendHost) + const partner = await getPartnerByHostname(hostToHostname(frontendHost)) if (partner) { - const origin = `https://${frontendHost}` + const origin = frontendOrigin backstory = t` ${backstory} @@ -463,7 +471,7 @@ export async function getServerSideProps(context) { context.res.setHeader( 'Content-Security-Policy', - `frame-ancestors 'self' https://${frontendHost}` + `frame-ancestors 'self' ${frontendOrigin}` ) return { diff --git a/platform/pages/blueprints/[blueprintId]/designer.jsx b/platform/pages/blueprints/[blueprintId]/designer.jsx index 04e6465..157734d 100644 --- a/platform/pages/blueprints/[blueprintId]/designer.jsx +++ b/platform/pages/blueprints/[blueprintId]/designer.jsx @@ -54,7 +54,7 @@ import { isCuid } from '@/lib/cuid' import { assert, warn } from '@/lib/debug' import { getShortDescription } from '@/lib/description.parse' import { isDevelopment, isProduction } from '@/lib/env' -import { getExternalAPIHost } from '@/lib/host' +import { resolvePlatformApiUrl } from '@/lib/api.tool.url' import { toThemeAwareIcon } from '@/lib/icon.theme' import { buildTemplateInstruction, @@ -17951,19 +17951,7 @@ export function Assistant({ blueprintId }) { // @note ensure we use relative URLs to stay within the platform // and include auth cookies - const u = new URL(url, window.location.origin) - - // @note translate the deployment api host urls to local /api - - if (u.hostname === getExternalAPIHost()) { - const localOrigin = new URL(window.location.origin) - - u.protocol = localOrigin.protocol - u.host = localOrigin.host - u.pathname = u.pathname.startsWith('/api/') - ? u.pathname - : `/api${u.pathname}` - } + const u = resolvePlatformApiUrl(url) // @note only allow same-origin requests for security diff --git a/platform/pages/examples/[slug]/_frame.utest.js b/platform/pages/examples/[slug]/_frame.utest.js new file mode 100644 index 0000000..d874bf3 --- /dev/null +++ b/platform/pages/examples/[slug]/_frame.utest.js @@ -0,0 +1,75 @@ +/** + * @jest-environment node + */ +import { getServerSideProps } from './frame' + +jest.mock('@/lib/example.fetch', () => ({ + getExampleBySlug: jest.fn(() => ({ + slug: 'concierge', + title: 'Concierge', + description: 'A concierge', + live: true, + backstory: 'You are a concierge', + model: 'gpt-4o', + widget: {}, + })), +})) +jest.mock('@/lib/session.get', () => ({ + getSoftSession: jest.fn(async () => null), +})) +jest.mock('@/lib/conversation.create', () => ({ + createConversation: jest.fn(), +})) +jest.mock('@/lib/bot.conversation', () => ({ + getConversationDetails: jest.fn(), +})) +jest.mock( + '@/pages/api/v1/conversation/[conversationId]/session/create', + () => ({ createConversationSessionToken: jest.fn() }) +) +jest.mock('@/pages/integrations/widget/[widgetIntegrationId]/frame', () => ({ + __esModule: true, + default: () => null, +})) +jest.mock('@/hooks/useIsTop', () => jest.fn(() => true)) + +function context(host) { + const headers = {} + + return { + params: { slug: 'concierge' }, + query: {}, + req: { method: 'GET', url: '/examples/concierge/frame', query: {}, headers: { host } }, + res: { + setHeader: jest.fn((name, value) => { + headers[name] = value + }), + headers, + }, + } +} + +describe('examples frame getServerSideProps', () => { + it('allows the embedding origin with the scheme and port the deployment serves', async () => { + const ctx = context('cbk.localhost:3000') + + const result = await getServerSideProps(ctx) + + expect(result.props.integration.id).toBe('concierge') + // @note a hard-coded https would reject the http origin the local + // stack embeds from + expect(ctx.res.headers['Content-Security-Policy']).toBe( + "frame-ancestors 'self' http://cbk.localhost:3000" + ) + }) + + it('keeps https for a public host', async () => { + const ctx = context('acme.chatbotkit.agency') + + await getServerSideProps(ctx) + + expect(ctx.res.headers['Content-Security-Policy']).toBe( + "frame-ancestors 'self' https://acme.chatbotkit.agency" + ) + }) +}) diff --git a/platform/pages/examples/[slug]/frame.jsx b/platform/pages/examples/[slug]/frame.jsx index 71610a0..788b1ef 100644 --- a/platform/pages/examples/[slug]/frame.jsx +++ b/platform/pages/examples/[slug]/frame.jsx @@ -1,7 +1,8 @@ /* eslint-disable custom-eslint-rules/no-restricted-client-imports -- used inside getServerSideProps only */ import { ONE_HOUR_IN_SECONDS } from '@chatbotkit-dev/time' -import { siteHostname } from '@/config/site' +import { siteHost } from '@/config/site' +import { getExternalFrontendHostURL } from '@/lib/host' import { exampleWidgetUserId } from '@/config/widget' import { getConversationDetails } from '@/lib/bot.conversation' @@ -35,7 +36,7 @@ export async function getServerSideProps(context) { setupRequestContext(context.req) const frontendHost = - getContextFrontendHost() || getContextRequestHost() || siteHostname + getContextFrontendHost() || getContextRequestHost() || siteHost const example = getExampleBySlug(context.params.slug) @@ -174,7 +175,7 @@ export async function getServerSideProps(context) { context.res.setHeader( 'Content-Security-Policy', - `frame-ancestors 'self' https://${frontendHost}` + `frame-ancestors 'self' ${new URL(getExternalFrontendHostURL('/', frontendHost)).origin}` ) return { diff --git a/platform/pages/hub/blueprints/[blueprintId]/index.jsx b/platform/pages/hub/blueprints/[blueprintId]/index.jsx index 10d35dd..f33419e 100644 --- a/platform/pages/hub/blueprints/[blueprintId]/index.jsx +++ b/platform/pages/hub/blueprints/[blueprintId]/index.jsx @@ -21,7 +21,7 @@ import { useApexHostURL, usePortalApex, useSpaceApex, -} from '@/hooks/useHostname' +} from '@/hooks/useHost' import useSession from '@/hooks/useSession' import faq from '@/content/faqs/platform-blueprints.yaml' diff --git a/platform/pages/integrations/recall/[recallIntegrationId]/_index.installDetails.utest.js b/platform/pages/integrations/recall/[recallIntegrationId]/_index.installDetails.utest.js new file mode 100644 index 0000000..62ab576 --- /dev/null +++ b/platform/pages/integrations/recall/[recallIntegrationId]/_index.installDetails.utest.js @@ -0,0 +1,33 @@ +import { getInstallDetails } from './index' + +jest.mock('@/prisma/client', () => ({})) +jest.mock('@/lib/host', () => ({ + getExternalAPIHostURL: jest.fn((path) => `https://build.example${path}`), +})) +jest.mock('@/lib/session.get', () => ({ getSoftSession: jest.fn() })) + +describe('recall getInstallDetails', () => { + it('builds the webhook with the runtime builder the page passes', () => { + const getAPIURL = jest.fn( + (path) => `http://cbk-api.localhost:3000${path}` + ) + + const { endpoints } = getInstallDetails({ + integration: { id: 'recall_1' }, + getAPIURL, + }) + + // @note the build-time builder knows nothing of the request host + expect(endpoints[0].url).toBe( + 'http://cbk-api.localhost:3000/v1/integration/recall/recall_1/webhook' + ) + }) + + it('serves requestless callers from the build-time builder', () => { + const { endpoints } = getInstallDetails({ integration: { id: 'recall_1' } }) + + expect(endpoints[0].url).toBe( + 'https://build.example/v1/integration/recall/recall_1/webhook' + ) + }) +}) diff --git a/platform/pages/integrations/recall/[recallIntegrationId]/index.jsx b/platform/pages/integrations/recall/[recallIntegrationId]/index.jsx index 049ff70..387f7e2 100644 --- a/platform/pages/integrations/recall/[recallIntegrationId]/index.jsx +++ b/platform/pages/integrations/recall/[recallIntegrationId]/index.jsx @@ -27,18 +27,21 @@ import RevealToken from '@/components/RevealToken' import ThisSolution from '@/components/ThisSolution' import WebhookSetupSection from '@/components/WebhookSetupSection' +import useExternalAPIURL from '@/hooks/useExternalAPIURL' import useFetch from '@/hooks/useFetch' import useRouter from '@/hooks/useRouter' import useScopedCreateData from '@/hooks/useScopedCreateData' -export function getInstallDetails({ integration }) { +export function getInstallDetails({ + integration, + // @note pages pass the runtime builder; the default serves requestless callers + getAPIURL = getExternalAPIHostURL, +}) { return { endpoints: [ { label: 'Bot Status Webhook', - url: getExternalAPIHostURL( - `/v1/integration/recall/${integration.id}/webhook` - ), + url: getAPIURL(`/v1/integration/recall/${integration.id}/webhook`), description: 'Use this URL as the Bot Status Change webhook in your Recall workspace dashboard. ChatBotKit listens for the bot.call_ended event and finalises the meeting conversation.', required: true, @@ -396,8 +399,9 @@ export function Initiate({ integration }) { } export default function Index({ integration }) { - const installDetails = getInstallDetails({ integration }) - const installPopupDetails = getInstallPopupDetails({ integration }) + const getAPIURL = useExternalAPIURL() + + const installDetails = getInstallDetails({ integration, getAPIURL }) return ( @@ -406,7 +410,7 @@ export default function Index({ integration }) {
diff --git a/platform/pages/integrations/twilio/[twilioIntegrationId]/_index.installDetails.utest.js b/platform/pages/integrations/twilio/[twilioIntegrationId]/_index.installDetails.utest.js new file mode 100644 index 0000000..2802f40 --- /dev/null +++ b/platform/pages/integrations/twilio/[twilioIntegrationId]/_index.installDetails.utest.js @@ -0,0 +1,38 @@ +import { getInstallDetails } from './index' + +jest.mock('@/prisma/client', () => ({})) +jest.mock('@/lib/host', () => ({ + getExternalAPIHostURL: jest.fn((path) => `https://build.example${path}`), +})) +jest.mock('@/lib/session.get', () => ({ getSoftSession: jest.fn() })) +jest.mock('@/lib/twilio.webhook', () => ({ + getTwilioIntegrationWebhook: jest.fn((id, host, getAPIURL) => + getAPIURL(`/v1/${id}`) + ), +})) +jest.mock('@/hooks/useExternalAPIURL', () => jest.fn()) + +describe('twilio getInstallDetails', () => { + it('builds the webhooks with the runtime URL builder the page passes', () => { + const { sections } = getInstallDetails({ + integration: { id: 'twilio_1' }, + getAPIURL: (path) => `http://cbk.localhost:3000/api${path}`, + }) + + // @note the build-time builder knows nothing of the request host + expect(sections.Messaging.endpoints[0].url).toBe( + 'http://cbk.localhost:3000/api/v1/twilio_1' + ) + expect(sections.Calls.endpoints[0].url).toBe( + 'http://cbk.localhost:3000/api/v1/twilio_1' + ) + }) + + it('serves requestless callers from the configured builder', () => { + const { sections } = getInstallDetails({ integration: { id: 'twilio_1' } }) + + expect(sections.Messaging.endpoints[0].url).toBe( + 'https://build.example/v1/twilio_1' + ) + }) +}) diff --git a/platform/pages/integrations/twilio/[twilioIntegrationId]/_index.runtime.utest.js b/platform/pages/integrations/twilio/[twilioIntegrationId]/_index.runtime.utest.js new file mode 100644 index 0000000..ad8a3ec --- /dev/null +++ b/platform/pages/integrations/twilio/[twilioIntegrationId]/_index.runtime.utest.js @@ -0,0 +1,39 @@ +import Recall from '@/pages/integrations/recall/[recallIntegrationId]/index' +import Twilio from '@/pages/integrations/twilio/[twilioIntegrationId]/index' + +jest.mock('@/prisma/client', () => ({})) +jest.mock('@/lib/session.get', () => ({ getSoftSession: jest.fn() })) +jest.mock('@/hooks/useHost', () => ({ useAPIHost: () => 'brand.example' })) +jest.mock('@/hooks/useExternalAPIURL', () => ({ + __esModule: true, + default: () => (path) => `https://brand.example/api${path}`, +})) + +function collectProps(element, matches = []) { + if (Array.isArray(element)) { + element.forEach((child) => collectProps(child, matches)) + } else if (element?.props) { + matches.push(element.props) + collectProps(element.props.children, matches) + } + + return matches +} + +describe('integration installation URLs', () => { + it.each([ + ['Recall', Recall], + ['Twilio', Twilio], + ])('%s uses the runtime URL in both the page and install popup', (_name, Page) => { + const props = collectProps(Page({ integration: { id: 'demo' } })) + const popup = props.find((props) => props.installDetails).installDetails + const endpoint = popup.endpoints?.[0] || popup.sections.Messaging.endpoints[0] + + expect(new URL(endpoint.url).origin).toBe('https://brand.example') + expect(new URL(endpoint.url).pathname).toMatch(/^\/api\/v1\/integration\//) + expect(props.some((props) => + props.endpoints === popup.endpoints && !!props.endpoints || + props.sections === popup.sections && !!props.sections + )).toBe(true) + }) +}) diff --git a/platform/pages/integrations/twilio/[twilioIntegrationId]/index.jsx b/platform/pages/integrations/twilio/[twilioIntegrationId]/index.jsx index c463f2d..7606131 100644 --- a/platform/pages/integrations/twilio/[twilioIntegrationId]/index.jsx +++ b/platform/pages/integrations/twilio/[twilioIntegrationId]/index.jsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react' -import { getExternalAPIHost } from '@/lib/host' +import { getExternalAPIHostURL } from '@/lib/host' + +import useExternalAPIURL from '@/hooks/useExternalAPIURL' import { ONE_DAY_IN_MILLISECONDS } from '@chatbotkit-dev/time' @@ -45,7 +47,11 @@ export const VISIBLE_EVENT_TYPES = [ // @note whitelist only specific events if applicable ] -export function getInstallDetails({ integration }) { +export function getInstallDetails({ + integration, + // @note pages pass the runtime builder; the default serves requestless callers + getAPIURL = getExternalAPIHostURL, +}) { return { sections: { Messaging: { @@ -55,7 +61,8 @@ export function getInstallDetails({ integration }) { label: 'Messaging Webhook', url: getTwilioIntegrationWebhook( integration.id, - getExternalAPIHost() + undefined, + getAPIURL ), description: 'Use this URL as the webhook for incoming messages in the Messaging configuration of your Twilio phone number.', @@ -79,7 +86,8 @@ export function getInstallDetails({ integration }) { label: 'Call Webhook', url: getTwilioIntegrationWebhook( integration.id, - getExternalAPIHost() + undefined, + getAPIURL ), description: 'Use this URL as the webhook for incoming calls in the Voice configuration of your Twilio phone number.', @@ -738,8 +746,9 @@ export function Initiate({ integration }) { } export default function Index({ integration }) { - const installDetails = getInstallDetails({ integration }) - const installPopupDetails = getInstallPopupDetails({ integration }) + const getAPIURL = useExternalAPIURL() + + const installDetails = getInstallDetails({ integration, getAPIURL }) return ( <> @@ -766,7 +775,7 @@ export default function Index({ integration }) {
diff --git a/platform/pages/integrations/widget/[widgetIntegrationId]/_index.origin.utest.js b/platform/pages/integrations/widget/[widgetIntegrationId]/_index.origin.utest.js new file mode 100644 index 0000000..4942ed4 --- /dev/null +++ b/platform/pages/integrations/widget/[widgetIntegrationId]/_index.origin.utest.js @@ -0,0 +1,27 @@ +import { getInstallCode } from '@/pages/integrations/widget/[widgetIntegrationId]/index' + +jest.mock('@/prisma/client', () => ({})) +jest.mock('@/lib/session.get', () => ({ getSoftSession: jest.fn() })) +jest.mock('@/config/site', () => ({ + siteUrl: 'http://console.example', + siteHost: 'console.example', + siteHostname: 'console.example', + staticUrl: 'http://assets.example:8080', + staticHost: 'assets.example:8080', + staticHostname: 'assets.example', + apiUrl: 'http://console.example', + apiHost: 'console.example', +})) + +describe('widget install origin', () => { + it('preserves the configured static scheme and port', () => { + const container = document.createElement('div') + + container.innerHTML = getInstallCode({ id: 'demo' }, 'assets.example:8080') + + const script = container.querySelector('script') + + expect(script.src).toBe('http://assets.example:8080/integrations/widget/v2.js') + expect(script.dataset.widget).toBe('demo') + }) +}) diff --git a/platform/pages/integrations/widget/[widgetIntegrationId]/index.jsx b/platform/pages/integrations/widget/[widgetIntegrationId]/index.jsx index cfd3c97..c0f04aa 100644 --- a/platform/pages/integrations/widget/[widgetIntegrationId]/index.jsx +++ b/platform/pages/integrations/widget/[widgetIntegrationId]/index.jsx @@ -10,7 +10,7 @@ import { import { getExamplesWithExportedThemes } from '@/lib/example.fetch' import { formToData } from '@/lib/form' -import { getExternalHostURL } from '@/lib/host' +import { getExternalStaticHostURL } from '@/lib/host' import { typeToFileName } from '@/lib/mime' import { getSoftSession } from '@/lib/session.get' import { withWidgetIntegrationResources } from '@/lib/solution' @@ -47,7 +47,7 @@ import WidgetPluginsSelect from '@/components/WidgetPluginsSelect' import useControlledState from '@/hooks/useControlledState' import useDropzone from '@/hooks/useDropzone' import useFetch from '@/hooks/useFetch' -import { useStaticHostname } from '@/hooks/useHostname' +import { useStaticHost } from '@/hooks/useHost' import usePopup from '@/hooks/usePopup' import useRouter from '@/hooks/useRouter' import useScopedCreateData from '@/hooks/useScopedCreateData' @@ -73,17 +73,17 @@ function getAttributes(options) { return div.outerHTML.replace(/^<\/div>$/g, '').trim() } -export function getInstallCode(integration, staticHostname) { - return `` } export function Install({ integration }) { - const staticHostname = useStaticHostname() + const staticHost = useStaticHost() - const code = getInstallCode(integration, staticHostname) + const code = getInstallCode(integration, staticHost) return ( <> diff --git a/platform/pages/playground/api/_index.utest.js b/platform/pages/playground/api/_index.utest.js new file mode 100644 index 0000000..3114bfd --- /dev/null +++ b/platform/pages/playground/api/_index.utest.js @@ -0,0 +1,66 @@ +/** + * @jest-environment @chatbotkit-dev/jest-jsdom + * @jest-environment-options {"url": "http://console.localhost:3000/playground/api"} + */ + +import { act, renderHook } from '@testing-library/react' + +import useFetch from '@/hooks/useFetch' +import Index from '@/pages/playground/api' + +jest.mock('@/hooks/useFetch', () => ({ + __esModule: true, + default: jest.fn(), +})) + +function findProps(element, predicate) { + if (Array.isArray(element)) { + return element.map((child) => findProps(child, predicate)).find(Boolean) + } + + if (!element?.props) { + return undefined + } + + return predicate(element.props) + ? element.props + : findProps(element.props.children, predicate) +} + +describe('API playground request origin', () => { + const fetch = jest.fn().mockResolvedValue({}) + + beforeEach(() => { + jest.clearAllMocks() + useFetch.mockReturnValue({ fetch, loading: false }) + }) + + it.each([ + '/v1/conversation/list?limit=2', + 'https://api.example/v1/conversation/list?limit=2', + 'https://api.example:8443/v1/conversation/list?limit=2', + 'http://api.localhost:8080/api/v1/conversation/list?limit=2', + ])('sends %s through the page origin and session', async (uri) => { + const { result } = renderHook(() => Index()) + + act(() => { + findProps(result.current, (props) => props.onChange && props.onKeyDown) + .onChange({ target: { value: `GET ${uri} HTTP/1.1` } }) + }) + + await act(async () => { + await findProps(result.current, (props) => props.onChange && props.onKeyDown) + .onKeyDown({ + ctrlKey: true, + keyCode: 13, + preventDefault() {}, + stopPropagation() {}, + }) + }) + + expect(fetch).toHaveBeenCalledWith( + 'http://console.localhost:3000/api/v1/conversation/list?limit=2', + expect.objectContaining({ method: 'GET', body: undefined }) + ) + }) +}) diff --git a/platform/pages/playground/api/index.jsx b/platform/pages/playground/api/index.jsx index b13c6a6..47bd860 100644 --- a/platform/pages/playground/api/index.jsx +++ b/platform/pages/playground/api/index.jsx @@ -352,6 +352,7 @@ export default function Index() { url.protocol = window.location.protocol url.hostname = window.location.hostname + url.port = window.location.port if (!url.pathname.startsWith('/api')) { url.pathname = `/api${url.pathname}` diff --git a/platform/pages/portals/[portalId]/index.jsx b/platform/pages/portals/[portalId]/index.jsx index a394d1c..7dd55f0 100644 --- a/platform/pages/portals/[portalId]/index.jsx +++ b/platform/pages/portals/[portalId]/index.jsx @@ -23,7 +23,7 @@ import PortalConfigInput from '@/components/PortalConfigInput' import ThisSolution from '@/components/ThisSolution' import useFetch from '@/hooks/useFetch' -import { usePortalApex } from '@/hooks/useHostname' +import { usePortalApex } from '@/hooks/useHost' import useRouter from '@/hooks/useRouter' import useScopedCreateData from '@/hooks/useScopedCreateData' diff --git a/platform/pages/signin/_index.utest.js b/platform/pages/signin/_index.utest.js new file mode 100644 index 0000000..2b9a50f --- /dev/null +++ b/platform/pages/signin/_index.utest.js @@ -0,0 +1,57 @@ +import Signin, { getServerSideProps } from '@/pages/signin' + +jest.mock('@/config/origins', () => ({ + appMainHost: 'main.revalidation.localhost:4300', + appLabsHost: 'labs.revalidation.localhost:4300', +})) + +jest.mock('@/config/hosts', () => ({ + ...jest.requireActual('@/config/hosts'), + hostsConfig: { + shell: { + match: ['main.revalidation.localhost:4400'], + site: 'console.revalidation.localhost:4400', + api: 'console.revalidation.localhost:4400', + static: 'console.revalidation.localhost:4400', + widgets: 'console.revalidation.localhost:4400', + }, + }, +})) + +jest.mock('@/lib/auth.providers', () => ({ + providers: [{ id: 'email' }, { id: 'email', options: { id: 'trusted' } }], +})) + +function authProps(props) { + return Signin(props).props.children[1].props.children.props +} + +describe('sign-in handoff', () => { + it.each([ + 'main.revalidation.localhost:4300', + 'labs.revalidation.localhost:4300', + 'main.revalidation.localhost:4400', + ])('keeps %s out of platform-only onboarding routes', async (host) => { + const { props } = await getServerSideProps({ + req: { headers: { host }, url: '/signin' }, + res: {}, + query: {}, + }) + + expect(authProps(props).intermediateURL).toBeNull() + expect(props.providers).toEqual(['email', 'trusted']) + }) + + it('retains onboarding on a platform hostname', async () => { + const { props } = await getServerSideProps({ + req: { + headers: { host: 'console.revalidation.localhost:4300' }, + url: '/signin', + }, + res: {}, + query: {}, + }) + + expect(authProps(props).intermediateURL).toBe('/welcome') + }) +}) diff --git a/platform/pages/signin/index.jsx b/platform/pages/signin/index.jsx index 1d02537..86fe3f0 100644 --- a/platform/pages/signin/index.jsx +++ b/platform/pages/signin/index.jsx @@ -1,4 +1,12 @@ +import { isAppHostname } from '@/lib/app.helpers' import { providers as authProviders } from '@/lib/auth.providers' +import { setupRequestContext } from '@/lib/context.setup' +import { + executeInContext, + getContextFrontendHost, + getContextRequestHost, +} from '@/lib/context.store' +import { hostToHostname } from '@/lib/host.parse' import { makeJsonSafe } from '@/lib/struct' import Auth from '@/components/Auth' @@ -34,16 +42,28 @@ export default function Signin({ // @note force server-side rendering so _document.getInitialProps receives the // real request with host headers, which is needed to set the data-audience // attribute on the html element correctly for each host -export async function getServerSideProps() { - return { - props: makeJsonSafe({ - // @note only the providers that are actually configured - the OAuth - // providers are presence-gated on their credentials in - // lib/auth.providers.ts, so a local or self-hosted deployment without - // them must not render their sign-in buttons - // @note a provider built with a custom id keeps it under `options` - // until NextAuth merges it at request time, as the trusted provider does - providers: authProviders.map(({ id, options }) => options?.id ?? id), - }), - } +export async function getServerSideProps({ req }) { + return executeInContext(async () => { + setupRequestContext(req) + + const isAppHost = [ + getContextFrontendHost(), + getContextRequestHost(), + ].some((host) => isAppHostname(hostToHostname(host))) + + return { + props: makeJsonSafe({ + // @note only the providers that are actually configured - the OAuth + // providers are presence-gated on their credentials in + // lib/auth.providers.ts, so a local or self-hosted deployment without + // them must not render their sign-in buttons + // @note a provider built with a custom id keeps it under `options` + // until NextAuth merges it at request time, as the trusted provider does + providers: authProviders.map(({ id, options }) => options?.id ?? id), + // @note app hosts do not serve platform onboarding or overview routes; + // return to the requested app path or its root after authentication + intermediateURL: isAppHost ? null : '/welcome', + }), + } + }) } diff --git a/platform/pages/widgets/preview/[...url].jsx b/platform/pages/widgets/preview/[...url].jsx index 11fda7b..dc146fe 100644 --- a/platform/pages/widgets/preview/[...url].jsx +++ b/platform/pages/widgets/preview/[...url].jsx @@ -6,7 +6,8 @@ import Head from 'next/head' import { assertUnreachable } from '@chatbotkit-dev/typescript-utils/unreachable' -import { siteHostname } from '@/config/site' +import { siteHost } from '@/config/site' +import { getExternalFrontendHostURL } from '@/lib/host' import demos from '@/data/demos.yaml' @@ -846,12 +847,12 @@ export async function getServerSideProps(context) { } const frontendHost = - getContextFrontendHost() || getContextRequestHost() || siteHostname + getContextFrontendHost() || getContextRequestHost() || siteHost if (capture) { const url = new URL( context.resolvedUrl.replace('/capture/', '/'), - `https://${frontendHost}` + getExternalFrontendHostURL('/', frontendHost) ) url.searchParams.set('layout', 'fullscreen') @@ -872,7 +873,7 @@ export async function getServerSideProps(context) { if (screenshot) { const url = new URL( context.resolvedUrl.replace('/screenshot/', '/'), - `https://${frontendHost}` + getExternalFrontendHostURL('/', frontendHost) ) url.searchParams.set('layout', 'fullscreen') @@ -893,7 +894,7 @@ export async function getServerSideProps(context) { if (card) { const url = new URL( context.resolvedUrl.replace('/card/', '/'), - `https://${frontendHost}` + getExternalFrontendHostURL('/', frontendHost) ) url.searchParams.set('layout', 'default') diff --git a/platform/pages/widgets/preview/_[...url].utest.js b/platform/pages/widgets/preview/_[...url].utest.js new file mode 100644 index 0000000..365332c --- /dev/null +++ b/platform/pages/widgets/preview/_[...url].utest.js @@ -0,0 +1,67 @@ +/** + * @jest-environment node + */ +import { makeScreenshot } from '@/lib/webshot' + +import { getServerSideProps } from './[...url]' + +jest.mock('@/lib/webshot', () => ({ + makeScreenshot: jest.fn((url) => `shot:${url}`), + makeScreenshotRequest: jest.fn(), +})) +jest.mock('@/lib/cdn', () => ({ + CACHE_PRESETS: {}, + applyCacheHeaders: jest.fn(), +})) +jest.mock('@/lib/fetch', () => jest.fn()) +jest.mock('@/lib/save', () => ({ saveBlob: jest.fn() })) +jest.mock('@/lib/dataurl.fetch', () => ({ fetchDataUrl: jest.fn() })) +jest.mock('@/lib/dataurl.response', () => ({ responseToDataUrl: jest.fn() })) +jest.mock('@/pages/api/v1/url/unfurl', () => ({ unfurlPage: jest.fn() })) +jest.mock('@/components/DotsLoader', () => () => null) +jest.mock('@/components/Meta', () => () => null) +jest.mock('@/components/Toggle', () => () => null) +jest.mock('@/components/WidgetPreview', () => () => null) +jest.mock('@/hooks/useEntryAnimation', () => jest.fn()) +jest.mock('@/hooks/useFetch', () => jest.fn()) +jest.mock('@/hooks/useImageColorPalette', () => jest.fn()) +jest.mock('@/hooks/usePopup', () => jest.fn()) +jest.mock('@/hooks/useRouter', () => jest.fn()) +jest.mock('@/hooks/useSession', () => jest.fn()) + +function context(host, mode) { + return { + query: { url: [mode, 'example.com'] }, + resolvedUrl: `/widgets/preview/${mode}/example.com`, + req: { + method: 'GET', + url: `/widgets/preview/${mode}/example.com`, + query: {}, + headers: { host }, + }, + res: { setHeader: jest.fn() }, + } +} + +describe('widget preview capture routes', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it.each(['capture', 'screenshot', 'card'])( + 'shoots the %s page on the origin the deployment serves', + async (mode) => { + const result = await getServerSideProps(context('cbk.localhost:3000', mode)) + + // @note a hard-coded https origin would point the screenshot service + // at a scheme the local stack does not serve + expect(makeScreenshot).toHaveBeenCalledWith( + expect.stringMatching( + /^http:\/\/cbk\.localhost:3000\/widgets\/preview\/example\.com\?layout=/ + ), + expect.any(Object) + ) + expect(result.redirect.destination).toMatch(/^shot:http:\/\/cbk\.localhost:3000\//) + } + ) +}) diff --git a/platform/pages/widgets/preview/_index.utest.js b/platform/pages/widgets/preview/_index.utest.js new file mode 100644 index 0000000..2e0b40a --- /dev/null +++ b/platform/pages/widgets/preview/_index.utest.js @@ -0,0 +1,55 @@ +import { PreviewInput } from './index' + +import '@testing-library/jest-dom' +import { fireEvent, render, screen } from '@testing-library/react' + +const push = jest.fn() + +jest.mock('@/components/Hero', () => () => null) +jest.mock('@/components/Meta', () => () => null) + +jest.mock('@/hooks/useRouter', () => jest.fn(() => ({ push }))) +jest.mock('@/hooks/useTextAnimation', () => jest.fn(() => '')) + +// @note jsdom does not expose form controls as named form properties, which +// the handler reads the value through +function submit(input) { + const form = input.closest('form') + + Object.defineProperty(form, 'heroInput', { value: input }) + + fireEvent.submit(form) +} + +describe('PreviewInput', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('keeps the port of an absolute url in the preview location', () => { + render() + + const input = screen.getByRole('textbox') + + fireEvent.change(input, { + target: { value: 'https://example.com:8443/pricing' }, + }) + submit(input) + + // @note the hostname alone would preview a different origin + expect(push).toHaveBeenCalledWith( + '/widgets/preview/example.com:8443/pricing' + ) + }) + + it('passes a bare location through', () => { + render() + + const input = screen.getByRole('textbox') + + fireEvent.change(input, { target: { value: 'notion.so' } }) + submit(input) + + expect(push).toHaveBeenCalledWith('/widgets/preview/notion.so') + }) +}) diff --git a/platform/pages/widgets/preview/index.jsx b/platform/pages/widgets/preview/index.jsx index 49becfa..6b979d9 100644 --- a/platform/pages/widgets/preview/index.jsx +++ b/platform/pages/widgets/preview/index.jsx @@ -55,7 +55,7 @@ export function PreviewInput({ className, ...props }) { if (input.startsWith('http')) { const url = new URL(input) - location = url.hostname + url.pathname + location = url.host + url.pathname } else { location = input } diff --git a/platform/proxy.ts b/platform/proxy.ts index 784a06d..c4d26fd 100644 --- a/platform/proxy.ts +++ b/platform/proxy.ts @@ -5,20 +5,31 @@ import partnersConfig from '@chatbotkit-dev/partners' import { partnersApex } from '@/config/apexes' import { apps } from '@/config/apps' +import { HOST_COOKIE_NAME } from '@/config/cookie' import { hosts } from '@/config/hosts' -import { apiHostname, siteHostname, staticHostname } from '@/config/site' +import { + apiHostname, + siteHostname, + siteUrl, + staticHostname, +} from '@/config/site' import { getPortalSlugFromHostname } from '@/lib/portal.hostname' import { getSecurityHeaders } from '@/lib/security.headers' import { getSpaceSiteSlug } from '@/lib/space.site' +import { hostToHostname, normalizeRequestHost } from '@/lib/host.parse' import { apiCorsHeaders } from '@/next.config.d/api.config' // @note host routing ignores ports, matching Next's former host conditions +// @note whether forwarded headers are trusted is deployment configuration, +// read once at startup like the host tables +const trustProxyHeaders = process.env.TRUST_PROXY_HEADERS === 'true' + const appsByHostname = new Map( apps.flatMap((app) => - app.host ? [[app.host.split(':')[0].toLowerCase(), app.slug]] : [] + app.host ? [[hostToHostname(app.host), app.slug]] : [] ) ) @@ -32,13 +43,21 @@ const partnerSuffix = partnersApex ? `.${partnersApex.toLowerCase()}` : undefined -// @note API and static targets that also serve the site must not capture its pages -const siteHostnames = new Set([siteHostname, ...hosts.site]) +// @note API and static targets that also serve the site must not capture its +// pages; the configured targets are hosts, routing compares hostnames +const siteHostnames = new Set([ + siteHostname, + ...hosts.site.map(hostToHostname), +]) const apiHostnames = new Set( - [...hosts.api, apiHostname].filter((host) => !siteHostnames.has(host)) + [...hosts.api.map(hostToHostname), apiHostname].filter( + (hostname) => !siteHostnames.has(hostname) + ) ) const staticHostnames = new Set( - [...hosts.static, staticHostname].filter((host) => !siteHostnames.has(host)) + [...hosts.static.map(hostToHostname), staticHostname].filter( + (hostname) => !siteHostnames.has(hostname) + ) ) /** @@ -49,7 +68,8 @@ export function proxy(request: NextRequest): NextResponse { // @note match the actual Host header, as Next's host rewrites did; forwarded // and internal assertion headers remain subject to request-context validation - const hostname = request.headers.get('host')?.split(':')[0].toLowerCase() + const host = request.headers.get('host')?.toLowerCase() + const hostname = host ? hostToHostname(host) : undefined const appSlug = hostname ? appsByHostname.get(hostname) : undefined const isApiHost = !!hostname && apiHostnames.has(hostname) @@ -224,6 +244,25 @@ export function proxy(request: NextRequest): NextResponse { } } + // @note the browser reads the request host back from this cookie; it + // records the same host the request context trusts - the forwarded host + // behind a trusted proxy, the Host header otherwise - so the two never + // disagree. `Secure` only on a TLS site, or plain-http deployments never + // receive it. Written raw: the cookie API percent-encodes the port separator + const cookieHost = + (trustProxyHeaders + ? normalizeRequestHost(request.headers.get('x-forwarded-host')) + : null) || normalizeRequestHost(host) + + if (cookieHost) { + response.headers.append( + 'set-cookie', + `${HOST_COOKIE_NAME}=${cookieHost}; Path=/; SameSite=Lax${ + siteUrl.startsWith('https://') ? '; Secure' : '' + }` + ) + } + if (partnerSlug && Object.hasOwn(partnersConfig, partnerSlug)) { const partner = partnersConfig[partnerSlug] // @note expose only public branding, never the partner's auth or transport diff --git a/platform/proxy.utest.js b/platform/proxy.utest.js index 9fecaea..0b73aa8 100644 --- a/platform/proxy.utest.js +++ b/platform/proxy.utest.js @@ -54,6 +54,54 @@ async function loadProxy(apex, portalApex = '', appConfiguration = {}) { } } +describe('host cookie', () => { + it.each([ + ['http://cbk.localhost:3000', false], + ['https://cbk.example', true], + ])('records the request host, secure only on a TLS site (%s)', async (siteUrl, secure) => { + const proxy = await loadProxy('space.localhost', '', { SITE_URL: siteUrl }) + const request = new NextRequest('http://localhost:3000/', { + headers: { host: 'CBK-Labs.localhost:3000' }, + }) + const response = proxy(request) + const header = response.headers.get('set-cookie') + + // @note the cookie carries the host - port included, unencoded - lower-cased + expect(header).toContain('chatbotkit.host=cbk-labs.localhost:3000; Path=/') + expect(header).toContain('SameSite=Lax') + expect(header.includes('Secure')).toBe(secure) + }) + + it.each([ + ['true', 'public.example'], + [undefined, 'upstream:3000'], + ])( + 'records the forwarded host only behind a trusted proxy (TRUST_PROXY_HEADERS=%s)', + async (trust, expected) => { + const proxy = await loadProxy('space.localhost', '', { + SITE_URL: 'https://public.example', + TRUST_PROXY_HEADERS: trust, + }) + const response = proxy( + new NextRequest('http://localhost:3000/', { + headers: { host: 'upstream:3000', 'x-forwarded-host': 'public.example' }, + }) + ) + + expect(response.headers.get('set-cookie')).toContain( + `chatbotkit.host=${expected};` + ) + } + ) + + it('sets no cookie without a Host header', async () => { + const proxy = await loadProxy('space.localhost') + const response = proxy(new NextRequest('http://localhost:3000/')) + + expect(response.headers.get('set-cookie')).toBeNull() + }) +}) + describe('runtime space host routing', () => { it('classifies a space host without rewriting its URL', async () => { const proxy = await loadProxy('space.localhost') @@ -173,6 +221,31 @@ describe('runtime space host routing', () => { }) describe('runtime portal host selection', () => { + it('does not turn a service-host mapping into a custom-domain portal mapping', async () => { + const proxy = await loadProxy('space.localhost', 'portal.localhost', { + HOSTS_CONFIG: JSON.stringify({ + customer: { + match: ['customer.partner.example'], + site: 'customer-partner-example.portal.localhost', + api: 'api.platform.example', + static: 'static.platform.example', + widgets: 'widgets.platform.example', + }, + }), + }) + + const response = proxy( + new NextRequest('https://customer.partner.example/', { + headers: { host: 'customer.partner.example' }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-portal')).toBeNull() + expect(response.headers.get('x-middleware-request-host')).toBe( + 'customer.partner.example' + ) + }) + it.each([ 'test.portal.localhost:3000', 'TEST.PORTAL.LOCALHOST:3000', @@ -272,6 +345,22 @@ describe('runtime app host routing', () => { ]), } + it('classifies an IPv6 literal shell origin', async () => { + const proxy = await loadProxy('space.localhost', 'portal.localhost', { + ...appConfiguration, + APP_MAIN_ORIGIN: 'http://[::1]:3000', + }) + const response = proxy( + new NextRequest('http://localhost:3000/', { + headers: { host: '[::1]:3000' }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-app-shell')).toBe( + '1' + ) + }) + it.each([ ['apps.localhost:3000', '1', null], ['LABS.LOCALHOST:3000', '1', null], @@ -953,3 +1042,50 @@ describe('runtime browser security host selection', () => { ) }) }) + +describe('runtime host routing with configured targets that carry a port', () => { + const configuration = { + SITE_URL: 'http://cbk.localhost:3000', + API_URL: '', + STATIC_URL: '', + HOSTS_CONFIG: JSON.stringify({ + local: { + match: ['cbk.localhost:3000'], + site: 'cbk.localhost:3000', + api: 'cbk-api.localhost:3000', + static: 'cbk-static.localhost:3000', + widgets: 'cbk-widgets.localhost:3000', + }, + }), + } + + it.each([ + ['cbk-api.localhost:3000', 'x-cbk-api'], + ['cbk-api.localhost', 'x-cbk-api'], + ['cbk-static.localhost:3000', 'x-cbk-static'], + ['CBK-STATIC.localhost:8080', 'x-cbk-static'], + ])('routes %s to its mapped target by hostname', async (host, marker) => { + // @note the mapping names its targets as hosts; routing compares + // hostnames, so the request port does not matter + const proxy = await loadProxy('', '', configuration) + const response = proxy( + new NextRequest('http://internal:3000/v1/probe', { headers: { host } }) + ) + + expect(response.headers.get(`x-middleware-request-${marker}`)).toBe('1') + }) + + it('leaves the ported site host unrestricted', async () => { + const proxy = await loadProxy('', '', configuration) + const response = proxy( + new NextRequest('http://internal:3000/v1/probe', { + headers: { host: 'cbk.localhost:3000' }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-api')).toBeNull() + expect( + response.headers.get('x-middleware-request-x-cbk-static') + ).toBeNull() + }) +}) diff --git a/platform/scripts/run-proxy.js b/platform/scripts/run-proxy.js index 92eedd9..84ff3cc 100644 --- a/platform/scripts/run-proxy.js +++ b/platform/scripts/run-proxy.js @@ -5,6 +5,7 @@ import { log, runScript } from '@/lib/script' import http from 'http' import httpProxy from 'http-proxy' +import { isIP } from 'node:net' /** * Run a local reverse proxy with a configured host. @@ -25,7 +26,7 @@ runScript({ type: 'string', short: 'h', description: - 'Host header and bind host (default: PROXY_HOST or localhost)', + 'Host the app records for requests (default: PROXY_HOST or localhost)', required: false, }, port: { @@ -56,20 +57,38 @@ runScript({ const resolvedPort = Number(port || process.env.PROXY_PORT || 9090) + // @note the browser reaches the proxy on its port, so that is the host + // the app must record - a bare hostname would have it build links to + // the default port; the default ports themselves stay implicit + const withProxyPort = (host) => { + const normalizedHost = isIP(host) === 6 ? `[${host}]` : host + + return /:\d+$/.test(normalizedHost) || [80, 443].includes(resolvedPort) + ? normalizedHost + : `${normalizedHost}:${resolvedPort}` + } + + const forwardedHost = withProxyPort(resolvedHost) + + // @note the asserted frontend host is reached on the same proxy port, so + // it carries the port too unless it already names one + const forwardedFrontendHost = + resolvedFrontendHost && withProxyPort(resolvedFrontendHost) + const resolvedTarget = target || process.env.PROXY_TARGET || 'http://localhost:8080' - log(`using host ${resolvedHost}`) + log(`using host ${forwardedHost}`) log(`running proxy on port ${resolvedPort}`) log(`proxying to target ${resolvedTarget}`) - if (resolvedFrontendHost) { - log(`asserting frontend host ${resolvedFrontendHost}`) + if (forwardedFrontendHost) { + log(`asserting frontend host ${forwardedFrontendHost}`) } const assertionHeaders = getInternalAssertionHeaders({ - frontendHost: resolvedFrontendHost, + frontendHost: forwardedFrontendHost, }) const proxy = httpProxy.createProxyServer({ @@ -82,8 +101,8 @@ runScript({ // proxy headers, so the proxy claims the header rather than passing it on const setProxyHeaders = (proxyReq) => { - proxyReq.setHeader('Host', resolvedHost) - proxyReq.setHeader('x-forwarded-host', resolvedHost) + proxyReq.setHeader('Host', forwardedHost) + proxyReq.setHeader('x-forwarded-host', forwardedHost) for (const [name, value] of Object.entries(assertionHeaders)) { proxyReq.setHeader(name, value) @@ -98,6 +117,61 @@ runScript({ setProxyHeaders(proxyReq) }) + // @note host selects the simulated partner or app upstream; relative + // redirects back to that authority retain the browser's origin even when + // a tunnel rewrites the incoming host, scheme or port + proxy.on('proxyRes', (proxyRes) => { + const rewriteRedirect = (location) => { + if (!/^(https?:)?\/\//i.test(location)) { + return location + } + + try { + const url = new URL(location, `http://${forwardedHost}`) + + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.host !== new URL(`${url.protocol}//${forwardedHost}`).host + ) { + return location + } + + // @note a leading double slash would become another authority when + // resolved by the browser; a dot segment keeps it in the URL path + const pathname = url.pathname.startsWith('//') + ? `/.${url.pathname}` + : url.pathname + + return `${pathname}${url.search}${url.hash}` + } catch { + return location + } + } + + for (const name of ['location', 'x-nextjs-redirect']) { + if (proxyRes.headers[name]) { + proxyRes.headers[name] = rewriteRedirect(proxyRes.headers[name]) + } + } + + if (proxyRes.headers['x-action-redirect']) { + proxyRes.headers['x-action-redirect'] = proxyRes.headers[ + 'x-action-redirect' + ].replace(/^(.*);(push|replace)$/, (_, location, mode) => { + return `${rewriteRedirect(location)};${mode}` + }) + } + + if (proxyRes.headers.refresh) { + proxyRes.headers.refresh = proxyRes.headers.refresh.replace( + /^(\s*\d+\s*;\s*url=)(.*)$/i, + (_, prefix, location) => prefix + rewriteRedirect(location) + ) + } + }) + proxy.on('error', (err, _req, res) => { log(`proxy error: ${err.message}`) diff --git a/platform/scripts/run-proxy.redirects.utest.js b/platform/scripts/run-proxy.redirects.utest.js new file mode 100644 index 0000000..526582d --- /dev/null +++ b/platform/scripts/run-proxy.redirects.utest.js @@ -0,0 +1,285 @@ +/** @jest-environment node */ +import { NextRequest } from 'next/server' + +import { runScript } from '@/lib/script' + +import { proxy as routeRequest } from '@/proxy' + +import './run-proxy' + +import http from 'http' +import { once } from 'node:events' + +jest.mock('@/lib/script', () => ({ + runScript: jest.fn(), + log: jest.fn(), +})) + +jest.mock('@chatbotkit-dev/partners', () => ({ + __esModule: true, + default: { + fixture: { name: 'Fixture partner', domain: 'backend.example' }, + }, +})) + +const { handler } = runScript.mock.calls[0][0] + +describe('local proxy redirects over HTTP', () => { + let upstream + let server + let port + + beforeAll(async () => { + upstream = http.createServer((req, res) => { + if (req.url === '/external') { + res.writeHead(302, { + location: 'https://accounts.example/signin?q=one', + }) + } else if (req.url === '/other-port') { + res.writeHead(307, { + location: 'https://backend.example:9000/overview', + }) + } else if (req.url === '/relative') { + res.writeHead(302, { location: '/signin?callbackUrl=%2Foverview' }) + } else if (req.url === '/client-redirect') { + res.writeHead(200, { + 'x-nextjs-redirect': 'https://backend.example:8085/overview?q=one', + }) + } else if (req.url === '/action-redirect') { + res.writeHead(303, { + 'x-action-redirect': + 'https://backend.example:8085/overview?q=one;replace', + }) + } else if (req.url === '/external-action') { + res.writeHead(303, { + 'x-action-redirect': 'https://accounts.example/signin;push', + }) + } else if (req.url === '/network-relative') { + res.writeHead(307, { location: '//backend.example:8085/overview' }) + } else if (req.url === '/double-slash') { + res.writeHead(307, { + location: + 'https://backend.example:8085//external.example/path?q=one#section', + }) + } else { + const response = routeRequest( + new NextRequest(`https://localhost:8080${req.url}`, { + headers: req.headers, + }) + ) + + res.writeHead(response.status, { + ...Object.fromEntries(response.headers), + ...(response.status === 308 + ? { refresh: `0;url=${response.headers.get('location')}` } + : {}), + }) + + if (response.headers.has('x-middleware-next')) { + res.end( + JSON.stringify({ + host: req.headers.host, + forwardedHost: req.headers['x-forwarded-host'], + partner: response.headers.get( + 'x-middleware-request-x-cbk-partner' + ), + }) + ) + + return + } + } + + res.end() + }) + upstream.listen(0, '127.0.0.1') + await once(upstream, 'listening') + + const createServer = jest.spyOn(http, 'createServer') + + try { + await handler({ + host: 'backend.example:8085', + port: '0', + target: `http://127.0.0.1:${upstream.address().port}`, + }) + server = createServer.mock.results[0].value + } finally { + createServer.mockRestore() + } + + if (!server.listening) { + await once(server, 'listening') + } + + port = server.address().port + }) + + afterAll(async () => { + await Promise.all( + [server, upstream] + .filter(Boolean) + .map((socket) => new Promise((resolve) => socket.close(resolve))) + ) + }) + + function request(path = '/', headers = {}, method = 'GET') { + return new Promise((resolve, reject) => { + const req = http.request( + `http://127.0.0.1:${port}${path}`, + { + method, + headers: { host: `localhost:${port}`, ...headers }, + }, + (response) => { + const chunks = [] + + response.on('data', (chunk) => chunks.push(chunk)) + response.on('error', reject) + response.on('end', () => + resolve({ + status: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString(), + }) + ) + } + ) + + req.on('error', reject) + req.end() + }) + } + + it.each([ + ['tunnel.example', 'https', 'https://tunnel.example'], + ['tunnel.example:8443', 'https', 'https://tunnel.example:8443'], + ['[::1]:9090', 'http', 'http://[::1]:9090'], + ['tunnel.example:443', 'http', 'http://tunnel.example:443'], + ['tunnel.example', 'https, http', 'https://tunnel.example'], + ['tunnel.example', 'invalid', 'http://tunnel.example'], + ['localhost:8085', 'https', 'https://tunnel.example'], + ['localhost:8085', 'http', 'https://tunnel.example'], + ])( + 'keeps partner redirects on the public origin %s', + async (host, protocol, origin) => { + const response = await request('/?q=one%20two&q=three', { + host, + 'x-forwarded-proto': protocol, + 'x-forwarded-host': 'untrusted.example', + }) + + expect(response.status).toBe(307) + + const destination = new URL(response.headers.location, origin) + + expect(destination.href).toBe(`${origin}/overview?q=one%20two&q=three`) + + const followed = await request(destination.pathname, { host }) + + expect(JSON.parse(followed.body)).toEqual({ + host: 'backend.example:8085', + forwardedHost: 'backend.example:8085', + partner: 'fixture', + }) + expect(followed.headers['server-timing']).toContain('partner;desc=') + } + ) + + it('keeps direct localhost requests on their actual HTTP port', async () => { + const response = await request() + + expect( + new URL(response.headers.location, `http://localhost:${port}`).href + ).toBe(`http://localhost:${port}/overview`) + }) + + it('preserves POST redirect status and the public origin', async () => { + const response = await request( + '/', + { + host: 'tunnel.example', + 'x-forwarded-proto': 'https', + }, + 'POST' + ) + + expect(response.status).toBe(307) + expect(response.headers.location).toBe('/overview') + }) + + it('translates both Location and Refresh on permanent redirects', async () => { + const response = await request('/overview/?q=a%2Fb', { + host: 'tunnel.example', + 'x-forwarded-proto': 'https', + }) + + expect(response.status).toBe(308) + expect(response.headers.location).toBe('/overview?q=a%2Fb') + expect(response.headers.refresh).toBe(`0;url=${response.headers.location}`) + }) + + it('translates absolute Next page-data redirects', async () => { + const response = await request('/client-redirect', { + host: 'tunnel.example', + 'x-forwarded-proto': 'https', + }) + + expect(response.headers['x-nextjs-redirect']).toBe('/overview?q=one') + }) + + it('translates Server Action redirects without changing navigation mode', async () => { + const response = await request('/action-redirect', { + host: 'tunnel.example', + 'x-forwarded-proto': 'https', + }) + + expect(response.status).toBe(303) + expect(response.headers['x-action-redirect']).toBe( + '/overview?q=one;replace' + ) + }) + + it('preserves external Server Action redirects', async () => { + const response = await request('/external-action') + + expect(response.headers['x-action-redirect']).toBe( + 'https://accounts.example/signin;push' + ) + }) + + it('translates protocol-relative redirects to the simulated host', async () => { + const response = await request('/network-relative', { + host: 'tunnel.example', + 'x-forwarded-proto': 'https', + }) + + expect(response.headers.location).toBe('/overview') + }) + + it('keeps double-slash paths on the public origin and preserves fragments', async () => { + const response = await request('/double-slash') + const destination = new URL( + response.headers.location, + 'https://tunnel.example' + ) + + expect(destination.origin).toBe('https://tunnel.example') + expect(destination.pathname).toBe('//external.example/path') + expect(destination.search).toBe('?q=one') + expect(destination.hash).toBe('#section') + }) + + it.each([ + ['/external', 'https://accounts.example/signin?q=one'], + ['/other-port', 'https://backend.example:9000/overview'], + ['/relative', '/signin?callbackUrl=%2Foverview'], + ])( + 'preserves unrelated and relative redirects at %s', + async (path, expected) => { + const response = await request(path) + + expect(response.headers.location).toBe(expected) + } + ) +}) diff --git a/platform/scripts/run-proxy.utest.js b/platform/scripts/run-proxy.utest.js new file mode 100644 index 0000000..f848be5 --- /dev/null +++ b/platform/scripts/run-proxy.utest.js @@ -0,0 +1,100 @@ +/** @jest-environment node */ +import { getInternalAssertionHeaders } from '@/lib/header.assertion' +import { runScript } from '@/lib/script' + +import './run-proxy' + +import http from 'http' +import httpProxy from 'http-proxy' + +jest.mock('@/lib/script', () => ({ + runScript: jest.fn(), + log: jest.fn(), +})) + +jest.mock('@/lib/header.assertion', () => ({ + getInternalAssertionHeaders: jest.fn(() => ({})), +})) + +jest.mock('http', () => ({ + createServer: jest.fn(), +})) + +jest.mock('http-proxy', () => ({ + createProxyServer: jest.fn(), +})) + +const { handler } = runScript.mock.calls[0][0] + +describe('local proxy frontend host assertion', () => { + it.each([ + ['quench.qsbx.ai', '8086', 'quench.qsbx.ai:8086'], + ['quench.qsbx.ai:9000', '8086', 'quench.qsbx.ai:9000'], + ['::1', '8086', '[::1]:8086'], + ['[::1]:9000', '8086', '[::1]:9000'], + ['quench.qsbx.ai', '443', 'quench.qsbx.ai'], + ['', '8086', ''], + ])( + 'asserts %s on port %s as %s', + async (frontendHost, port, expected) => { + httpProxy.createProxyServer.mockReturnValue({ on: jest.fn() }) + http.createServer.mockReturnValue({ on: jest.fn(), listen: jest.fn() }) + getInternalAssertionHeaders.mockReturnValue({}) + + await handler({ + host: 'console.localhost', + port, + target: 'http://localhost:8080', + frontendHost, + }) + + expect(getInternalAssertionHeaders).toHaveBeenCalledWith({ + frontendHost: expected, + }) + } + ) +}) + +describe('local proxy host forwarding', () => { + it.each([ + ['console.localhost', '3000', 'console.localhost:3000'], + ['console.localhost', '80', 'console.localhost'], + ['console.localhost:9000', '3000', 'console.localhost:9000'], + ['::1', '3000', '[::1]:3000'], + ['[::1]', '3000', '[::1]:3000'], + ['[::1]:9000', '3000', '[::1]:9000'], + ])( + 'forwards %s on port %s as %s for HTTP and WebSockets', + async (host, port, expected) => { + const proxy = { on: jest.fn() } + const server = { on: jest.fn(), listen: jest.fn() } + + httpProxy.createProxyServer.mockReturnValue(proxy) + http.createServer.mockReturnValue(server) + getInternalAssertionHeaders.mockReturnValue({ + 'x-test-assertion': 'value', + }) + + await handler({ host, port, target: 'http://localhost:8080' }) + + for (const event of ['proxyReq', 'proxyReqWs']) { + const listener = proxy.on.mock.calls.find(([name]) => name === event)[1] + const request = { setHeader: jest.fn() } + + listener(request) + + expect(request.setHeader).toHaveBeenCalledWith('Host', expected) + expect(request.setHeader).toHaveBeenCalledWith( + 'x-forwarded-host', + expected + ) + expect(request.setHeader).toHaveBeenCalledWith( + 'x-test-assertion', + 'value' + ) + } + + expect(server.listen).toHaveBeenCalledWith(Number(port)) + } + ) +}) diff --git a/platform/templates/onboarding.js b/platform/templates/onboarding.js index ab8b07a..6cda0b9 100644 --- a/platform/templates/onboarding.js +++ b/platform/templates/onboarding.js @@ -1,7 +1,8 @@ // @ts-check import { resolveBuilderExperience } from '@/lib/experience' +import { hostToHostname } from '@/lib/host.parse' -import { getDocumentHostname } from '@/hooks/useHostname' +import { getDocumentHost } from '@/hooks/useHost' import { getPartnerFromDocument } from '@/hooks/usePartner' const allSteps = [ @@ -37,7 +38,7 @@ export const template = { const builder = resolveBuilderExperience({ partnerExperience: getPartnerFromDocument()?.experience, - hostname: getDocumentHostname(), + hostname: hostToHostname(getDocumentHost()), }) if (builder) { diff --git a/platform/templates/onboarding.utest.js b/platform/templates/onboarding.utest.js index 04366e4..60e8c95 100644 --- a/platform/templates/onboarding.utest.js +++ b/platform/templates/onboarding.utest.js @@ -1,6 +1,6 @@ import { resolveBuilderExperience } from '@/lib/experience' -import { getDocumentHostname } from '@/hooks/useHostname' +import { getDocumentHost } from '@/hooks/useHost' import { getPartnerFromDocument } from '@/hooks/usePartner' import template from '@/templates/onboarding' @@ -9,8 +9,8 @@ jest.mock('@/lib/experience', () => ({ resolveBuilderExperience: jest.fn(), })) -jest.mock('@/hooks/useHostname', () => ({ - getDocumentHostname: jest.fn(), +jest.mock('@/hooks/useHost', () => ({ + getDocumentHost: jest.fn(), })) jest.mock('@/hooks/usePartner', () => ({ @@ -26,7 +26,7 @@ describe('onboarding template', () => { delete window.location window.location = { href: '', hostname: 'chatbotkit.com' } - getDocumentHostname.mockReturnValue('chatbotkit.com') + getDocumentHost.mockReturnValue('chatbotkit.com') getPartnerFromDocument.mockReturnValue(null) resolveBuilderExperience.mockReturnValue(true) @@ -48,7 +48,7 @@ describe('onboarding template', () => { it('skips the intent step on the platform experience', () => { resolveBuilderExperience.mockReturnValue(false) - getDocumentHostname.mockReturnValue('platform.example.com') + getDocumentHost.mockReturnValue('platform.example.com') expect(template.steps).toEqual([ ':disabled', @@ -64,7 +64,7 @@ describe('onboarding template', () => { }) it('resolves the experience a partner pins over the hostname', () => { - getDocumentHostname.mockReturnValue('backend.acme.dev') + getDocumentHost.mockReturnValue('backend.acme.dev') getPartnerFromDocument.mockReturnValue({ name: 'AgenticOS', experience: 'builder', @@ -164,3 +164,22 @@ describe('onboarding template', () => { }) }) }) + +describe('onboarding template on a host that carries a port', () => { + it('resolves the experience by hostname', () => { + jest.clearAllMocks() + + resolveBuilderExperience.mockReturnValue(false) + getPartnerFromDocument.mockReturnValue(null) + getDocumentHost.mockReturnValue('platform.example.com:3000') + + template.steps + + // @note the document carries the host; the experience table is keyed by + // hostname + expect(resolveBuilderExperience).toHaveBeenCalledWith({ + partnerExperience: undefined, + hostname: 'platform.example.com', + }) + }) +}) diff --git a/platform/tests/config/host-configuration.utest.js b/platform/tests/config/host-configuration.utest.js index c4f1ef4..4e7d5f4 100644 --- a/platform/tests/config/host-configuration.utest.js +++ b/platform/tests/config/host-configuration.utest.js @@ -55,6 +55,28 @@ describe('HOSTS_CONFIG host mappings', () => { ).toThrow() }) + it('accepts hosts that carry a port', () => { + const ported = { + match: ['console.example:8443', 'api.example:8443'], + site: 'console.example:8443', + api: 'api.example:8443', + static: 'static.example:8443', + widgets: 'widgets.example:8443', + } + + expect(hostsSchema.parse({ ported })).toEqual({ ported }) + }) + + it('allows mappings that differ only by port', () => { + // @note selection prefers an exact host, so per-port mappings are legal + expect(() => + hostsSchema.parse({ + family: mapping, + ported: { ...mapping, match: ['example.com:8443'] }, + }) + ).not.toThrow() + }) + it('rejects duplicate matches across mappings', () => { expect(() => hostsSchema.parse({ diff --git a/platform/tests/config/site.browser.utest.js b/platform/tests/config/site.browser.utest.js new file mode 100644 index 0000000..1db7c2c --- /dev/null +++ b/platform/tests/config/site.browser.utest.js @@ -0,0 +1,65 @@ +/** + * @jest-environment jsdom + */ + +// @note the browser bundle has no SITE_URL; it seeds from the origins the +// server stamped on , so server and client render the same hosts and +// the post-hydration overlay is a real change React applies to the DOM + +const ORIGIN_KEYS = ['SITE_URL', 'STATIC_URL', 'WIDGET_URL', 'API_URL'] + +function loadSite() { + let site + + jest.isolateModules(() => { + site = jest.requireActual('@/config/site') + }) + + return site +} + +describe('config/site in the browser', () => { + const previous = Object.fromEntries( + ORIGIN_KEYS.map((key) => [key, process.env[key]]) + ) + + beforeEach(() => { + for (const key of ORIGIN_KEYS) { + delete process.env[key] + } + + for (const key of ['siteUrl', 'staticUrl', 'widgetUrl', 'apiUrl']) { + delete document.documentElement.dataset[key] + } + }) + + afterAll(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + }) + + it('seeds from the origins the server rendered with', () => { + document.documentElement.dataset.siteUrl = 'http://127.0.0.1:3000' + document.documentElement.dataset.staticUrl = 'https://static.example:9443' + + const site = loadSite() + + expect(site.siteUrl).toBe('http://127.0.0.1:3000') + expect(site.siteHost).toBe('127.0.0.1:3000') + expect(site.staticHost).toBe('static.example:9443') + // @note unset origins fall back to the site, as on the server + expect(site.widgetHost).toBe('127.0.0.1:3000') + expect(site.apiHost).toBe('127.0.0.1:3000') + }) + + it('falls back to the page origin when nothing is stamped', () => { + const site = loadSite() + + expect(site.siteUrl).toBe(window.location.origin) + }) +})