diff --git a/README.md b/README.md index 8db0a68..cc17200 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,11 @@ docker compose -f oci://ghcr.io/chatbotkit/platform-community:latest logs platfo See [Deployment](./docs/deployment.md) for details. +On an Apple silicon Mac you can skip Docker altogether: +[ChatBotKit Studio](https://github.com/chatbotkit/studio) is a native macOS +app that runs this same platform in an app-private VM, with model providers, +logs and updates managed from the app. + ## Local development Binary assets are stored with Git LFS, so install it (`git lfs install`) diff --git a/docker/Dockerfile b/docker/Dockerfile index 33ed724..8ef8bc0 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -95,13 +95,10 @@ ENV NODE_OPTIONS="--max-old-space-size=$NODE_HEAP_MB --require /app/platform/scr ARG SITE_URL=http://cbk.localhost:3000 ENV SITE_URL=$SITE_URL -# @note space, portal and app hosts are selected at runtime through proxy.ts; -# partner host rewrites are generated at build time, so their runtime values -# must match the build +# @note space, portal, app and partner hosts are selected at server startup; +# app apex is also used for build defaults ARG APP_APEX= ENV APP_APEX=$APP_APEX -ARG PARTNERS_APEX= -ENV PARTNERS_APEX=$PARTNERS_APEX # @note build defaults only; shell host routing uses the values supplied at # server startup diff --git a/docker/distro/community/compose.yml b/docker/distro/community/compose.yml index ad54060..a3f792c 100644 --- a/docker/distro/community/compose.yml +++ b/docker/distro/community/compose.yml @@ -66,6 +66,9 @@ services: NODE_ENV: production PORT: 3000 SITE_URL: ${SITE_URL:-http://cbk.localhost:3000} + STATIC_URL: ${STATIC_URL:-} + API_URL: ${API_URL:-} + HOSTS_CONFIG: ${HOSTS_CONFIG:-} NEXTAUTH_URL: ${NEXTAUTH_URL:-http://cbk.localhost:3000} # @note realtime channels (voice, avatars) meet at a relay the platform # process hosts itself on RELAY_PORT. Both that process and a host @@ -73,11 +76,12 @@ services: # needs an address it can reach instead (and TLS if the site has it) RELAY_PORT: 3001 RELAY_URL: ${RELAY_URL:-http://cbk-relay.localhost:3001} - # @note space, portal and app hosts read their settings at server startup; + # @note host routing reads its settings at server startup; # recreate the container to change domains without rebuilding the image SPACE_APEX: ${SPACE_APEX:-cbk-space.localhost} PORTAL_APEX: ${PORTAL_APEX:-cbk-portal.localhost} APP_APEX: ${APP_APEX:-} + PARTNERS_APEX: ${PARTNERS_APEX:-} # @note cookies do not cross hosts, so sign in on the shell host itself APP_MAIN_ORIGIN: ${APP_MAIN_ORIGIN:-http://cbk-apps.localhost:3000} APP_LABS_ORIGIN: ${APP_LABS_ORIGIN:-http://cbk-labs.localhost:3000} diff --git a/docker/distro/studio/compose.yml b/docker/distro/studio/compose.yml index eae2dcb..ff43f81 100644 --- a/docker/distro/studio/compose.yml +++ b/docker/distro/studio/compose.yml @@ -67,6 +67,9 @@ services: NODE_ENV: production PORT: 3000 SITE_URL: ${SITE_URL:-http://cbk.localhost:3000} + STATIC_URL: ${STATIC_URL:-} + API_URL: ${API_URL:-} + HOSTS_CONFIG: ${HOSTS_CONFIG:-} NEXTAUTH_URL: ${NEXTAUTH_URL:-http://cbk.localhost:3000} # @note an explicitly empty value restores ordinary email sign-in NEXTAUTH_TRUSTED_SIGNIN: ${NEXTAUTH_TRUSTED_SIGNIN-true} @@ -76,11 +79,12 @@ services: # needs an address it can reach instead (and TLS if the site has it) RELAY_PORT: 3001 RELAY_URL: ${RELAY_URL:-http://cbk-relay.localhost:3001} - # @note space, portal and app hosts read their settings at server startup; + # @note host routing reads its settings at server startup; # recreate the container to change domains without rebuilding the image SPACE_APEX: ${SPACE_APEX:-cbk-space.localhost} PORTAL_APEX: ${PORTAL_APEX:-cbk-portal.localhost} APP_APEX: ${APP_APEX:-} + PARTNERS_APEX: ${PARTNERS_APEX:-} # @note cookies do not cross hosts, so sign in on the shell host itself APP_MAIN_ORIGIN: ${APP_MAIN_ORIGIN:-http://cbk-apps.localhost:3000} APP_LABS_ORIGIN: ${APP_LABS_ORIGIN:-http://cbk-labs.localhost:3000} diff --git a/docs/configuration.md b/docs/configuration.md index f8d5741..929ee3f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,8 +57,8 @@ stops booting until they edit it. So: migration easier. - Any exception travels in the release notes as a configuration migration. -`HOSTS_CONFIG` is shared by build-time routing and runtime URL selection. The -build flattens every configured API and static target into its routing rules. +`HOSTS_CONFIG` is shared by runtime routing and URL selection. The proxy reads +all configured API and static targets at server startup. At runtime, request-context setup selects a mapping once from the authenticated frontend host or trusted normalized request host. URL helpers then read only the resolved context; the raw mapping is not exposed to the browser. The @@ -225,9 +225,12 @@ Standalone app host routing also reads `APP_APEX` at server startup. Setting catalogue receive subdomain routes. Community and Studio leave this apex empty by default, so apps remain available by path. -Partner host rewrites are still generated when Next builds, so `PARTNERS_APEX` -must match the image's build-time value; see -[Deployment](./deployment.md#production-boundary). +Partner host routing and branding also read `PARTNERS_APEX` at server startup. +Changing it and recreating the container moves partner subdomains without +rebuilding. Partner custom domains come from the installed partner catalogue +and work even when the apex is unset. The public catalogue is empty by default; +adding or changing catalogue entries still requires packaging that catalogue +into the image. Community and Studio leave `PARTNERS_APEX` empty by default. ## App shell origins @@ -287,12 +290,25 @@ 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. -At build time, every `api` and `static` target is enabled unconditionally. 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. +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 +continues serving the application and its API at `/api/v1`. Changing targets, +`API_URL` or `STATIC_URL` requires restarting the server without rebuilding +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. + +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 +`api.example.com` keeps its browser protections. API paths and embeddable widget +paths retain their separate policies. These policies are loaded at server +startup, including the optional `SENTRY_HEADERS_REPORT_URI` reporting endpoint. +The proxy also owns trailing-slash normalization, so those responses receive +the same runtime policy before they leave the server. Their permanent status, +destinations and query parameters are preserved. Keep redirects that require +these headers in the proxy; Next's configured redirects run before it. `SITE_URL` remains the canonical and requestless default. When no mapping is selected, `API_URL`, `STATIC_URL`, and `WIDGET_URL` all fall back to diff --git a/docs/deployment.md b/docs/deployment.md index 4d1fbdc..1295eec 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -189,7 +189,10 @@ its workspaces kept under `/data/sandbox` in the same volume. | `studio` | SQLite (default module) | Redis | Qdrant | Garage | Studio starts as a copy of Community, with the same Docker build targets, -module defaults and services. Its separate Compose file lives at +module defaults and services. It is the flavor embedded by +[ChatBotKit Studio](https://github.com/chatbotkit/studio), the native macOS +app that runs the platform in an app-private VM without a Docker install, and +can also be run directly with Compose. Its separate Compose file lives at [docker/distro/studio/compose.yml](../docker/distro/studio/compose.yml), and the publish workflow produces `platform-studio`, `platform-studio-app` and `platform-studio-init` under `ghcr.io/chatbotkit`, using the same channel tags @@ -292,8 +295,9 @@ startup. Changing them and recreating the container moves those sites to the new domains without rebuilding the image. Portal authentication and app configuration continue to apply on the new domain. App hosts work the same way through `APP_APEX`, `APP_MAIN_ORIGIN` and `APP_LABS_ORIGIN`; see -[Configuration](./configuration.md#app-shell-origins). Only `PARTNERS_APEX` -still has to match the build. Runtime service variables +[Configuration](./configuration.md#app-shell-origins). Partner routing and +branding also read `PARTNERS_APEX` at startup, using the installed partner +catalogue for custom domains and branding. Runtime service variables such as the database, Redis, Qdrant and S3-compatible storage endpoints remain configurable. Deployment identity that Next currently exposes through `next.config.js` is still frozen at build time; do not present the same digest @@ -304,13 +308,40 @@ as portable across arbitrary public domains until that migration is complete. Every deployment serves the API at `/api/v1` on its own host - nothing to configure. To advertise and serve it on a dedicated origin instead, set `API_URL` (e.g. `https://api.example.com`), point that DNS name at the -deployment, and rebuild: the host is then routed to the API (answering under -the clean `/v1` path) and every externally advertised URL - webhook -registrations, embeds, the OpenAPI spec - follows it. Unset, advertised URLs -stay on the site host under `/api`. Multi-domain deployments name their API -hosts in `HOSTS_CONFIG` instead; see -[Configuration](./configuration.md#hosts_config). Both are read at build time, -so changing them requires a rebuild, not just a restart. +deployment, and restart the server: the host is then routed to the API under +the clean `/v1` path. Runtime API URL helpers use the configured origin. Unset, +advertised URLs stay on the site host under `/api`. Multi-domain deployments +name their API hosts in `HOSTS_CONFIG` instead; see +[Configuration](./configuration.md#hosts_config). Both are read at server +startup, so changing API hosts requires recreating the container without +rebuilding. The existing CORS policy applies to `/v1` on dedicated API hosts +and `/api/v1` on every host: any origin may call the API with a bearer token; +cookie credentials are not enabled. Community and Studio expose `API_URL` and +`HOSTS_CONFIG`, leaving both empty by default. + +## Static host + +`STATIC_URL` selects the origin for public assets and widget embeds. A dedicated +static hostname applies the existing static path restrictions, including the +text fallback for application pages. Leaving it unset serves those assets on +`SITE_URL` without restricting the site. + +Static host routing reads `STATIC_URL` and the static targets in `HOSTS_CONFIG` +at server startup. Change the values and recreate the container to move static +hosts without rebuilding. Community and Studio expose `STATIC_URL` and leave +it empty by default. + +## Sitemaps and crawl policy + +The platform does not generate a root sitemap or sitemap chunks during the +build. Public examples, hub resources, connections and model pages retain their +dynamic section sitemaps. A frontend site can include those endpoints in its +own sitemap index. + +`public/robots.txt` is a checked-in, origin-independent crawl policy. It keeps +the existing allow and content-signal directives without embedding a deployment +host or sitemap URL. `SITE_URL` remains runtime configuration for absolute URLs, +including links emitted by the dynamic section sitemaps. ## Reverse proxy trust diff --git a/platform/.gitignore b/platform/.gitignore index dc173ac..12f04f0 100644 --- a/platform/.gitignore +++ b/platform/.gitignore @@ -35,3 +35,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Crawl policy is source, independent of the deployment origin +!/public/robots.txt diff --git a/platform/config/hosts.js b/platform/config/hosts.js index 85bcd51..4677331 100644 --- a/platform/config/hosts.js +++ b/platform/config/hosts.js @@ -66,9 +66,9 @@ export const hostMappingSchema = z * } * } * - * Build-time routing consumes the flattened target lists. Request-context - * setup selects one mapping when a trusted request or frontend host matches; - * runtime URL helpers only read that resolved context. + * API and static host routing read the flattened targets at startup. + * Request-context setup selects one mapping when a trusted request or frontend + * host matches; runtime URL helpers only read that resolved context. */ export const hostsSchema = z .record(hostMappingSchema) diff --git a/platform/lib/proxy.routing.utest.js b/platform/lib/proxy.routing.utest.js index 48b0ba7..75cf313 100644 --- a/platform/lib/proxy.routing.utest.js +++ b/platform/lib/proxy.routing.utest.js @@ -1,4 +1,6 @@ /** @jest-environment node */ +import loadCustomRoutes from 'next/dist/lib/load-custom-routes' + import { execFile, spawn } from 'node:child_process' import { once } from 'node:events' import fs from 'node:fs/promises' @@ -34,10 +36,14 @@ describe.each(['', '/platform'])( PORTAL_APEX: 'cbk-portal.localhost', SITE_URL: 'http://platform.localhost:3000', HOSTS_CONFIG: '', + STATIC_URL: 'http://build.static.example:3000', + API_URL: 'http://build.api.example:3000', + SENTRY_HEADERS_REPORT_URI: 'https://build.report.example/csp', APP_MAIN_ORIGIN: 'http://cbk-apps.localhost:3000', APP_LABS_ORIGIN: 'http://cbk-labs.localhost:3000', APP_APEX: 'cbk-app.localhost', - PARTNERS_APEX: '', + PARTNERS_APEX: 'cbk-partners.localhost', + FIXTURE_PARTNER_DOMAIN: 'build.partner.example', } async function write(file, content) { @@ -54,6 +60,14 @@ describe.each(['', '/platform'])( APP_APEX: 'app.localhost', APP_MAIN_ORIGIN: 'http://apps.localhost:3000', APP_LABS_ORIGIN: 'http://labs.localhost:3000', + }, + partnerConfiguration = { + PARTNERS_APEX: 'partners.localhost', + FIXTURE_PARTNER_DOMAIN: 'partner.example', + }, + hostConfiguration = { + STATIC_URL: 'http://static.localhost:3000', + HOSTS_CONFIG: '', } ) { const socket = createServer() @@ -77,6 +91,10 @@ describe.each(['', '/platform'])( SPACE_APEX: apex, PORTAL_APEX: portalApex, ...appConfiguration, + ...partnerConfiguration, + API_URL: 'http://api.localhost:3000', + SENTRY_HEADERS_REPORT_URI: '', + ...hostConfiguration, // @note use the same serialized configuration as Next's generated // standalone launcher; next start otherwise reloads next.config __NEXT_PRIVATE_STANDALONE_CONFIG: standaloneConfig, @@ -167,10 +185,51 @@ describe.each(['', '/platform'])( beforeAll(async () => { directory = await fs.mkdtemp(path.join(os.tmpdir(), 'cbk-host-routing-')) - await fs.symlink( - path.join(project, 'node_modules'), - path.join(directory, 'node_modules'), - 'dir' + // @note supply a fixture partner catalogue through the public package + // boundary without changing the workspace's installed catalogue + await fs.mkdir(path.join(directory, 'node_modules/@chatbotkit-dev'), { + recursive: true, + }) + + for (const name of await fs.readdir(path.join(project, 'node_modules'))) { + if (name !== '@chatbotkit-dev') { + await fs.symlink( + path.join(project, 'node_modules', name), + path.join(directory, 'node_modules', name) + ) + } + } + + for (const name of await fs.readdir( + path.join(project, 'node_modules/@chatbotkit-dev') + )) { + if (name !== 'partners') { + await fs.symlink( + path.join(project, 'node_modules/@chatbotkit-dev', name), + path.join(directory, 'node_modules/@chatbotkit-dev', name) + ) + } + } + + await write( + 'node_modules/@chatbotkit-dev/partners/package.json', + JSON.stringify({ + name: '@chatbotkit-dev/partners', + type: 'module', + exports: './index.js', + }) + ) + await write( + 'node_modules/@chatbotkit-dev/partners/index.js', + `export default { + acme: { + id: 'partner-account', name: 'Acme Studio', logo: '/acme.svg', + icon: '/acme.png', whitelabel: true, experience: 'builder', + domain: process.env.FIXTURE_PARTNER_DOMAIN, + auth: { allowGlobalLogin: true }, email: { send() {} }, + }, + plain: { id: 'plain-account', name: 'Plain Partner' }, + }` ) await write( 'package.json', @@ -206,10 +265,16 @@ describe.each(['', '/platform'])( 'lib/json.ts', 'lib/struct.ts', 'lib/nextjs.config.rewrites.js', + 'lib/security.headers.js', 'next.config.d/portals.config.js', 'next.config.d/apps.config.js', + 'next.config.d/partner.config.js', 'next.config.d/actions.config.js', 'next.config.d/spaces.config.js', + 'next.config.d/static.config.js', + 'next.config.d/api.config.js', + 'next.config.d/oauth.config.js', + 'next.config.d/proxy.config.js', 'next.config.d/transpile.config.js', ]) { await write(file, await fs.readFile(path.join(project, file), 'utf8')) @@ -223,7 +288,13 @@ describe.each(['', '/platform'])( import portals from './next.config.d/portals.config.js' import actions from './next.config.d/actions.config.js' import apps from './next.config.d/apps.config.js' - export default { ...transpile, env: apps.env, + import partners from './next.config.d/partner.config.js' + import staticConfig from './next.config.d/static.config.js' + import apiConfig from './next.config.d/api.config.js' + import oauthConfig from './next.config.d/oauth.config.js' + import proxyConfig from './next.config.d/proxy.config.js' + export default { ...transpile, ...proxyConfig, env: apps.env, + headers: apiConfig.headers, experimental: { ...actions.experimental, cpus: 1 }, basePath: ${JSON.stringify(basePath)}, i18n: ${ @@ -235,10 +306,14 @@ describe.each(['', '/platform'])( const portalRules = await portals.rewrites() const spaceRules = await spaces.rewrites() const appRules = await apps.rewrites() + const partnerRules = await partners.rewrites() + const staticRules = await staticConfig.rewrites() + const apiRules = await apiConfig.rewrites() + const oauthRules = await oauthConfig.rewrites() return { - beforeFiles: [...appRules.beforeFiles, ...portalRules.beforeFiles, ...spaceRules.beforeFiles], - afterFiles: [...appRules.afterFiles, ...portalRules.afterFiles, ...spaceRules.afterFiles], - fallback: [...appRules.fallback, ...portalRules.fallback, ...spaceRules.fallback], + beforeFiles: [...apiRules.beforeFiles, ...appRules.beforeFiles, ...partnerRules.beforeFiles, ...portalRules.beforeFiles, ...spaceRules.beforeFiles, ...staticRules.beforeFiles], + afterFiles: [...appRules.afterFiles, ...oauthRules.afterFiles, ...portalRules.afterFiles, ...spaceRules.afterFiles], + fallback: [...apiRules.fallback, ...appRules.fallback, ...portalRules.fallback, ...spaceRules.fallback, ...staticRules.fallback], } }, typescript: { ignoreBuildErrors: true } } @@ -257,6 +332,33 @@ describe.each(['', '/platform'])( 'pages/api/health.js', 'export default function handler(req, res) { res.json({ ok: true }) }' ) + await write( + 'pages/api/v1/probe.js', + `export default function handler(req, res) { + if (req.method === 'OPTIONS') { res.status(200).end(); return } + res.json({ method: req.method, query: req.query, body: req.body, host: req.headers.host }) + }` + ) + + for (const route of ['index', '404']) { + await write( + `pages/api/${route}.js`, + `export default function handler(req, res) { res.status(404).json({ api: 'not found' }) }` + ) + } + + for (const route of [ + 'oauth/probe', + '.well-known/api-catalog', + '.well-known/microsoft-identity-association.json', + ]) { + await write( + `pages/api/${route}.js`, + `export default function handler(req, res) { res.json({ route: ${JSON.stringify( + route + )} }) }` + ) + } for (const route of [ 'apps/index', @@ -278,6 +380,8 @@ describe.each(['', '/platform'])( 'integrations/mcpserver/[integrationId]/test', 'redirect/target', 'partner/signin/acme', + 'partner/signin/acme/verify', + 'partner/signin/plain', ]) { await write( `pages/${route}.js`, @@ -293,6 +397,16 @@ describe.each(['', '/platform'])( ) } + await write( + 'pages/integrations/widget/restricted/frame.js', + `import { buildOriginRestrictedCsp } from '../../../../lib/security.headers.js' + export default function Page() { return null } + export function getServerSideProps({ res }) { + res.setHeader('Content-Security-Policy', buildOriginRestrictedCsp('https://allowed.example')) + return { props: {} } + }` + ) + // @note stand in only for database/storage access; routing into this // handler must happen through the actual compiled proxy or rewrite rules await write( @@ -354,6 +468,7 @@ describe.each(['', '/platform'])( } await write('public/favicon.ico', 'fixture favicon') + await write('public/404.txt', 'fixture static fallback') await execute(process.execPath, [next, 'build', '--webpack'], { cwd: directory, @@ -682,6 +797,27 @@ describe.each(['', '/platform'])( }) it.each([ + { + name: 'same-origin form on the runtime partner apex', + host: 'acme.partners.localhost:3000', + origin: 'http://acme.partners.localhost:3000', + pathname: '/apps/action-probe', + allowed: true, + }, + { + name: 'same-origin form on a partner custom domain', + host: 'partner.example:3000', + origin: 'http://partner.example:3000', + pathname: '/apps/action-probe', + allowed: true, + }, + { + name: 'foreign origin on a partner custom domain', + host: 'partner.example:3000', + origin: 'https://attacker.invalid', + pathname: '/apps/action-probe', + allowed: false, + }, { name: 'same-origin form on the runtime portal apex', host: 'test.portal.localhost:3000', @@ -1113,5 +1249,1014 @@ describe.each(['', '/platform'])( expect(response.status).toBe(307) expect(response.headers.get('x-fixture-route')).toBeNull() }) + + it('serves partner sign-in on the runtime apex with the same build', async () => { + await start() + + const response = await request( + 'acme.partners.localhost:3000', + '/signin?callbackUrl=%2Foverview' + ) + + expect(response.status).toBe(200) + expect(response.headers.get('x-fixture-route')).toBe( + 'partner/signin/acme' + ) + expect( + JSON.parse(response.headers.get('x-fixture-query')).callbackUrl + ).toBe('/overview') + }) + + it('routes partner verification and custom domains without leaking private branding fields', async () => { + await start() + + for (const host of [ + 'ACME.PARTNERS.LOCALHOST:3000', + 'partner.example:3000', + ]) { + for (const [pathname, route] of [ + ['/signin?slug=plain&callbackUrl=%2Foverview', 'partner/signin/acme'], + ['/signin/verify?token=example', 'partner/signin/acme/verify'], + ]) { + const response = await request(host, pathname) + + expect(response.status).toBe(200) + expect(response.headers.get('x-fixture-route')).toBe(route) + + const encoded = response.headers + .get('server-timing') + .match(/partner;desc="([^"]+)"/)[1] + + expect(JSON.parse(Buffer.from(encoded, 'base64').toString())).toEqual( + { + name: 'Acme Studio', + logo: '/acme.svg', + icon: '/acme.png', + whitelabel: true, + experience: 'builder', + } + ) + } + } + }) + + it('redirects partner roots on the public host and includes branding on API responses', async () => { + await start() + + for (const host of [ + 'acme.partners.localhost:3000', + 'partner.example:3000', + ]) { + const response = await request(host, '/?campaign=one') + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + `http://${host}${basePath}/overview?campaign=one` + ) + expect(response.headers.get('server-timing')).toContain('partner;desc=') + + const api = await request(host, '/api/health') + + expect(await api.json()).toEqual({ ok: true }) + expect(api.headers.get('server-timing')).toBe( + response.headers.get('server-timing') + ) + } + }) + + it('rejects stale partner domains and forged partner markers', async () => { + await start() + + for (const host of [ + 'acme.cbk-partners.localhost:3000', + 'build.partner.example:3000', + 'partners.localhost:3000', + 'acme.partners.localhost.attacker.example', + 'partnerXexample:3000', + 'unrelated.example:3000', + ]) { + const response = await request(host, '/signin', { + headers: { + 'x-cbk-partner': 'acme', + 'x-forwarded-host': 'partner.example:3000', + }, + }) + + expect(response.status).toBe(200) + expect(response.headers.get('x-fixture-route')).toBe('signin') + expect(response.headers.get('server-timing')).toBeNull() + } + }) + + it('keeps unknown partner slugs on the partner route without inventing branding', async () => { + await start() + + const response = await request( + 'unknown.partners.localhost:3000', + '/signin' + ) + + expect(response.status).toBe(404) + expect(response.headers.get('server-timing')).toBeNull() + + const plain = await request('plain.partners.localhost:3000', '/signin') + const encoded = plain.headers + .get('server-timing') + .match(/partner;desc="([^"]+)"/)[1] + + expect(plain.status).toBe(200) + expect(JSON.parse(Buffer.from(encoded, 'base64').toString())).toEqual({ + name: 'Plain Partner', + whitelabel: false, + }) + }) + + it('changes partner domains and disables the apex without rebuilding', async () => { + await start(undefined, undefined, undefined, { + PARTNERS_APEX: '', + FIXTURE_PARTNER_DOMAIN: 'new.partner.example', + }) + + expect( + (await request('new.partner.example', '/signin')).headers.get( + 'x-fixture-route' + ) + ).toBe('partner/signin/acme') + + for (const host of [ + 'partner.example', + 'acme.partners.localhost', + 'acme.cbk-partners.localhost', + ]) { + const response = await request(host, '/signin') + + expect(response.headers.get('x-fixture-route')).toBe('signin') + expect(response.headers.get('server-timing')).toBeNull() + } + }) + + it.each([ + ['apps.localhost:3000', '/chat', 'apps/chat/[[...path]]'], + ['chat.app.localhost:3000', '/conversation', 'apps/chat/[[...path]]'], + ['test.portal.localhost:3000', '/chat', 'apps/chat/[[...path]]'], + ])( + 'preserves partner sign-in alongside app routing on %s', + async (host, pathname, route) => { + await start(undefined, undefined, undefined, { + PARTNERS_APEX: 'partners.localhost', + FIXTURE_PARTNER_DOMAIN: host.split(':')[0], + }) + + expect( + (await request(host, '/signin')).headers.get('x-fixture-route') + ).toBe('partner/signin/acme') + expect( + (await request(host, pathname)).headers.get('x-fixture-route') + ).toBe(route) + + const root = await request(host) + + expect(root.status).toBe(307) + expect(root.headers.get('location')).toBe( + `http://${host}${basePath}/overview` + ) + + const overview = await request(host, '/overview') + + expect(overview.headers.get('location')).not.toBe( + `http://${host}${basePath}/` + ) + } + ) + + it('preserves public space rendering alongside partner sign-in', async () => { + await start(undefined, undefined, undefined, { + PARTNERS_APEX: 'partners.localhost', + FIXTURE_PARTNER_DOMAIN: 'test.space.localhost', + }) + + expect( + (await request('test.space.localhost', '/signin')).headers.get( + 'x-fixture-route' + ) + ).toBe('partner/signin/acme') + + const response = await request('test.space.localhost', '/docs') + + expect(response.headers.get('x-space-site')).toBe('public') + expect(response.headers.get('server-timing')).toContain('partner;desc=') + }) + + it('keeps partner hosts and branding out of the built route manifest', async () => { + const manifest = await fs.readFile( + path.join(directory, '.next/routes-manifest.json'), + 'utf8' + ) + + expect(manifest).toContain('x-cbk-partner') + expect(manifest).not.toContain('cbk-partners.localhost') + expect(manifest).not.toContain('build.partner.example') + expect(manifest).not.toContain('partner;desc=') + }) + + it('applies static host restrictions from the runtime URL with the same build', async () => { + await start() + + const response = await request('static.localhost:3000', '/signin') + + expect(await response.text()).toBe('fixture static fallback') + expect(response.headers.get('x-fixture-route')).toBeNull() + }) + + it('preserves static fallback status and allowed widgets, assets and API paths', async () => { + await start() + + for (const pathname of ['/', '/overview', '/missing/path']) { + const response = await request('STATIC.LOCALHOST:3000', pathname) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('fixture static fallback') + } + + for (const [pathname, route] of [ + ['/integrations/widget/v1.js', 'integrations/widget/v1.js'], + [ + '/integrations/widget/demo/frame?theme=dark', + 'integrations/widget/[integrationId]/frame', + ], + ['/partner/signin/acme', 'partner/signin/acme'], + ]) { + const response = await request('static.localhost:3000', pathname) + + expect(response.status).toBe(200) + expect(response.headers.get('x-fixture-route')).toBe(route) + } + + expect( + await (await request('static.localhost:3000', '/favicon.ico')).text() + ).toBe('fixture favicon') + expect( + await (await request('static.localhost:3000', '/api/health')).json() + ).toEqual({ ok: true }) + expect( + (await request('static.localhost:3000', '/missing.js')).status + ).toBe(404) + expect( + (await request('static.localhost:3000', '/signin', { method: 'HEAD' })) + .status + ).toBe(200) + }) + + it('does not restrict stale or spoofed static hosts', async () => { + await start() + + for (const host of [ + 'build.static.example:3000', + 'unrelated.example', + 'staticXlocalhost', + 'static.localhost.attacker.example', + ]) { + const response = await request(host, '/signin', { + headers: { + 'x-cbk-static': '1', + 'x-forwarded-host': 'static.localhost:3000', + }, + }) + + expect(response.headers.get('x-fixture-route')).toBe('signin') + } + }) + + it('loads all mapped static targets at startup while keeping shared site hosts unrestricted', async () => { + await start(undefined, undefined, undefined, undefined, { + STATIC_URL: 'https://static.chatbotkit.com', + HOSTS_CONFIG: JSON.stringify({ + family: { + match: ['example.com'], + site: 'example.com', + api: 'api.example.com', + static: 'static.example.com', + widgets: 'widgets.example.com', + }, + secondary: { + match: ['legacy.example.com'], + site: 'legacy.example.com', + api: 'api.legacy.example.com', + static: 'static.legacy.example.com', + widgets: 'widgets.legacy.example.com', + }, + single: { + match: ['single.example.com'], + site: 'single.example.com', + api: 'single.example.com', + static: 'single.example.com', + widgets: 'single.example.com', + }, + }), + }) + + for (const host of [ + 'static.example.com', + 'static.legacy.example.com', + 'static.chatbotkit.com', + ]) { + expect(await (await request(host, '/signin')).text()).toBe( + 'fixture static fallback' + ) + } + + for (const host of [ + 'example.com', + 'legacy.example.com', + 'single.example.com', + 'platform.localhost:3000', + ]) { + expect( + (await request(host, '/signin')).headers.get('x-fixture-route') + ).toBe('signin') + } + }) + + it.each(['', 'http://platform.localhost:3000'])( + 'disables static host restrictions with STATIC_URL=%s', + async (staticUrl) => { + await start(undefined, undefined, undefined, undefined, { + STATIC_URL: staticUrl, + HOSTS_CONFIG: '', + }) + + for (const host of [ + 'static.localhost:3000', + 'build.static.example:3000', + 'platform.localhost:3000', + ]) { + expect( + (await request(host, '/signin')).headers.get('x-fixture-route') + ).toBe('signin') + } + } + ) + + it.each([ + 'apps.localhost:3000', + 'chat.app.localhost:3000', + 'test.portal.localhost:3000', + ])( + 'preserves static restrictions after app rewrites when hosts overlap at %s', + async (host) => { + await start(undefined, undefined, undefined, undefined, { + STATIC_URL: `http://${host}`, + HOSTS_CONFIG: '', + }) + + expect(await (await request(host, '/conversation')).text()).toBe( + 'fixture static fallback' + ) + expect( + (await request(host, '/integrations/widget/v1.js')).headers.get( + 'x-fixture-route' + ) + ).toBe('integrations/widget/v1.js') + } + ) + + it('preserves public space routing and partner sign-in when static hosts overlap', async () => { + await start(undefined, undefined, undefined, undefined, { + STATIC_URL: 'http://test.space.localhost:3000', + HOSTS_CONFIG: JSON.stringify({ + partner: { + match: ['partner.example'], + site: 'example.com', + api: 'api.example.com', + static: 'partner.example', + widgets: 'widgets.example.com', + }, + }), + }) + + expect( + (await request('test.space.localhost:3000', '/docs')).headers.get( + 'x-space-site' + ) + ).toBe('public') + expect( + (await request('partner.example', '/signin')).headers.get( + 'x-fixture-route' + ) + ).toBe('partner/signin/acme') + expect((await request('partner.example')).headers.get('location')).toBe( + `http://partner.example${basePath}/overview` + ) + }) + + it('keeps static hostnames out of the built route manifest', async () => { + const manifest = await fs.readFile( + path.join(directory, '.next/routes-manifest.json'), + 'utf8' + ) + + expect(manifest).toContain('x-cbk-static') + expect(manifest).not.toContain('build.static.example') + }) + + it('serves the clean API path and CORS from the runtime API URL', async () => { + await start() + + const response = await request('api.localhost:3000', '/v1/probe?q=one') + + expect(response.status).toBe(200) + expect(response.headers.get('access-control-allow-origin')).toBe('*') + expect((await response.json()).query.q).toBe('one') + }) + + it('preserves API preflight and POST requests on clean and shared-site paths', async () => { + await start() + + for (const [host, pathname] of [ + ['api.localhost:3000', '/v1/probe'], + ['api.localhost:3000', '/api/v1/probe'], + ['platform.localhost:3000', '/api/v1/probe'], + ]) { + const preflight = await request(host, pathname, { + method: 'OPTIONS', + headers: { + origin: 'https://browser.example', + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'authorization,content-type', + }, + }) + + expect(preflight.status).toBe(200) + expect(preflight.headers.get('access-control-allow-origin')).toBe('*') + expect(preflight.headers.get('access-control-allow-methods')).toBe( + 'GET,POST' + ) + expect(preflight.headers.get('access-control-allow-headers')).toBe( + 'X-Requested-With, Accept, Content-Length, Content-Type, Authorization' + ) + expect( + preflight.headers.get('access-control-allow-credentials') + ).toBeNull() + + const response = await request(host, `${pathname}?q=one%20two`, { + method: 'POST', + body: JSON.stringify({ value: 'example' }), + headers: { + origin: 'https://browser.example', + 'content-type': 'application/json', + authorization: 'Bearer fixture-token', + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + method: 'POST', + query: { q: 'one two' }, + body: { value: 'example' }, + host, + }) + expect(response.headers.get('access-control-allow-origin')).toBe('*') + } + }) + + it('preserves API root and fallback responses without broadening CORS', async () => { + await start() + + for (const pathname of [ + '/', + '/v1/missing', + '/api/v1/missing', + '/v10/missing', + '/redirect/missing', + ]) { + const response = await request('api.localhost:3000', pathname) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ api: 'not found' }) + expect(response.headers.get('access-control-allow-origin')).toBe( + pathname.includes('/v1/') ? '*' : null + ) + } + }) + + it('preserves API OAuth, well-known and callback exclusions', async () => { + await start() + + for (const host of ['api.localhost:3000', 'platform.localhost:3000']) { + for (const route of [ + 'oauth/probe', + '.well-known/api-catalog', + '.well-known/microsoft-identity-association.json', + ]) { + const response = await request(host, `/${route}`) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ route }) + } + } + + for (const [pathname, route] of [ + ['/redirect/target', 'redirect/target'], + ['/secrets/oauth/callback', 'secrets/oauth/callback'], + [ + '/secrets/demo/manager/authenticate', + 'secrets/[secretId]/manager/authenticate', + ], + [ + '/secrets/demo/manager/oauth/callback', + 'secrets/[secretId]/manager/oauth/callback', + ], + ]) { + expect( + (await request('api.localhost:3000', pathname)).headers.get( + 'x-fixture-route' + ) + ).toBe(route) + } + }) + + it('rejects stale API hosts and spoofed selection or forwarded headers', async () => { + await start() + + for (const host of [ + 'build.api.example:3000', + 'apiXlocalhost', + 'api.localhost.attacker.example', + 'unrelated.example', + ]) { + const response = await request(host, '/v1/probe', { + headers: { + 'x-cbk-api': '1', + 'x-forwarded-host': 'api.localhost:3000', + }, + }) + + expect(response.status).toBe(404) + expect(response.headers.get('access-control-allow-origin')).toBeNull() + } + }) + + it('enables all mapped API hosts and the hosted scalar URL with the same build', async () => { + await start(undefined, undefined, undefined, undefined, { + API_URL: 'https://api.chatbotkit.com', + HOSTS_CONFIG: JSON.stringify({ + primary: { + match: ['example.com'], + site: 'example.com', + api: 'api.example.com', + static: 'static.example.com', + widgets: 'widgets.example.com', + }, + secondary: { + match: ['legacy.example.com'], + site: 'legacy.example.com', + api: 'api.legacy.example.com', + static: 'static.legacy.example.com', + widgets: 'widgets.legacy.example.com', + }, + single: { + match: ['single.example.com'], + site: 'single.example.com', + api: 'single.example.com', + static: 'single.example.com', + widgets: 'single.example.com', + }, + }), + }) + + for (const host of [ + 'api.example.com', + 'API.LEGACY.EXAMPLE.COM:3000', + 'api.chatbotkit.com', + ]) { + const response = await request(host, '/v1/probe') + + expect(response.status).toBe(200) + expect(response.headers.get('access-control-allow-origin')).toBe('*') + } + + for (const host of [ + 'example.com', + 'legacy.example.com', + 'single.example.com', + ]) { + expect( + (await request(host, '/signin')).headers.get('x-fixture-route') + ).toBe('signin') + expect( + (await request(host, '/v1/probe')).headers.get( + 'access-control-allow-origin' + ) + ).toBeNull() + expect((await request(host, '/api/v1/probe')).status).toBe(200) + } + }) + + it.each(['', 'http://platform.localhost:3000'])( + 'keeps the shared-site API when API_URL=%s', + async (apiUrl) => { + await start(undefined, undefined, undefined, undefined, { + API_URL: apiUrl, + HOSTS_CONFIG: '', + }) + + for (const host of [ + 'api.localhost:3000', + 'build.api.example:3000', + 'platform.localhost:3000', + ]) { + expect( + (await request(host, '/signin')).headers.get('x-fixture-route') + ).toBe('signin') + expect( + (await request(host, '/v1/probe')).headers.get( + 'access-control-allow-origin' + ) + ).toBeNull() + + const response = await request(host, '/api/v1/probe') + + expect(response.status).toBe(200) + expect(response.headers.get('access-control-allow-origin')).toBe('*') + } + } + ) + + it.each([ + 'apps.localhost:3000', + 'chat.app.localhost:3000', + 'test.portal.localhost:3000', + 'test.space.localhost:3000', + 'static.localhost:3000', + 'partner.example:3000', + ])( + 'preserves API rewrite precedence on the overlapping host %s', + async (host) => { + await start(undefined, undefined, undefined, undefined, { + API_URL: `http://${host}`, + STATIC_URL: 'http://static.localhost:3000', + HOSTS_CONFIG: '', + }) + + const response = await request(host, '/v1/probe') + + expect(response.status).toBe(200) + expect((await response.json()).host).toBe(host) + expect(response.headers.get('access-control-allow-origin')).toBe('*') + } + ) + + it('keeps API hostnames out of the built rewrite and header manifest', async () => { + const manifest = await fs.readFile( + path.join(directory, '.next/routes-manifest.json'), + 'utf8' + ) + + expect(manifest).toContain('x-cbk-api') + expect(manifest).not.toContain('build.api.example') + }) + + it('excludes the configured API host from browser security headers without relying on its prefix', async () => { + await start(undefined, undefined, undefined, undefined, { + API_URL: 'https://gateway.example.com', + HOSTS_CONFIG: '', + }) + + const response = await request('gateway.example.com', '/v1/probe') + + expect(response.status).toBe(200) + expect(response.headers.get('content-security-policy')).toBeNull() + expect(response.headers.get('x-frame-options')).toBeNull() + expect(response.headers.get('access-control-allow-origin')).toBe('*') + }) + + it('protects an ordinary site whose hostname starts with api', async () => { + await start() + + const response = await request('api.site.example', '/signin') + + expect(response.status).toBe(200) + expect(response.headers.get('content-security-policy')).toContain( + "frame-ancestors 'self'" + ) + expect(response.headers.get('x-frame-options')).toBe('SAMEORIGIN') + }) + + it('preserves embeddable policies across platform and static hosts', async () => { + await start() + + for (const host of [ + 'platform.localhost:3000', + 'static.localhost:3000', + 'api.site.example', + ]) { + for (const pathname of [ + '/integrations/widget/v1.js', + '/integrations/widget/demo/frame', + ]) { + const response = await request(host, pathname) + + expect(response.status).toBe(200) + expect(response.headers.get('x-frame-options')).toBeNull() + expect(response.headers.get('content-security-policy')).toContain( + 'frame-ancestors * capacitor: ionic:' + ) + expect(response.headers.get('content-security-policy')).toContain( + "form-action 'self'" + ) + expect(response.headers.get('cross-origin-resource-policy')).toBe( + 'cross-origin' + ) + expect( + response.headers.get('cross-origin-embedder-policy') + ).toBeNull() + expect(response.headers.get('cross-origin-opener-policy')).toBeNull() + expect(response.headers.get('strict-transport-security')).toBe( + 'max-age=31536000' + ) + } + } + }) + + it('allows the frame handler to tighten CSP without an additional permissive policy', async () => { + await start() + + const response = await request( + 'platform.localhost:3000', + '/integrations/widget/restricted/frame' + ) + const csp = response.headers.get('content-security-policy') + + expect(response.status).toBe(200) + expect(response.headers.get('x-frame-options')).toBeNull() + expect(csp).toContain("frame-ancestors 'self' https://allowed.example") + expect(csp).not.toContain('frame-ancestors *') + expect(csp.match(/frame-ancestors/g)).toHaveLength(1) + }) + + it('uses mapped API host classification and protects shared site/API hosts', async () => { + await start(undefined, undefined, undefined, undefined, { + API_URL: '', + HOSTS_CONFIG: JSON.stringify({ + dedicated: { + match: ['site.example'], + site: 'site.example', + api: 'gateway.example', + static: 'static.example', + widgets: 'widgets.example', + }, + shared: { + match: ['api.site.example'], + site: 'api.site.example', + api: 'api.site.example', + static: 'api.site.example', + widgets: 'api.site.example', + }, + }), + }) + + const api = await request('gateway.example', '/v1/probe') + const site = await request('api.site.example', '/signin') + + expect(api.status).toBe(200) + expect(api.headers.get('content-security-policy')).toBeNull() + expect(api.headers.get('access-control-allow-origin')).toBe('*') + expect(site.headers.get('x-frame-options')).toBe('SAMEORIGIN') + + const sharedApi = await request('api.site.example', '/api/v1/probe') + + expect(sharedApi.status).toBe(200) + expect(sharedApi.headers.get('content-security-policy')).toBeNull() + }) + + it('does not let forged routing headers disable the browser policy', async () => { + await start() + + const response = await request('platform.localhost:3000', '/signin', { + headers: { 'x-cbk-api': '1', 'x-forwarded-host': 'api.localhost:3000' }, + }) + + expect(response.headers.get('x-frame-options')).toBe('SAMEORIGIN') + expect(response.headers.get('content-security-policy')).toContain( + "frame-ancestors 'self'" + ) + }) + + it('applies browser policies to proxy redirects and page errors', async () => { + await start() + + for (const [host, pathname, status] of [ + ['partner.example', '/', 307], + ['chat.app.localhost:3000', '/overview', 307], + ['platform.localhost:3000', '/missing/page', 404], + ]) { + const response = await request(host, pathname) + + expect(response.status).toBe(status) + expect(response.headers.get('x-frame-options')).toBe('SAMEORIGIN') + } + + const apiError = await request('api.localhost:3000', '/v1/missing') + + expect(apiError.status).toBe(404) + expect(apiError.headers.get('content-security-policy')).toBeNull() + }) + + it('matches the previous native redirect destinations and status codes', async () => { + const paths = [ + '/signin/', + '/favicon.ico/', + ...(basePath ? ['/en/signin/', '/fr/signin/', '/fr/'] : []), + ] + + async function redirects() { + const results = [] + + for (const pathname of paths) { + const response = await request( + 'platform.localhost:3000', + `${pathname}?q=one%20two&q=three` + ) + + results.push({ + status: response.status, + location: new URL( + response.headers.get('location'), + 'http://platform.localhost:3000' + ).href, + }) + } + + return results + } + + await start() + + const runtimeRedirects = await redirects() + + await stop() + + // @note install the former native rules into the same fixture; they + // run before the proxy and provide the framework's reference behavior + const manifestPath = path.join(directory, '.next/routes-manifest.json') + const manifest = await fs.readFile(manifestPath, 'utf8') + const runtimeConfig = standaloneConfig + const nativeConfig = { + ...JSON.parse(standaloneConfig), + skipTrailingSlashRedirect: false, + skipProxyUrlNormalize: false, + } + const nativeRoutes = await loadCustomRoutes(nativeConfig) + + try { + await fs.writeFile( + manifestPath, + JSON.stringify({ + ...JSON.parse(manifest), + redirects: nativeRoutes.redirects, + }) + ) + standaloneConfig = JSON.stringify(nativeConfig) + await start() + + expect(runtimeRedirects).toEqual(await redirects()) + } finally { + await stop() + standaloneConfig = runtimeConfig + await fs.writeFile(manifestPath, manifest) + } + }) + + it('preserves security headers and destinations on permanent redirects', async () => { + await start(undefined, undefined, undefined, undefined, { + SITE_URL: 'https://site.example', + API_URL: 'https://gateway.example', + HOSTS_CONFIG: '', + SENTRY_HEADERS_REPORT_URI: 'https://runtime.report.example/csp', + }) + + const paths = [ + ['/signin/', `${basePath}/signin`], + ['/favicon.ico/', `${basePath}/favicon.ico`], + [ + '/integrations/widget/test/frame/', + `${basePath}/integrations/widget/test/frame`, + ], + ...(basePath + ? [ + ['/en/signin/', `${basePath}/signin`], + ['/fr/signin/', `${basePath}/fr/signin`], + ] + : []), + ] + + for (const [pathname, target] of paths) { + for (const host of ['site.example', 'gateway.example']) { + const response = await request( + host, + `${pathname}?q=one%20two&q=three`, + { method: 'POST', body: 'value=kept' } + ) + const location = new URL( + response.headers.get('location'), + `http://${host}` + ) + + expect(response.status).toBe(308) + expect(location.origin + location.pathname).toBe( + new URL(target, `http://${host}`).href + ) + expect(location.searchParams.getAll('q')).toEqual([ + 'one two', + 'three', + ]) + expect(response.headers.get('refresh')).toBe( + `0;url=${response.headers.get('location')}` + ) + + if (host === 'gateway.example') { + expect(response.headers.get('content-security-policy')).toBeNull() + expect(response.headers.get('strict-transport-security')).toBeNull() + } else { + expect(response.headers.get('content-security-policy')).toContain( + 'report-uri https://runtime.report.example/csp' + ) + expect(response.headers.get('strict-transport-security')).toBe( + pathname.includes('/widget/') + ? 'max-age=31536000' + : 'max-age=31536000; includeSubDomains; preload' + ) + expect(response.headers.get('referrer-policy')).toBe( + pathname.includes('/widget/') + ? 'strict-origin-when-cross-origin' + : 'same-origin' + ) + } + } + } + }) + + it('keeps canonical redirects ahead of host routing and preserves public ports', async () => { + await start() + + for (const host of [ + 'chat.app.localhost:3000', + 'apps.localhost:3000', + 'acme.partners.localhost:3000', + 'partner.example', + 'test.portal.localhost:3000', + 'test.space.localhost:3000', + 'static.localhost:3000', + ]) { + const response = await request(host, '/overview/?q=one', { + method: 'HEAD', + }) + const location = new URL( + response.headers.get('location'), + `http://${host}` + ) + + expect(response.status).toBe(308) + expect(location.href).toBe(`http://${host}${basePath}/overview?q=one`) + expect(response.headers.get('x-frame-options')).toBe('SAMEORIGIN') + + const secure = await request(host, '/signin/', { + headers: { 'x-forwarded-proto': 'https' }, + }) + + expect(secure.status).toBe(308) + expect(secure.headers.get('location')).toBe( + `https://${host}${basePath}/signin` + ) + } + }) + + it('uses the runtime reporting URI and transport policy with the same build', async () => { + await start(undefined, undefined, undefined, undefined, { + API_URL: '', + HOSTS_CONFIG: '', + SITE_URL: 'https://site.example', + SENTRY_HEADERS_REPORT_URI: 'https://runtime.report.example/csp', + }) + + const response = await request('site.example', '/signin') + const csp = response.headers.get('content-security-policy') + + expect(response.status).toBe(200) + expect(csp).toContain('report-uri https://runtime.report.example/csp') + expect(csp).not.toContain('build.report.example') + expect(csp).toContain("connect-src 'self' https: wss: blob: data:") + }) + + it('keeps browser security policy out of the built header manifest', async () => { + const manifest = await fs.readFile( + path.join(directory, '.next/routes-manifest.json'), + 'utf8' + ) + + expect(manifest).not.toContain('Content-Security-Policy') + expect(manifest).not.toContain('X-Frame-Options') + expect(JSON.parse(manifest).redirects).toEqual([]) + }) } ) diff --git a/platform/lib/security.headers.js b/platform/lib/security.headers.js index c712c4b..be5da1f 100644 --- a/platform/lib/security.headers.js +++ b/platform/lib/security.headers.js @@ -1,4 +1,4 @@ -/* eslint-disable import/extensions -- loaded by next.config.d at build time, where the alias does not resolve */ +/* eslint-disable import/extensions */ // @ts-check import { siteUrl } from '../config/site.js' @@ -287,17 +287,6 @@ const EMBEDDABLE_PATHS = [ String.raw`/integrations/mcpserver/v1\.js`, ] -/** - * Hosts that should be excluded from security headers entirely - * - * @type {string[]} - */ -const EXCLUDE_HOSTS = [ - // API routes don't need browser security headers - - String.raw`api\.`, -] - /** * Paths that should be excluded from security headers entirely * @@ -316,6 +305,128 @@ const EXCLUDE_PATHS = [ */ const REPORT_URI = process.env.SENTRY_HEADERS_REPORT_URI +/** + * Converts policy fields into HTTP response headers. + * + * @param {SecurityHeadersConfig} config + * @returns {Array<{ key: string, value: string}>} + */ +function configToHeaders(config) { + let headers = [] + + if (config.xFrameOptions) { + headers.push({ key: 'X-Frame-Options', value: config.xFrameOptions }) + } + + if (config.contentSecurityPolicy) { + let cspValue = config.contentSecurityPolicy + + if (REPORT_URI) { + cspValue += `; report-uri ${REPORT_URI}` + } + + headers.push({ + key: 'Content-Security-Policy', + value: cspValue, + }) + } + + if (config.xContentTypeOptions) { + headers.push({ + key: 'X-Content-Type-Options', + value: config.xContentTypeOptions, + }) + } + + if (config.referrerPolicy) { + headers.push({ key: 'Referrer-Policy', value: config.referrerPolicy }) + } + + if (config.permissionsPolicy) { + headers.push({ key: 'Permissions-Policy', value: config.permissionsPolicy }) + } + + if (config.strictTransportSecurity) { + headers.push({ + key: 'Strict-Transport-Security', + value: config.strictTransportSecurity, + }) + } + + if (config.xXssProtection) { + headers.push({ key: 'X-XSS-Protection', value: config.xXssProtection }) + } + + if (config.crossOriginEmbedderPolicy) { + headers.push({ + key: 'Cross-Origin-Embedder-Policy', + value: config.crossOriginEmbedderPolicy, + }) + } + + if (config.crossOriginOpenerPolicy) { + headers.push({ + key: 'Cross-Origin-Opener-Policy', + value: config.crossOriginOpenerPolicy, + }) + } + + if (config.crossOriginResourcePolicy) { + headers.push({ + key: 'Cross-Origin-Resource-Policy', + value: config.crossOriginResourcePolicy, + }) + } + + // Filter out any empty values + { + headers = headers.filter( + (header) => header.value && header.value.length > 0 + ) + } + + return headers +} + +// @note retain the existing path expressions and case-insensitive matching +// used by Next's header rules; host classification belongs to the proxy +const embeddablePathPattern = EMBEDDABLE_PATHS.map((path) => + path.replace(/^\//, '') +).join('|') +const excludePathPattern = EXCLUDE_PATHS.map((path) => + path.replace(/^\//, '') +).join('|') +const defaultPathMatcher = new RegExp( + `^/(?!${embeddablePathPattern}|${excludePathPattern}).*$`, + 'i' +) +const embeddablePathMatcher = new RegExp( + `^/(?:${embeddablePathPattern})/?$`, + 'i' +) + +const defaultHeaders = configToHeaders(DEFAULT_SECURITY_HEADERS) +const embeddableHeaders = configToHeaders(EMBEDDABLE_SECURITY_HEADERS) + +/** + * Selects browser security headers for a pathname without its base path or locale. + * API host exclusion is applied by the runtime proxy before calling this. + * + * @param {string} pathname + * @returns {Array<{ key: string, value: string }>} + */ +function getSecurityHeaders(pathname) { + if (defaultPathMatcher.test(pathname)) { + return defaultHeaders + } + + if (embeddablePathMatcher.test(pathname)) { + return embeddableHeaders + } + + return [] +} + /** * Build the Content-Security-Policy for an embeddable surface that restricts * framing to an explicit origin whitelist. The result is the full embeddable @@ -365,8 +476,8 @@ export { DEFAULT_SECURITY_HEADERS, EMBEDDABLE_SECURITY_HEADERS, EMBEDDABLE_PATHS, - EXCLUDE_HOSTS, EXCLUDE_PATHS, REPORT_URI, buildOriginRestrictedCsp, + getSecurityHeaders, } diff --git a/platform/lib/security.headers.utest.js b/platform/lib/security.headers.utest.js index 08a78d3..71adf00 100644 --- a/platform/lib/security.headers.utest.js +++ b/platform/lib/security.headers.utest.js @@ -1,3 +1,5 @@ +import { siteUrl } from '@/config/site' + import { ALLOWED_FRAME_ANCESTORS, DEFAULT_SECURITY_HEADERS, @@ -5,10 +7,9 @@ import { EMBEDDABLE_SECURITY_HEADERS, EXCLUDE_PATHS, buildOriginRestrictedCsp, + getSecurityHeaders, } from '@/lib/security.headers' -import { siteUrl } from '@/config/site' - describe('Security Headers Configuration', () => { describe('DEFAULT_SECURITY_HEADERS', () => { it('should be defined as an object with security properties', () => { @@ -203,7 +204,9 @@ describe('Security Headers Configuration', () => { it('returns undefined when no valid origin is configured', () => { expect(buildOriginRestrictedCsp()).toBeUndefined() expect(buildOriginRestrictedCsp('')).toBeUndefined() - expect(buildOriginRestrictedCsp('example.com, javascript:')).toBeUndefined() + expect( + buildOriginRestrictedCsp('example.com, javascript:') + ).toBeUndefined() }) it('restricts frame-ancestors to self plus the whitelisted origins', () => { @@ -238,3 +241,70 @@ describe('Security Headers Configuration', () => { }) }) }) + +describe('runtime browser security path selection', () => { + it.each([ + '/', + '/signin', + '/overview', + '/api', + '/ordinary/page', + '/favicon.ico', + ])('protects the ordinary path %s', (pathname) => { + const headers = new Headers( + getSecurityHeaders(pathname).map(({ key, value }) => [key, value]) + ) + + expect(headers.get('x-frame-options')).toBe('SAMEORIGIN') + expect(headers.get('content-security-policy')).toContain( + "frame-ancestors 'self'" + ) + expect(headers.get('content-security-policy')).toContain( + "form-action 'self'" + ) + expect(headers.get('referrer-policy')).toBe('same-origin') + expect(headers.get('x-content-type-options')).toBe('nosniff') + }) + + it.each([ + '/integrations/widget/v1.js', + '/integrations/widget/v2.js', + '/integrations/widget/plugins/analytics-consent.js', + '/integrations/widget/demo/frame', + '/integrations/widget/demo/frame/thread', + '/integrations/anam/demo/frame', + '/integrations/avatar/demo/frame', + '/integrations/mcpserver/v1.js', + '/integrations/mcpserver/demo/frame', + '/examples/demo/preview', + '/examples/demo/card', + '/INTEGRATIONS/WIDGET/demo/FRAME', + '/integrations/widget/demo/frame/', + ])('keeps the embedding policy on %s', (pathname) => { + const headers = new Headers( + getSecurityHeaders(pathname).map(({ key, value }) => [key, value]) + ) + + expect(headers.get('x-frame-options')).toBeNull() + expect(headers.get('content-security-policy')).toContain( + 'frame-ancestors * capacitor: ionic:' + ) + expect(headers.get('content-security-policy')).toContain( + "form-action 'self'" + ) + expect(headers.get('referrer-policy')).toBe( + 'strict-origin-when-cross-origin' + ) + expect(headers.get('cross-origin-resource-policy')).toBe('cross-origin') + expect(headers.get('cross-origin-embedder-policy')).toBeNull() + expect(headers.get('cross-origin-opener-policy')).toBeNull() + expect(headers.get('strict-transport-security')).toBe('max-age=31536000') + }) + + it.each(['/api/v1/probe', '/API/v1/probe', '/api/oauth/token'])( + 'preserves the API path exclusion for %s', + (pathname) => { + expect(getSecurityHeaders(pathname)).toEqual([]) + } + ) +}) diff --git a/platform/next-sitemap.config.js b/platform/next-sitemap.config.js deleted file mode 100644 index da6a084..0000000 --- a/platform/next-sitemap.config.js +++ /dev/null @@ -1,153 +0,0 @@ -/* eslint-disable import/no-anonymous-default-export */ -// @ts-check -import { ok } from 'assert' - -ok(process.env.SITE_URL, 'site url not set') - -// @note this application emits a plain, self-contained sitemap: its own -// rendered pages plus the paths and dynamic section sitemaps below. On -// deployments fronted by a zone application (see next.config.d/zone.config.js) -// the zone may overlay /sitemap.xml with a domain-wide index of its own; the -// generated chunk files (/sitemap-0.xml) and the section sitemaps stay -// reachable for such an index to reference. - -export const additionalPaths = [ - // @todo make these dynamic - - '/llms.txt', - - '/bots', - '/datasets', - '/skillsets', - '/integrations', - - '/hub', - '/hub/blueprints', - '/hub/blueprints/latest', - '/hub/bots', - '/hub/bots/latest', - '/hub/datasets', - '/hub/datasets/latest', - '/hub/skillsets', - '/hub/skillsets/latest', - '/hub/widgets', - '/hub/widgets/latest', - - '/apps', - '/apps/chat', - '/apps/inbox', - '/apps/usage', -] - -export const additionalSitemaps = [ - // @todo make these dynamic - - '/examples/sitemap.xml', - '/connections/sitemap.xml', - // hub - - '/hub/blueprints/latest/sitemap.xml', - '/hub/bots/latest/sitemap.xml', - '/hub/datasets/latest/sitemap.xml', - '/hub/skillsets/latest/sitemap.xml', - '/hub/widgets/latest/sitemap.xml', - - // platform - - '/platform/models/sitemap.xml', -] - -export const exclude = [ - // @todo make these dynamic - - // next - - '/_next/*', - - // api - - '/api/v1/*', - - // landing - - '/landing', - '/landing/*', - - // ui - - '/changelog/bar', - - // admin - - '/admin', - '/admin/*', - - // experiments - - '/experiments/*', - - // dashboard - - '/signin', - '/signin/verify', - '/redirect', - '/welcome', - '/new', - '/new/*', - '/overview', - '/billing', - '/billing/*', - '/usage', - - // utility - - '/**/404', - '/**/500', - - // auxiliary, non-canonical views - marked noindex via X-Robots-Tag in - // next.config.d/seo.config.js; keep them out of the sitemap too - - '/card', - '/**/card', - '/**/designer', - - // other - - // sitemaps - - ...additionalSitemaps.map((sitemap) => { - return sitemap.replace('/sitemap.xml', '/*') - }), -] - -/** @type {import('next-sitemap').IConfig} */ -export default { - siteUrl: process.env.SITE_URL, - - generateRobotsTxt: true, - generateIndexSitemap: true, - - sitemapSize: 10000, - - exclude: exclude, - - additionalPaths() { - return additionalPaths.map((loc) => { - return { - loc: loc, - } - }) - }, - - robotsTxtOptions: { - transformRobotsTxt: async (_config, robotsTxt) => { - return robotsTxt.replace( - 'Allow: /', - `Allow: /\nContent-Signal: ai-train=no, search=yes, ai-input=yes` - ) - }, - additionalSitemaps: additionalSitemaps.map((sitemap) => { - return new URL(sitemap, process.env.SITE_URL).toString() - }), - }, -} diff --git a/platform/next.config.d/api.config.js b/platform/next.config.d/api.config.js index 9bedf95..ad95f73 100644 --- a/platform/next.config.d/api.config.js +++ b/platform/next.config.d/api.config.js @@ -1,30 +1,27 @@ /* eslint-disable import/extensions */ // @ts-check -import { hosts } from '../config/hosts.js' -import { apiHostname, siteHostname } from '../config/site.js' -import { - buildCaptureAllSource, - buildHostPattern, -} from '../lib/nextjs.config.rewrites.js' +import { buildCaptureAllSource } from '../lib/nextjs.config.rewrites.js' -// @note every configured API target is routed unconditionally, mirroring the -// static targets in static.config.js: the hostnames named by HOSTS_CONFIG plus -// the API_URL scalar. An API host that is also a site host - API_URL left at -// its site URL default, or a single-domain HOSTS_CONFIG mapping where the API -// answers under /api on the site host - derives no routing, as the capture-all -// below would otherwise swallow the site itself. - -const siteHostnames = new Set([siteHostname, ...hosts.site]) - -const apiHostnames = [...new Set([...hosts.api, apiHostname])].filter( - (hostname) => hostname && !siteHostnames.has(hostname) -) - -const apiHostPattern = buildHostPattern(apiHostnames, 'host') +// @note the proxy selects dedicated API hosts at startup, excluding site hosts +const apiHostHas = [ + { + type: /** @type {'header'} */ ('header'), + key: 'x-cbk-api', + value: '1', + }, +] -const apiHostHas = apiHostPattern - ? [{ type: /** @type {'host'} */ ('host'), value: apiHostPattern }] - : [] +// @note browser API access uses bearer tokens and deliberately allows any +// origin without credentials; the proxy shares this policy for clean /v1 paths +export const apiCorsHeaders = [ + { key: 'Access-Control-Allow-Origin', value: '*' }, + { key: 'Access-Control-Allow-Methods', value: 'GET,POST' }, + { + key: 'Access-Control-Allow-Headers', + value: + 'X-Requested-With, Accept, Content-Length, Content-Type, Authorization', + }, +] // @note well-known endpoints that are not related to OAuth or the API catalog // are rewritten here. They are deliberately NOT host-gated: they are served on @@ -44,18 +41,6 @@ const wellKnownRewrites = [ /** @type {import('next').NextConfig} */ export default { async rewrites() { - if (!apiHostPattern) { - return { - beforeFiles: [ - // @note the well-known endpoints are not host-gated - they are - // served on the deployment's own host either way - ...wellKnownRewrites, - ], - afterFiles: [], - fallback: [], - } - } - return { beforeFiles: [ ...wellKnownRewrites, @@ -79,7 +64,7 @@ export default { // @note oauth endpoints are rewritten by oauth.config.js - 'oauth\/', + 'oauth/', // @note portals can also serve secret callbacks @@ -118,52 +103,12 @@ export default { }, async headers() { - const corsHeaders = [ - { - key: 'Access-Control-Allow-Origin', - value: '*', - }, - { - key: 'Access-Control-Allow-Methods', - value: 'GET,POST', - }, - { - key: 'Access-Control-Allow-Headers', - value: - 'X-Requested-With, Accept, Content-Length, Content-Type, Authorization', - }, - ] - return [ - // @note browser access to the v1 API is deliberately public. An earlier - // comment here claimed the opposite - that CORS was restricted to a - // specified origin - which the `*` below has never matched. It is `*` - // on purpose: v1 authenticates with a bearer token, not a cookie, and - // no `Access-Control-Allow-Credentials` is sent, so a foreign origin can - // only reach the API with a token its own user gave it. Restricting the - // origin would break every browser SDK caller without protecting - // anything. - - // @note the clean `/v1` path only exists where the API answers on a host - // of its own, so this rule is emitted only then - ...(apiHostPattern - ? [ - { - source: '/v1/:path*', - has: apiHostHas, - headers: [...corsHeaders], - }, - ] - : []), - - // @note `/api/v1` is the path every deployment serves, including a - // single-domain one with no API subdomain at all, so it carries the CORS - // headers unconditionally. On a deployment that does have an API host - // this is the same content already reachable cross-origin through the - // rule above - the headers follow the endpoint rather than the hostname. + // @note this path is available on every host; host-dependent /v1 CORS + // is applied by the proxy because configured headers run before it { source: '/api/v1/:path*', - headers: [...corsHeaders], + headers: [...apiCorsHeaders], }, ] }, diff --git a/platform/next.config.d/api.config.utest.js b/platform/next.config.d/api.config.utest.js index d910cf5..3f14775 100644 --- a/platform/next.config.d/api.config.utest.js +++ b/platform/next.config.d/api.config.utest.js @@ -1,257 +1,73 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ - -// @note the module reads HOSTS_CONFIG once at load, through the shared -// hosts table, so each deployment shape here is loaded in isolation rather -// than by mutating an already-parsed config. - -/** - * Loads api.config with the given HOSTS_CONFIG table. - * - * @param {object|undefined} hosts - the mappings, or undefined for a deployment - * that sets no hostnames at all - * @param {{API_URL?: string, SITE_URL?: string}} [scalars] - the scalar - * variables, unset unless given - */ -function loadConfig(hosts, scalars = {}) { - let config - - jest.isolateModules(() => { - if (hosts === undefined) { - process.env.HOSTS_CONFIG = JSON.stringify({}) - } else { - process.env.HOSTS_CONFIG = JSON.stringify(hosts) - } - - for (const name of ['API_URL', 'SITE_URL']) { - if (scalars[name] === undefined) { - delete process.env[name] - } else { - process.env[name] = scalars[name] - } - } - - config = require('./api.config').default - }) - - return config -} - -/** - * The single `has` host pattern a rule matches on, or undefined when the rule - * is not host-gated. - */ -function hostPattern(rule) { - return rule.has?.[0]?.value -} - -const HOSTED = { - primary: { - match: ['console.example.com', 'api.example.com'], - site: 'console.example.com', - api: 'api.example.com', - static: 'static.example.com', - widgets: 'widgets.example.com', - }, - secondary: { - match: ['console.example.net', 'api.example.net'], - site: 'console.example.net', - api: 'api.example.net', - static: 'static.example.net', - widgets: 'widgets.example.net', - }, -} -const CUSTOM_SUBDOMAIN = { - custom: { - match: ['example.org', 'api.example.org'], - site: 'example.org', - api: 'api.example.org', - static: 'static.example.org', - widgets: 'widgets.example.org', - }, -} - -describe('api.config', () => { - const original = { - HOSTS_CONFIG: process.env.HOSTS_CONFIG, - API_URL: process.env.API_URL, - SITE_URL: process.env.SITE_URL, - } - - afterEach(() => { - for (const [name, value] of Object.entries(original)) { - if (value === undefined) { - delete process.env[name] - } else { - process.env[name] = value - } - } - }) - - describe('hosted deployment - several API hostnames', () => { - it('gates the API rewrites on every named host', async () => { - const { beforeFiles, fallback } = await loadConfig(HOSTED).rewrites() - - const capture = beforeFiles.find( - (rule) => rule.destination === '/api/:path*' - ) - - expect(hostPattern(capture)).toBe( - '(?api\\.example\\.com|api\\.example\\.net)' - ) - - // the API root and the 404 fallback answer on the same hosts - expect( - hostPattern(beforeFiles.find((rule) => rule.destination === '/api')) - ).toBe(hostPattern(capture)) - - expect( - hostPattern(fallback.find((rule) => rule.destination === '/api/404')) - ).toBe(hostPattern(capture)) - }) - - it('serves the clean /v1 CORS rule as well as /api/v1', async () => { - const headers = await loadConfig(HOSTED).headers() - - expect(headers.map((rule) => rule.source)).toEqual([ - '/v1/:path*', - '/api/v1/:path*', - ]) +/** @jest-environment node */ +import { unstable_getResponseFromNextConfig } from 'next/experimental/testing/server' + +import apiConfig from './api.config' + +// @note runtime hostname and clean /v1 CORS selection are covered in proxy +// tests; this config retains the API path rules and unconditional /api/v1 CORS +describe('API path routing', () => { + it.each([ + ['/', '/api/'], + ['/v1/probe', '/api/v1/probe'], + ['/health', '/api/health'], + ])('rewrites %s on a classified API host', async (pathname, destination) => { + const response = await unstable_getResponseFromNextConfig({ + nextConfig: apiConfig, + url: `https://api.example.com${pathname}`, + headers: { 'x-cbk-api': '1' }, }) - }) - describe('custom API subdomain', () => { - it('gates on the configured API host', async () => { - const { beforeFiles } = await loadConfig(CUSTOM_SUBDOMAIN).rewrites() - - const capture = beforeFiles.find( - (rule) => rule.destination === '/api/:path*' - ) - - expect(hostPattern(capture)).toBe('(?api\\.example\\.org)') - }) + expect(response.headers.get('x-middleware-rewrite')).toBe( + `https://api.example.com${destination}` + ) }) - describe('API_URL scalar', () => { - it('routes a dedicated API_URL alongside the configured targets', async () => { - const { beforeFiles } = await loadConfig(CUSTOM_SUBDOMAIN, { - API_URL: 'https://api.example.io', - SITE_URL: 'https://example.org', - }).rewrites() - - const capture = beforeFiles.find( - (rule) => rule.destination === '/api/:path*' - ) - - expect(hostPattern(capture)).toBe( - '(?api\\.example\\.org|api\\.example\\.io)' - ) - }) - - it('routes a dedicated API_URL with no HOSTS_CONFIG at all', async () => { - const { beforeFiles } = await loadConfig(undefined, { - API_URL: 'https://api.example.com', - SITE_URL: 'https://example.com', - }).rewrites() - - const capture = beforeFiles.find( - (rule) => rule.destination === '/api/:path*' + it.each([{}, { 'x-cbk-api': 'untrusted' }, { 'x-cbk-static': '1' }])( + 'requires the API classification marker: %j', + async (headers) => { + const response = await unstable_getResponseFromNextConfig({ + nextConfig: apiConfig, + url: 'https://api.example.com/v1/probe', + headers, + }) + + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + expect(response.headers.get('access-control-allow-origin')).toBeNull() + } + ) + + it.each(['api-catalog', 'microsoft-identity-association.json'])( + 'serves the well-known %s without an API host', + async (path) => { + const response = await unstable_getResponseFromNextConfig({ + nextConfig: apiConfig, + url: `https://example.com/.well-known/${path}`, + }) + + expect(response.headers.get('x-middleware-rewrite')).toBe( + `https://example.com/api/.well-known/${path}` ) - - expect(hostPattern(capture)).toBe('(?api\\.example\\.com)') - }) - - it('derives no routing from the single-domain meaning - API_URL equal to the site URL', async () => { - const { beforeFiles, fallback } = await loadConfig(undefined, { - API_URL: 'https://example.com', - SITE_URL: 'https://example.com', - }).rewrites() - - expect(beforeFiles.every((rule) => rule.has === undefined)).toBe(true) - expect(fallback).toEqual([]) - }) - - it('does not duplicate a host already named by HOSTS_CONFIG', async () => { - const { beforeFiles } = await loadConfig(CUSTOM_SUBDOMAIN, { - API_URL: 'https://api.example.org', - SITE_URL: 'https://example.org', - }).rewrites() - - const capture = beforeFiles.find( - (rule) => rule.destination === '/api/:path*' + } + ) + + it.each(['/api/v1', '/api/v1/probe'])( + 'keeps public, credential-free CORS on %s for every host', + async (pathname) => { + const response = await unstable_getResponseFromNextConfig({ + nextConfig: apiConfig, + url: `https://example.com${pathname}`, + }) + + expect(response.headers.get('access-control-allow-origin')).toBe('*') + expect(response.headers.get('access-control-allow-methods')).toBe( + 'GET,POST' ) - - expect(hostPattern(capture)).toBe('(?api\\.example\\.org)') - }) - }) - - describe('single-domain HOSTS_CONFIG mapping - API host equal to site host', () => { - it('derives no routing from the mapping, only from dedicated API hosts', async () => { - const { beforeFiles } = await loadConfig({ - single: { - match: ['single.example.com'], - site: 'single.example.com', - api: 'single.example.com', - static: 'single.example.com', - widgets: 'single.example.com', - }, - ...CUSTOM_SUBDOMAIN, - }).rewrites() - - const capture = beforeFiles.find( - (rule) => rule.destination === '/api/:path*' + expect(response.headers.get('access-control-allow-headers')).toBe( + 'X-Requested-With, Accept, Content-Length, Content-Type, Authorization' ) - - expect(hostPattern(capture)).toBe('(?api\\.example\\.org)') - }) - }) - - describe('single-domain deployment - no API host named', () => { - it('emits no host-gated rewrites at all', async () => { - const { beforeFiles, fallback } = await loadConfig(undefined).rewrites() - - // a rule that can never match is worse than no rule: it reads as - // configured routing while silently doing nothing - expect(beforeFiles.every((rule) => rule.has === undefined)).toBe(true) - expect(fallback).toEqual([]) - }) - - it('still serves the well-known endpoints, which are not host-gated', async () => { - const { beforeFiles } = await loadConfig(undefined).rewrites() - - expect(beforeFiles.map((rule) => rule.source)).toEqual([ - '/.well-known/api-catalog', - '/.well-known/microsoft-identity-association.json', - ]) - }) - - it('still serves CORS on /api/v1, the path every deployment has', async () => { - const headers = await loadConfig(undefined).headers() - - expect(headers.map((rule) => rule.source)).toEqual(['/api/v1/:path*']) - - expect(headers[0].has).toBeUndefined() - }) - }) - - describe('CORS policy', () => { - it.each([HOSTED, CUSTOM_SUBDOMAIN, undefined])( - 'is public and credential-free whatever the topology', - async (hosts) => { - const headers = await loadConfig(hosts).headers() - - const apiV1 = headers.find((rule) => rule.source === '/api/v1/:path*') - - const value = (key) => - apiV1.headers.find((header) => header.key === key)?.value - - expect(value('Access-Control-Allow-Origin')).toBe('*') - - // `*` is only safe because no credentials are allowed alongside it - - // v1 authenticates by bearer token, never by cookie - expect(value('Access-Control-Allow-Credentials')).toBeUndefined() - - expect(value('Access-Control-Allow-Headers')).toContain('Authorization') - } - ) - }) + expect( + response.headers.get('access-control-allow-credentials') + ).toBeNull() + } + ) }) diff --git a/platform/next.config.d/content.config.js b/platform/next.config.d/content.config.js deleted file mode 100644 index 934ac96..0000000 --- a/platform/next.config.d/content.config.js +++ /dev/null @@ -1,11 +0,0 @@ -export default { - async redirects() { - return [ - { - source: '/manuals/:path*', - destination: 'https://docs.cbk.ai/:path*', - permanent: true, - }, - ] - }, -} diff --git a/platform/next.config.d/content.config.utest.js b/platform/next.config.d/content.config.utest.js deleted file mode 100644 index 6ae616a..0000000 --- a/platform/next.config.d/content.config.utest.js +++ /dev/null @@ -1,15 +0,0 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ - -const config = require('./content.config').default - -describe('content.config', () => { - it('redirects platform-owned content routes to their publishing sites', async () => { - await expect(config.redirects()).resolves.toEqual([ - { - source: '/manuals/:path*', - destination: 'https://docs.cbk.ai/:path*', - permanent: true, - }, - ]) - }) -}) diff --git a/platform/next.config.d/partner.config.js b/platform/next.config.d/partner.config.js index 08351ef..e48339c 100644 --- a/platform/next.config.d/partner.config.js +++ b/platform/next.config.d/partner.config.js @@ -1,174 +1,34 @@ -/* eslint-disable import/no-anonymous-default-export, import/extensions */ +/* eslint-disable import/no-anonymous-default-export */ // @ts-check -import partnersConfig from '@chatbotkit-dev/partners' -import { APEXES } from '../config/apexes.js' -import { escapeRegex } from '../lib/nextjs.config.rewrites.js' -/** - * Select the partner based on the subdomain and rewrite the URL to the - * partner's signin page. - */ - -/** - * Define the conditions for matching partner hostnames. - * - * @type {Array<{ type: 'host'; value: string }> | null} - */ -// @note apex-derived partner hosts exist only when PARTNERS_APEX is set; -// partners with a custom domain keep their own rules -const has = APEXES.partners - ? [ - { - type: /** @type {'host'} */ ('host'), - value: `(?.+?).${escapeRegex(APEXES.partners)}`, - }, - ] - : null - -/** - * Extract the subset of partners that have a custom domain configured. - * Used to generate per-host rewrites and redirects below. - * - * @type {Array<{ slug: string; domain: string }>} - */ -const customDomainEntries = Object.entries(partnersConfig).reduce( - (result, [slug, partner]) => { - if (typeof partner.domain === 'string') { - result.push({ slug, domain: partner.domain }) - } - - return result +// @note the proxy selects partner hosts at runtime and replaces this marker; +// the config owns sign-in paths while branding and root redirects run in proxy +const has = [ + { + type: /** @type {'header'} */ ('header'), + key: 'x-cbk-partner', + value: '(?.+)', }, - /** @type {Array<{ slug: string; domain: string }>} */ ([]) -) - -function encodeBranding(partner) { - const branding = { - name: partner.name, - logo: partner.logo, - icon: partner.icon, - whitelabel: !!partner.whitelabel, - experience: partner.experience, - } - - // @note base64 keeps the JSON safe inside the Server-Timing desc token - return Buffer.from(JSON.stringify(branding), 'utf8').toString('base64') -} +] /** @type {import('next').NextConfig} */ export default { async rewrites() { return { beforeFiles: [ - ...(has - ? [ - { - source: '/signin', - has: has, - destination: '/partner/signin/:slug', - }, - { - source: '/signin/:path*', - has: has, - destination: '/partner/signin/:slug/:path*', - }, - ] - : []), - - ...customDomainEntries.map(({ slug, domain }) => ({ + { source: '/signin', - has: [ - { - type: /** @type {'host'} */ ('host'), - value: domain, - }, - ], - destination: `/partner/signin/${slug}`, - })), - - ...customDomainEntries.map(({ slug, domain }) => ({ + has, + destination: '/partner/signin/:slug', + }, + { source: '/signin/:path*', - has: [ - { - type: /** @type {'host'} */ ('host'), - value: domain, - }, - ], - destination: `/partner/signin/${slug}/:path*`, - })), + has, + destination: '/partner/signin/:slug/:path*', + }, ], - afterFiles: [], - fallback: [], } }, - - async headers() { - // @note server-timing headers are added but they do not have effect in the - // vercel environment at all - it is a known issue - - return [ - ...(APEXES.partners - ? Object.entries(partnersConfig).map(([slug, partner]) => ({ - source: '/:path*', - has: [ - { - type: /** @type {'host'} */ ('host'), - value: `${slug}.${APEXES.partners}`, - }, - ], - headers: [ - { - key: 'Server-Timing', - value: `partner;desc="${encodeBranding(partner)}"`, - }, - ], - })) - : []), - - ...customDomainEntries.map(({ slug, domain }) => ({ - source: '/:path*', - has: [ - { - type: /** @type {'host'} */ ('host'), - value: domain, - }, - ], - headers: [ - { - key: 'Server-Timing', - value: `partner;desc="${encodeBranding(partnersConfig[slug])}"`, - }, - ], - })), - ] - }, - - async redirects() { - return [ - ...(has - ? [ - { - source: '/', - has: has, - destination: '/overview', - permanent: false, - }, - ] - : []), - - ...customDomainEntries.map(({ domain }) => ({ - source: '/', - has: [ - { - type: /** @type {'host'} */ ('host'), - value: domain, - }, - ], - destination: '/overview', - permanent: false, - })), - ] - }, } diff --git a/platform/next.config.d/proxy.config.js b/platform/next.config.d/proxy.config.js new file mode 100644 index 0000000..6539d57 --- /dev/null +++ b/platform/next.config.d/proxy.config.js @@ -0,0 +1,11 @@ +// @ts-check + +/** @type {import('next').NextConfig} */ +export default { + // @note the proxy applies runtime host policies before canonical redirects + skipTrailingSlashRedirect: true, + + // @note retain the original locale prefix and path for canonical redirects; + // the proxy normalizes page-data paths separately for host routing + skipProxyUrlNormalize: true, +} diff --git a/platform/next.config.d/security.config.js b/platform/next.config.d/security.config.js deleted file mode 100644 index e16ae40..0000000 --- a/platform/next.config.d/security.config.js +++ /dev/null @@ -1,152 +0,0 @@ -/* eslint-disable import/extensions, import/no-anonymous-default-export */ -// @ts-check -import { - DEFAULT_SECURITY_HEADERS, - EMBEDDABLE_PATHS, - EMBEDDABLE_SECURITY_HEADERS, - EXCLUDE_HOSTS, - EXCLUDE_PATHS, - REPORT_URI, -} from '../lib/security.headers.js' - -/** - * Convert security headers config to header array format for Next.js - * - * @param {import('../lib/security.headers').SecurityHeadersConfig} config - * @returns {Array<{ key: string, value: string}>} - */ -function configToHeaders(config) { - let headers = [] - - if (config.xFrameOptions) { - headers.push({ key: 'X-Frame-Options', value: config.xFrameOptions }) - } - - if (config.contentSecurityPolicy) { - let cspValue = config.contentSecurityPolicy - - if (REPORT_URI) { - cspValue += `; report-uri ${REPORT_URI}` - } - - headers.push({ - key: 'Content-Security-Policy', - value: cspValue, - }) - } - - if (config.xContentTypeOptions) { - headers.push({ - key: 'X-Content-Type-Options', - value: config.xContentTypeOptions, - }) - } - - if (config.referrerPolicy) { - headers.push({ key: 'Referrer-Policy', value: config.referrerPolicy }) - } - - if (config.permissionsPolicy) { - headers.push({ key: 'Permissions-Policy', value: config.permissionsPolicy }) - } - - if (config.strictTransportSecurity) { - headers.push({ - key: 'Strict-Transport-Security', - value: config.strictTransportSecurity, - }) - } - - if (config.xXssProtection) { - headers.push({ key: 'X-XSS-Protection', value: config.xXssProtection }) - } - - if (config.crossOriginEmbedderPolicy) { - headers.push({ - key: 'Cross-Origin-Embedder-Policy', - value: config.crossOriginEmbedderPolicy, - }) - } - - if (config.crossOriginOpenerPolicy) { - headers.push({ - key: 'Cross-Origin-Opener-Policy', - value: config.crossOriginOpenerPolicy, - }) - } - - if (config.crossOriginResourcePolicy) { - headers.push({ - key: 'Cross-Origin-Resource-Policy', - value: config.crossOriginResourcePolicy, - }) - } - - // Filter out any empty values - { - headers = headers.filter( - (header) => header.value && header.value.length > 0 - ) - } - - return headers -} - -/** @type {import('next').NextConfig} */ -export default { - async headers() { - const headers = [] - - // Build dynamic regex patterns - - const embeddablePathPattern = EMBEDDABLE_PATHS.map((path) => - path.replace(/^\//, '') - ).join('|') - - const excludePathPattern = EXCLUDE_PATHS.map((path) => - path.replace(/^\//, '') - ).join('|') - - const excludeHostPattern = EXCLUDE_HOSTS.map((host) => `(${host})`).join( - '|' - ) - - // Add security headers for default routes - { - const configHeaders = configToHeaders(DEFAULT_SECURITY_HEADERS) - - if (configHeaders.length > 0) { - headers.push({ - source: `/:path((?!${embeddablePathPattern}|${excludePathPattern}).*)`, - has: [ - { - type: /** @type {'host'} */ ('host'), - value: `(?!${excludeHostPattern}).*`, - }, - ], - headers: configHeaders, - }) - } - } - - // Add security headers for embeddable routes - { - const configHeaders = configToHeaders(EMBEDDABLE_SECURITY_HEADERS) - - if (configHeaders.length > 0) { - headers.push({ - source: `/:path(${embeddablePathPattern})`, - has: [ - { - type: /** @type {'host'} */ ('host'), - value: `(?!${excludeHostPattern}).*`, - }, - ], - headers: configHeaders, - }) - } - } - - return headers - }, -} diff --git a/platform/next.config.d/security.config.utest.js b/platform/next.config.d/security.config.utest.js deleted file mode 100644 index 331a3ea..0000000 --- a/platform/next.config.d/security.config.utest.js +++ /dev/null @@ -1,55 +0,0 @@ -import securityConfig from './security.config' - -describe('Security Config - configToHeaders function', () => { - let headerRules - - beforeAll(async () => { - headerRules = await securityConfig.headers() - }) - - // @note we are not setting any headers for embeddable paths for now to avoid - // breaking any integrations that rely on iframes - describe.skip('Integration with EMBEDDABLE_PATHS', () => { - it.each([ - ['/integrations/widget/v1.js'], - ['/integrations/widget/v2.js'], - ['/integrations/widget/plugins/plugin1.js'], - ['/integrations/widget/plugins/plugin2.js'], - ['/integrations/widget/abc/frame'], - ['/integrations/widget/abc/frame?xyz=123'], - ])('should match an embeddable path %s', (path) => { - const match = headerRules.find((rule) => - path.match(new RegExp('^/' + rule.source.slice('/:path'.length))) - ) - - expect(match).toBeDefined() - - const xFrameOptions = match.headers.find( - (h) => h.key === 'X-Frame-Options' - ) - - expect(xFrameOptions).toBeUndefined() - }) - - it.each([ - ['/'], - ['/random/path'], - ['/bots'], - ['/integration/widget'], - ['/integration/widget/test123'], - ['/integration/widget/test123/test'], - ])('should not match an embeddable path %s', (path) => { - const match = headerRules.find((rule) => - path.match(new RegExp('^/' + rule.source.slice('/:path'.length))) - ) - - expect(match).toBeDefined() - - const xFrameOptions = match.headers.find( - (h) => h.key === 'X-Frame-Options' - ) - - expect(xFrameOptions).toBeDefined() - }) - }) -}) diff --git a/platform/next.config.d/static.config.js b/platform/next.config.d/static.config.js index cd9273f..16269f2 100644 --- a/platform/next.config.d/static.config.js +++ b/platform/next.config.d/static.config.js @@ -1,41 +1,25 @@ /* eslint-disable import/extensions */ // @ts-check -import { hosts } from '../config/hosts.js' -import { siteHostname, staticHostname } from '../config/site.js' -import { - buildCaptureAllSource, - buildHostPattern, -} from '../lib/nextjs.config.rewrites.js' - -// @note every configured static target is routed unconditionally; runtime -// application logic decides which target belongs to the current request. -// A static host that is also a site host - STATIC_URL left at its site URL -// default, or a single-domain HOSTS_CONFIG mapping - derives no routing, as -// the capture-all below would otherwise swallow the site itself. - -const siteHostnames = new Set([siteHostname, ...hosts.site]) - -const staticHostnames = [...new Set([...hosts.static, staticHostname])].filter( - (hostname) => !siteHostnames.has(hostname) -) - -const staticHostPattern = buildHostPattern(staticHostnames, 'host') - -const forStaticHost = (rules) => (staticHostPattern ? rules : []) +import { buildCaptureAllSource } from '../lib/nextjs.config.rewrites.js' + +// @note the proxy selects static hosts at startup from STATIC_URL and +// HOSTS_CONFIG; path restrictions and exclusions remain in this config +const has = [ + { + type: /** @type {'header'} */ ('header'), + key: 'x-cbk-static', + value: '1', + }, +] /** @type {import('next').NextConfig} */ export default { async rewrites() { return { - beforeFiles: forStaticHost([ + beforeFiles: [ { source: '/', - has: [ - { - type: /** @type {'host'} */ ('host'), - value: staticHostPattern, - }, - ], + has, destination: '/404.txt', }, { @@ -46,30 +30,20 @@ export default { 'integrations\\/widget', ], }), - has: [ - { - type: /** @type {'host'} */ ('host'), - value: staticHostPattern, - }, - ], + has, destination: `/404.txt`, }, - ]), + ], afterFiles: [], - fallback: forStaticHost([ + fallback: [ { source: '/', - has: [ - { - type: /** @type {'host'} */ ('host'), - value: staticHostPattern, - }, - ], + has, destination: '/404.txt', }, - ]), + ], } }, } diff --git a/platform/next.config.d/static.config.utest.js b/platform/next.config.d/static.config.utest.js index ed39372..89bb69d 100644 --- a/platform/next.config.d/static.config.utest.js +++ b/platform/next.config.d/static.config.utest.js @@ -1,123 +1,63 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ - -function loadConfig(hosts, staticUrl = '') { - let config - - jest.isolateModules(() => { - process.env.HOSTS_CONFIG = JSON.stringify(hosts) - process.env.SITE_URL = 'https://example.com' - process.env.STATIC_URL = staticUrl - - config = require('./static.config').default - }) - - return config -} - -function hostPattern(rule) { - return rule.has?.[0]?.value -} - -describe('static.config', () => { - const originalHostsConfig = process.env.HOSTS_CONFIG - const originalSiteUrl = process.env.SITE_URL - const originalStaticUrl = process.env.STATIC_URL - - afterEach(() => { - process.env.HOSTS_CONFIG = originalHostsConfig - process.env.SITE_URL = originalSiteUrl - process.env.STATIC_URL = originalStaticUrl - }) - - it('gates static rewrites on every configured static host', async () => { - const config = loadConfig({ - family: { - match: ['example.com', 'static.example.com'], - site: 'example.com', - api: 'api.example.com', - static: 'static.example.com', - widgets: 'widgets.example.com', - }, - secondary: { - match: ['legacy.example.com', 'static.legacy.example.com'], - site: 'legacy.example.com', - api: 'api.legacy.example.com', - static: 'static.legacy.example.com', - widgets: 'widgets.legacy.example.com', - }, +/** @jest-environment node */ +import { unstable_getResponseFromNextConfig } from 'next/experimental/testing/server' + +import staticConfig from './static.config' + +// @note hostname selection is covered in proxy.utest.js; these tests cover +// the path policy applied after the proxy classifies a static host +describe('static host path routing', () => { + it.each([ + '/', + '/signin', + '/overview', + '/apps/chat', + '/unknown/path', + '/download.zip', + ])('serves the existing text fallback for %s', async (pathname) => { + const response = await unstable_getResponseFromNextConfig({ + nextConfig: staticConfig, + url: `https://static.example.com${pathname}`, + headers: { 'x-cbk-static': '1' }, }) - const { beforeFiles, fallback } = await config.rewrites() - - expect(hostPattern(beforeFiles[0])).toBe( - '(?static\\.example\\.com|static\\.legacy\\.example\\.com)' + expect(response.headers.get('x-middleware-rewrite')).toBe( + 'https://static.example.com/404.txt' ) - expect( - beforeFiles.every( - (rule) => hostPattern(rule) === hostPattern(beforeFiles[0]) - ) - ).toBe(true) - expect(hostPattern(fallback[0])).toBe(hostPattern(beforeFiles[0])) }) - it('includes the scalar static host with configured mappings', async () => { - const config = loadConfig( - { - family: { - match: ['example.com'], - site: 'example.com', - api: 'api.example.com', - static: 'static.example.com', - widgets: 'widgets.example.com', - }, - }, - 'https://assets.example.com' - ) - - const { beforeFiles } = await config.rewrites() - - expect(hostPattern(beforeFiles[0])).toBe( - '(?static\\.example\\.com|assets\\.example\\.com)' - ) - }) - - it('ignores the scalar static host when it is the site host', async () => { - const { beforeFiles, fallback } = await loadConfig( - {}, - 'https://example.com' - ).rewrites() - - expect(beforeFiles).toEqual([]) - expect(fallback).toEqual([]) - }) - - it('ignores a HOSTS_CONFIG static host that is also a site host', async () => { - const config = loadConfig({ - single: { - match: ['single.example.com'], - site: 'single.example.com', - api: 'single.example.com', - static: 'single.example.com', - widgets: 'single.example.com', - }, - family: { - match: ['example.com'], - site: 'example.com', - api: 'api.example.com', - static: 'static.example.com', - widgets: 'widgets.example.com', - }, + it.each([ + '/integrations/widget/v1.js', + '/integrations/widget/demo/frame', + '/integrations/widget/demo/test', + '/api/v1/bot/list', + '/oauth/callback', + '/_next/static/chunk.js', + '/monitoring-tunnel', + '/s/example', + '/apps/demo/icon', + '/partner/signin/acme', + '/favicon.ico', + '/assets/site.css', + ])('preserves the existing exclusion for %s', async (pathname) => { + const response = await unstable_getResponseFromNextConfig({ + nextConfig: staticConfig, + url: `https://static.example.com${pathname}`, + headers: { 'x-cbk-static': '1' }, }) - const { beforeFiles } = await config.rewrites() - - expect(hostPattern(beforeFiles[0])).toBe('(?static\\.example\\.com)') + expect(response.headers.get('x-middleware-rewrite')).toBeNull() }) - it('emits no host-gated rules without a configured static host', async () => { - const { beforeFiles, fallback } = await loadConfig({}).rewrites() - - expect(beforeFiles).toEqual([]) - expect(fallback).toEqual([]) - }) + it.each([{}, { 'x-cbk-static': 'untrusted' }, { 'x-cbk-app': 'chat' }])( + 'requires the static classification marker: %j', + async (headers) => { + const response = await unstable_getResponseFromNextConfig({ + nextConfig: staticConfig, + url: 'https://static.example.com/signin', + headers, + }) + + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + } + ) }) diff --git a/platform/package.json b/platform/package.json index 3ec2aa2..7a895c3 100644 --- a/platform/package.json +++ b/platform/package.json @@ -19,7 +19,6 @@ "build:08-dev-pages": "rimraf pages/_", "build:09-next": "next build --webpack", "build:09-verify_bundle": "tsx scripts/verify-bundle-modules.js", - "build:10-sitemap": "next-sitemap", "check": "node ./node_modules/typescript7/bin/tsc --noEmit --incremental --singleThreaded --diagnostics", "clean": "run-s clean:*", "clean:00-next": "rimraf .next", diff --git a/platform/proxy.ts b/platform/proxy.ts index f8bbf75..784a06d 100644 --- a/platform/proxy.ts +++ b/platform/proxy.ts @@ -1,35 +1,123 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import partnersConfig from '@chatbotkit-dev/partners' + +import { partnersApex } from '@/config/apexes' import { apps } from '@/config/apps' +import { hosts } from '@/config/hosts' +import { apiHostname, siteHostname, staticHostname } from '@/config/site' import { getPortalSlugFromHostname } from '@/lib/portal.hostname' +import { getSecurityHeaders } from '@/lib/security.headers' import { getSpaceSiteSlug } from '@/lib/space.site' +import { apiCorsHeaders } from '@/next.config.d/api.config' + // @note host routing ignores ports, matching Next's former host conditions + const appsByHostname = new Map( apps.flatMap((app) => app.host ? [[app.host.split(':')[0].toLowerCase(), app.slug]] : [] ) ) +const partnersByHostname = new Map( + Object.entries(partnersConfig).flatMap(([slug, partner]) => + partner.domain ? [[partner.domain.toLowerCase(), slug]] : [] + ) +) + +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]) +const apiHostnames = new Set( + [...hosts.api, apiHostname].filter((host) => !siteHostnames.has(host)) +) +const staticHostnames = new Set( + [...hosts.static, staticHostname].filter((host) => !siteHostnames.has(host)) +) + /** - * Selects space, portal and app hosts using the running deployment's settings. + * Selects host routing using the deployment's runtime settings. * Their Next configs own the path rewrites, exclusions and fallbacks. */ 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 appSlug = hostname ? appsByHostname.get(hostname) : undefined + const isApiHost = !!hostname && apiHostnames.has(hostname) + + // @note preserve wildcard partner sign-in routing, including unknown slugs; + // the partner page validates the catalogue entry before allowing sign-in + + const partnerSlug = + hostname && partnerSuffix && hostname.endsWith(partnerSuffix) + ? hostname.slice(0, -partnerSuffix.length) + : hostname + ? partnersByHostname.get(hostname) + : undefined + const headers = new Headers(request.headers) + const originalUrl = new URL(request.url) + const routeUrl = request.nextUrl.clone() + + // @note retaining the original URL also retains page-data paths; normalize + // those for host routing while leaving Next's request URL unchanged + + if (routeUrl.buildId) { + let pagePath = request.nextUrl.pathname + .replace(/^\/_next\/data\/[^/]+/, '') + .replace(/\.json$/, '') + + if (routeUrl.locale && pagePath.startsWith(`/${routeUrl.locale}/`)) { + pagePath = pagePath.slice(routeUrl.locale.length + 1) + } + + routeUrl.pathname = pagePath === '/index' ? '/' : pagePath + routeUrl.buildId = '' + } + + const { pathname } = routeUrl + + let redirectPath: string | undefined + // @note routing markers are always replaced, including on excluded paths; // client-supplied values must never select host-specific rewrites headers.delete('x-cbk-space-site') headers.delete('x-cbk-portal') headers.delete('x-cbk-app-shell') headers.delete('x-cbk-app') + headers.delete('x-cbk-partner') + headers.delete('x-cbk-static') + headers.delete('x-cbk-api') + + // @note API rules run first and retain their exclusions on overlapping hosts + if (isApiHost) { + headers.set('x-cbk-api', '1') + } + + // @note static restrictions compose with other surfaces in rewrite order, + // preserving the path exclusions when configured hostnames overlap + if (hostname && staticHostnames.has(hostname)) { + headers.set('x-cbk-static', '1') + } + + // @note partner sign-in and branding can coexist with another host surface + if (partnerSlug) { + headers.set('x-cbk-partner', partnerSlug) + + if (pathname === '/') { + redirectPath = '/overview' + } + } // @note a space host takes precedence if the configured apexes overlap; // assign only one marker so rewrite phases cannot route the request twice @@ -55,22 +143,113 @@ export function proxy(request: NextRequest): NextResponse { // @note configured redirects run before the proxy, so this host-based // redirect must use the same runtime classification as app rewrites - const { pathname } = request.nextUrl - - if (pathname === '/overview' || pathname === '/overview/') { - const destination = request.nextUrl.clone() - - destination.pathname = '/' - destination.host = request.headers.get('host') || destination.host + // @note partner roots lead to overview; reversing that redirect here + // would loop when a partner custom domain is also a registered app host - return NextResponse.redirect(destination) + if ( + !partnerSlug && + (pathname === '/overview' || pathname === '/overview/') + ) { + redirectPath = '/' } break } } - return NextResponse.next({ request: { headers } }) + let response: NextResponse + + // @note canonical redirects previously ran before host redirects and + // rewrites; retain the platform's paths without trailing slashes and their + // precedence while attaching the runtime policy + let permanentDestination: URL | undefined + + if ( + originalUrl.pathname !== '/' && + originalUrl.pathname.endsWith('/') && + !( + routeUrl.locale && + routeUrl.locale === routeUrl.defaultLocale && + pathname === '/' + ) + ) { + permanentDestination = new URL(originalUrl) + // @note Next's canonical redirects omit the default locale and leave + // its root alone, including a root served under a base path + permanentDestination.pathname = + routeUrl.locale && routeUrl.locale === routeUrl.defaultLocale + ? `${routeUrl.basePath}${pathname.slice(0, -1)}` + : originalUrl.pathname.slice(0, -1) + } + + if (permanentDestination || redirectPath) { + const destination = permanentDestination || routeUrl.clone() + + if (!permanentDestination && redirectPath) { + destination.pathname = redirectPath + } + + const publicHost = request.headers.get('host') + + if (publicHost) { + const { hostname, port } = new URL( + `${destination.protocol}//${publicHost}` + ) + + // @note assigning host alone retains an internal port when the public + // host has none; replace both parts for reverse-proxied deployments + destination.hostname = hostname + destination.port = port + } + + const redirectUrl = new URL(destination) + + // @note host redirects previously received an already normalized URL, + // including default-locale roots that do not need a separate redirect + if (!permanentDestination) { + redirectUrl.pathname = redirectUrl.pathname.replace(/\/$/, '') || '/' + } + + response = NextResponse.redirect( + redirectUrl, + permanentDestination ? 308 : 307 + ) + } else { + response = NextResponse.next({ request: { headers } }) + } + + if (!isApiHost) { + for (const { key, value } of getSecurityHeaders(pathname)) { + response.headers.set(key, value) + } + } + + if (partnerSlug && Object.hasOwn(partnersConfig, partnerSlug)) { + const partner = partnersConfig[partnerSlug] + // @note expose only public branding, never the partner's auth or transport + const branding = Buffer.from( + JSON.stringify({ + name: partner.name, + logo: partner.logo, + icon: partner.icon, + whitelabel: !!partner.whitelabel, + experience: partner.experience, + }), + 'utf8' + ).toString('base64') + + response.headers.append('Server-Timing', `partner;desc="${branding}"`) + } + + // @note configured headers run before the proxy, so clean API paths need + // runtime CORS selection here; /api/v1 keeps its unconditional config rule + if (isApiHost && /^\/v1(?:\/|$)/i.test(pathname)) { + for (const { key, value } of apiCorsHeaders) { + response.headers.set(key, value) + } + } + + return response } export const config = { diff --git a/platform/proxy.utest.js b/platform/proxy.utest.js index da6930a..9fecaea 100644 --- a/platform/proxy.utest.js +++ b/platform/proxy.utest.js @@ -4,6 +4,22 @@ import { NextRequest } from 'next/server' import { config } from './proxy' +jest.mock('@chatbotkit-dev/partners', () => ({ + __esModule: true, + default: + process.env.FIXTURE_EMPTY_PARTNERS === '1' + ? {} + : { + acme: { + id: 'private-account', + name: 'Acme', + domain: 'partner.example', + auth: { allowGlobalLogin: true }, + email: { token: 'private-token' }, + }, + }, +})) + async function loadProxy(apex, portalApex = '', appConfiguration = {}) { const previous = { NODE_ENV: process.env.NODE_ENV, @@ -20,6 +36,8 @@ async function loadProxy(apex, portalApex = '', appConfiguration = {}) { process.env.SPACE_APEX = apex process.env.PORTAL_APEX = portalApex Object.assign(process.env, appConfiguration) + // @note reload the package catalogue as well as the routing configuration + jest.resetModules() await jest.isolateModulesAsync(async () => { proxy = (await import('./proxy')).proxy }) @@ -58,7 +76,7 @@ describe('runtime space host routing', () => { it('classifies a space host independently of its base path and locale', async () => { const proxy = await loadProxy('space.localhost') - const request = new NextRequest('http://localhost:3000/platform/fr/docs/', { + const request = new NextRequest('http://localhost:3000/platform/fr/docs', { headers: { host: 'test.space.localhost:3000' }, nextConfig: { basePath: '/platform', @@ -69,7 +87,7 @@ describe('runtime space host routing', () => { expect( proxy(request).headers.get('x-middleware-request-x-cbk-space-site') ).toBe('1') - expect(request.url).toBe('http://localhost:3000/platform/fr/docs/') + expect(request.url).toBe('http://localhost:3000/platform/fr/docs') }) it('preserves the original host, path and query during classification', async () => { @@ -368,18 +386,18 @@ describe('runtime app host routing', () => { } ) - it('preserves trailing slashes in an app overview redirect', async () => { + it('canonicalizes the slash before redirecting app overview', async () => { const proxy = await loadProxy('', '', appConfiguration) const response = proxy( new NextRequest('https://internal.localhost:8080/platform/overview/', { headers: { host: 'chat.app.localhost:3000' }, - nextConfig: { basePath: '/platform', trailingSlash: true }, + nextConfig: { basePath: '/platform' }, }) ) - expect(response.status).toBe(307) + expect(response.status).toBe(308) expect(response.headers.get('location')).toBe( - 'https://chat.app.localhost:3000/platform/' + 'https://chat.app.localhost:3000/platform/overview' ) }) @@ -438,3 +456,500 @@ describe('routing marker isolation', () => { expect(response.headers.get('x-middleware-request-x-cbk-portal')).toBeNull() }) }) + +describe('runtime partner routing', () => { + it.each(['/signin', '/api/health', '/_next/static/test.js', '/favicon.ico'])( + 'removes a forged partner marker on %s', + async (pathname) => { + const proxy = await loadProxy('', '', { + PARTNERS_APEX: 'partners.localhost', + }) + const response = proxy( + new NextRequest(`http://localhost:3000${pathname}`, { + headers: { + host: 'unrelated.example', + 'x-cbk-partner': 'acme', + 'x-forwarded-host': 'acme.partners.localhost', + 'x-cbk-host': 'partner.example', + }, + }) + ) + + expect( + response.headers.get('x-middleware-request-x-cbk-partner') + ).toBeNull() + expect(response.headers.get('server-timing')).toBeNull() + } + ) + + it('does not expose inherited catalogue properties as branding', async () => { + const proxy = await loadProxy('', '', { + PARTNERS_APEX: 'partners.localhost', + }) + const response = proxy( + new NextRequest('http://localhost:3000/signin', { + headers: { host: 'constructor.partners.localhost' }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-partner')).toBe( + 'constructor' + ) + expect(response.headers.get('server-timing')).toBeNull() + }) + + it('works with the empty public catalogue', async () => { + const proxy = await loadProxy('', '', { + PARTNERS_APEX: '', + FIXTURE_EMPTY_PARTNERS: '1', + }) + const response = proxy( + new NextRequest('http://localhost:3000/', { + headers: { host: 'partner.example', 'x-cbk-partner': 'acme' }, + }) + ) + + expect(response.headers.get('x-middleware-next')).toBe('1') + expect(response.headers.get('location')).toBeNull() + expect(response.headers.get('server-timing')).toBeNull() + expect( + response.headers.get('x-middleware-request-x-cbk-partner') + ).toBeNull() + }) + + it('preserves the base path, locale, query and public host in partner root redirects', async () => { + const proxy = await loadProxy('', '', { + PARTNERS_APEX: 'partners.localhost', + }) + const response = proxy( + new NextRequest('http://localhost:3000/platform/fr?q=one', { + headers: { host: 'acme.partners.localhost:3000' }, + nextConfig: { + basePath: '/platform', + i18n: { locales: ['en', 'fr'], defaultLocale: 'en' }, + }, + }) + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'http://acme.partners.localhost:3000/platform/fr/overview?q=one' + ) + + const encoded = response.headers + .get('server-timing') + .match(/partner;desc="([^"]+)"/)[1] + + expect(JSON.parse(Buffer.from(encoded, 'base64').toString())).toEqual({ + name: 'Acme', + whitelabel: false, + }) + }) +}) + +describe('runtime static host routing', () => { + const configuration = { + SITE_URL: 'https://example.com', + STATIC_URL: 'https://assets.example.com', + HOSTS_CONFIG: JSON.stringify({ + family: { + match: ['example.com'], + site: 'example.com', + api: 'api.example.com', + static: 'static.example.com', + widgets: 'widgets.example.com', + }, + secondary: { + match: ['legacy.example.com'], + site: 'legacy.example.com', + api: 'api.legacy.example.com', + static: 'static.legacy.example.com', + widgets: 'widgets.legacy.example.com', + }, + single: { + match: ['single.example.com'], + site: 'single.example.com', + api: 'single.example.com', + static: 'single.example.com', + widgets: 'single.example.com', + }, + }), + } + + it.each([ + 'assets.example.com', + 'static.example.com', + 'STATIC.LEGACY.EXAMPLE.COM:3000', + ])( + 'selects scalar and mapped static targets for %s even outside match', + async (host) => { + const proxy = await loadProxy('', '', configuration) + const response = proxy( + new NextRequest('http://localhost:3000/signin', { headers: { host } }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-static')).toBe( + '1' + ) + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + } + ) + + it.each([ + 'example.com', + 'legacy.example.com', + 'single.example.com', + 'api.example.com', + 'staticXexample.com', + 'static.example.com.attacker.example', + ])( + 'does not apply static restrictions to %s or trust its supplied markers', + async (host) => { + const proxy = await loadProxy('', '', configuration) + const response = proxy( + new NextRequest('http://localhost:3000/signin', { + headers: { + host, + 'x-cbk-static': '1', + 'x-forwarded-host': 'static.example.com', + }, + }) + ) + + expect( + response.headers.get('x-middleware-request-x-cbk-static') + ).toBeNull() + } + ) + + it.each(['', 'https://example.com'])( + 'leaves the site unrestricted when STATIC_URL is %s', + async (staticUrl) => { + const proxy = await loadProxy('', '', { + SITE_URL: 'https://example.com', + STATIC_URL: staticUrl, + HOSTS_CONFIG: '', + }) + const response = proxy( + new NextRequest('https://example.com/', { + headers: { host: 'example.com' }, + }) + ) + + expect( + response.headers.get('x-middleware-request-x-cbk-static') + ).toBeNull() + expect(response.headers.get('location')).toBeNull() + } + ) + + it('excludes every mapped site host even when it is the scalar static target', async () => { + const proxy = await loadProxy('', '', { + ...configuration, + STATIC_URL: 'https://legacy.example.com', + }) + const response = proxy( + new NextRequest('https://legacy.example.com/', { + headers: { host: 'legacy.example.com' }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-static')).toBeNull() + }) + + it.each([ + '/api/health', + '/_next/static/chunk.js', + '/integrations/widget/v1.js', + '/favicon.ico', + ])( + 'removes forged static markers on the excluded path %s', + async (pathname) => { + const proxy = await loadProxy('', '', configuration) + const response = proxy( + new NextRequest(`https://example.com${pathname}`, { + headers: { host: 'example.com', 'x-cbk-static': '1' }, + }) + ) + + expect( + response.headers.get('x-middleware-request-x-cbk-static') + ).toBeNull() + } + ) +}) + +describe('public redirect authority', () => { + it.each([ + ['acme.partners.localhost', '/', '/overview'], + ['acme.partners.localhost:8080', '/', '/overview'], + ['chat.app.localhost', '/overview', '/'], + ['chat.app.localhost:8080', '/overview', '/'], + ])( + 'preserves the public host and port for %s', + async (host, pathname, destination) => { + const proxy = await loadProxy('', '', { + PARTNERS_APEX: 'partners.localhost', + APP_APEX: 'app.localhost', + }) + const response = proxy( + new NextRequest(`https://internal:3000${pathname}?q=one`, { + headers: { host }, + }) + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + `https://${host}${destination}?q=one` + ) + } + ) +}) + +describe('runtime API host routing', () => { + const configuration = { + SITE_URL: 'https://example.com', + API_URL: 'https://api.scalar.example.com', + HOSTS_CONFIG: JSON.stringify({ + family: { + match: ['example.com'], + site: 'example.com', + api: 'api.example.com', + static: 'static.example.com', + widgets: 'widgets.example.com', + }, + secondary: { + match: ['legacy.example.com'], + site: 'legacy.example.com', + api: 'api.legacy.example.com', + static: 'static.legacy.example.com', + widgets: 'widgets.legacy.example.com', + }, + single: { + match: ['single.example.com'], + site: 'single.example.com', + api: 'single.example.com', + static: 'single.example.com', + widgets: 'single.example.com', + }, + }), + } + + it.each([ + 'api.scalar.example.com', + 'api.example.com', + 'API.LEGACY.EXAMPLE.COM:3000', + ])('selects the scalar and every mapped API target for %s', async (host) => { + const proxy = await loadProxy('', '', configuration) + const response = proxy( + new NextRequest('http://internal:3000/v1/probe', { headers: { host } }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-api')).toBe('1') + expect(response.headers.get('access-control-allow-origin')).toBe('*') + expect(response.headers.get('access-control-allow-methods')).toBe( + 'GET,POST' + ) + expect(response.headers.get('access-control-allow-headers')).toBe( + 'X-Requested-With, Accept, Content-Length, Content-Type, Authorization' + ) + expect(response.headers.get('access-control-allow-credentials')).toBeNull() + }) + + it.each([ + 'example.com', + 'legacy.example.com', + 'single.example.com', + 'apiXexample.com', + 'api.example.com.attacker.example', + ])('rejects forged API selection and CORS for %s', async (host) => { + const proxy = await loadProxy('', '', configuration) + const response = proxy( + new NextRequest('http://internal:3000/v1/probe', { + headers: { + host, + 'x-cbk-api': '1', + 'x-forwarded-host': 'api.example.com', + }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-api')).toBeNull() + expect(response.headers.get('access-control-allow-origin')).toBeNull() + }) + + it.each(['', 'https://example.com', 'https://legacy.example.com'])( + 'excludes site hosts when API_URL is %s', + async (apiUrl) => { + const proxy = await loadProxy('', '', { + ...configuration, + API_URL: apiUrl, + }) + + for (const host of ['example.com', 'legacy.example.com']) { + const response = proxy( + new NextRequest(`https://${host}/v1/probe`, { headers: { host } }) + ) + + expect( + response.headers.get('x-middleware-request-x-cbk-api') + ).toBeNull() + expect(response.headers.get('access-control-allow-origin')).toBeNull() + } + } + ) + + it.each(['/v1', '/v1/', '/v1/probe', '/V1/probe'])( + 'applies the existing clean API CORS policy to %s', + async (pathname) => { + const proxy = await loadProxy('', '', configuration) + const response = proxy( + new NextRequest(`http://internal:3000${pathname}`, { + headers: { host: 'api.example.com' }, + method: 'OPTIONS', + }) + ) + + expect(response.headers.get('access-control-allow-origin')).toBe('*') + + if (pathname.endsWith('/')) { + expect(response.status).toBe(308) + expect(response.headers.get('location')).toBe( + 'http://api.example.com/v1' + ) + } else { + expect(response.headers.get('x-middleware-next')).toBe('1') + } + } + ) + + it.each([ + '/', + '/v10/probe', + '/v1other', + '/oauth/token', + '/signin', + '/_next/static/test.js', + ])('does not broaden CORS to %s', async (pathname) => { + const proxy = await loadProxy('', '', configuration) + const response = proxy( + new NextRequest(`http://internal:3000${pathname}`, { + headers: { host: 'api.example.com' }, + }) + ) + + expect(response.headers.get('access-control-allow-origin')).toBeNull() + }) + + it('classifies CORS independently of base path and locale', async () => { + const proxy = await loadProxy('', '', configuration) + const response = proxy( + new NextRequest('http://internal:3000/platform/fr/v1/probe', { + headers: { host: 'api.example.com' }, + nextConfig: { + basePath: '/platform', + i18n: { locales: ['en', 'fr'], defaultLocale: 'en' }, + }, + }) + ) + + expect(response.headers.get('access-control-allow-origin')).toBe('*') + }) +}) + +describe('runtime browser security host selection', () => { + it('leaves manuals paths to the deployment routing table', async () => { + const proxy = await loadProxy('', '', { API_URL: '', HOSTS_CONFIG: '' }) + + for (const pathname of ['/manuals', '/manuals/api/example']) { + const response = proxy( + new NextRequest(`https://example.com${pathname}`, { + headers: { host: 'example.com' }, + }) + ) + + expect(response.headers.get('location')).toBeNull() + expect(response.headers.get('x-middleware-next')).toBe('1') + expect(response.headers.get('x-frame-options')).toBe('SAMEORIGIN') + } + }) + + it.each(['gateway.example.com', 'api.example.com'])( + 'exempts the configured API host %s', + async (host) => { + const proxy = await loadProxy('', '', { + SITE_URL: 'https://example.com', + API_URL: `https://${host}`, + HOSTS_CONFIG: '', + }) + const response = proxy( + new NextRequest(`https://${host}/v1/probe`, { headers: { host } }) + ) + + expect(response.headers.get('content-security-policy')).toBeNull() + expect(response.headers.get('x-frame-options')).toBeNull() + expect(response.headers.get('x-content-type-options')).toBeNull() + expect(response.headers.get('access-control-allow-origin')).toBe('*') + } + ) + + it('protects a shared site/API host even with an api prefix', async () => { + const proxy = await loadProxy('', '', { + SITE_URL: 'https://api.example.com', + API_URL: 'https://api.example.com', + HOSTS_CONFIG: '', + }) + const response = proxy( + new NextRequest('https://api.example.com/signin', { + headers: { host: 'api.example.com', 'x-cbk-api': '1' }, + }) + ) + + expect(response.headers.get('content-security-policy')).toContain( + "frame-ancestors 'self'" + ) + expect(response.headers.get('x-frame-options')).toBe('SAMEORIGIN') + }) + + it('does not accept a forwarded API host or forged marker to disable browser protections', async () => { + const proxy = await loadProxy('', '', { + SITE_URL: 'https://example.com', + API_URL: 'https://gateway.example.com', + HOSTS_CONFIG: '', + }) + const response = proxy( + new NextRequest('https://example.com/signin', { + headers: { + host: 'example.com', + 'x-forwarded-host': 'gateway.example.com', + 'x-cbk-api': '1', + }, + }) + ) + + expect(response.headers.get('content-security-policy')).toContain( + "frame-ancestors 'self'" + ) + }) + + it('selects the embedding policy after removing the base path and locale', async () => { + const proxy = await loadProxy('', '', { API_URL: '', HOSTS_CONFIG: '' }) + const response = proxy( + new NextRequest( + 'http://localhost:3000/platform/fr/integrations/widget/demo/frame', + { + headers: { host: 'example.com' }, + nextConfig: { + basePath: '/platform', + i18n: { locales: ['en', 'fr'], defaultLocale: 'en' }, + }, + } + ) + ) + + expect(response.headers.get('x-frame-options')).toBeNull() + expect(response.headers.get('content-security-policy')).toContain( + 'frame-ancestors * capacitor: ionic:' + ) + }) +}) diff --git a/platform/public/robots.txt b/platform/public/robots.txt new file mode 100644 index 0000000..589afc5 --- /dev/null +++ b/platform/public/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Content-Signal: ai-train=no, search=yes, ai-input=yes