diff --git a/docker/Dockerfile b/docker/Dockerfile index 657855a..33ed724 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -95,19 +95,16 @@ 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 and portal sites use their apexes at runtime through proxy.ts; -# the remaining apex rewrites are generated at build time, so their runtime -# values must match the build -# @todo move the remaining apex host rewrites into the runtime proxy -# so one image digest serves any domain without a rebuild +# @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 ARG APP_APEX= ENV APP_APEX=$APP_APEX ARG PARTNERS_APEX= ENV PARTNERS_APEX=$PARTNERS_APEX -# @note the app-shell hosts are rewrites of the same kind: the main shell at -# `cbk-apps.localhost`, the labs shell at `cbk-labs.localhost`. The runtime -# origins must match these too +# @note build defaults only; shell host routing uses the values supplied at +# server startup ARG APP_MAIN_ORIGIN=http://cbk-apps.localhost:3000 ENV APP_MAIN_ORIGIN=$APP_MAIN_ORIGIN ARG APP_LABS_ORIGIN=http://cbk-labs.localhost:3000 diff --git a/docker/distro/community/compose.yml b/docker/distro/community/compose.yml index 64022ba..ad54060 100644 --- a/docker/distro/community/compose.yml +++ b/docker/distro/community/compose.yml @@ -73,14 +73,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 and portal subdomains read their apexes at server startup; + # @note space, portal and app hosts read their 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} - # @note the app shells, baked the same way: the main shell at - # `cbk-apps.localhost:3000`, the labs shell at - # `cbk-labs.localhost:3000`. Cookies do not cross hosts, so sign in on - # the shell host itself + APP_APEX: ${APP_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} # @note left empty, the image generates these secrets on first boot and diff --git a/docker/distro/studio/compose.yml b/docker/distro/studio/compose.yml index 4f71c48..eae2dcb 100644 --- a/docker/distro/studio/compose.yml +++ b/docker/distro/studio/compose.yml @@ -76,14 +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 and portal subdomains read their apexes at server startup; + # @note space, portal and app hosts read their 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} - # @note the app shells, baked the same way: the main shell at - # `cbk-apps.localhost:3000`, the labs shell at - # `cbk-labs.localhost:3000`. Cookies do not cross hosts, so sign in on - # the shell host itself + APP_APEX: ${APP_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} # @note left empty, the image generates these secrets on first boot and diff --git a/docs/configuration.md b/docs/configuration.md index c26316b..f8d5741 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -219,8 +219,14 @@ and `PORTAL_APEX=cbk-portal.localhost`. Setting `PORTAL_APEX=portal.localhost` serves a portal named `test` at `http://test.portal.localhost:3000/`, with its existing authentication and app configuration. -The other apex host rewrites are still generated when Next builds, so their -runtime values must match the image; see +Standalone app host routing also reads `APP_APEX` at server startup. Setting +`APP_APEX=app.localhost` serves a registered app such as `chat` at +`http://chat.app.localhost:3000/`. Only apps included in the image's manifest +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). ## App shell origins @@ -239,10 +245,12 @@ APP_MAIN_ORIGIN=https://apps.example.com APP_LABS_ORIGIN=https://labs.example.com ``` -Like the apexes, the shell host rewrites are generated when Next builds, so -the runtime origins must match the build. The community image bakes -`APP_MAIN_ORIGIN=http://cbk-apps.localhost:3000` and -`APP_LABS_ORIGIN=http://cbk-labs.localhost:3000`. +Shell host routing reads these origins at server startup. Recreate the container +after changing them; no image rebuild is needed. The Community and Studio stacks +default to `APP_MAIN_ORIGIN=http://cbk-apps.localhost:3000` and +`APP_LABS_ORIGIN=http://cbk-labs.localhost:3000`. Existing app paths, sign-in, +callbacks and manifests remain available on the new hosts. Cookies do not cross +hosts, so sign in on the shell host itself. ## `HOSTS_CONFIG` @@ -414,6 +422,30 @@ only when the reverse proxy removes client-supplied forwarded headers, writes its own values, and prevents clients from reaching the application origin directly. If those conditions cannot be guaranteed, leave it unset. +### Server Actions origin checks + +Next.js checks Server Actions before the platform sets up request context. +It compares the browser's `Origin` host, including any port, with +`x-forwarded-host` when present, otherwise `Host`. This framework check runs +independently of `TRUST_PROXY_HEADERS`. A reverse proxy must overwrite forwarded +headers with the public request host, including its port. Preserve the public +`Host` as well when using the platform's space, portal and app host routing. + +Ordinary page actions post back to the page's public host. Deployed builds +configure no allowed-origin exceptions, including for deployment-owned domains. +An origin that differs from the public forwarded host is rejected. +Host-classification headers do not grant cross-origin access. + +A portal gateway may route to an internal backend hostname. It must preserve +the browser's original `Origin` and overwrite `x-forwarded-host` with the public +portal host. Replacing `Origin` with the backend origin breaks the comparison +and hides where the browser request actually originated. + +Only the development server retains exceptions for `localhost:8080` through +`localhost:8089` and `localhost:9090` through `localhost:9099`. These support +the local proxy's deliberate host impersonation and are absent from production +builds. Deployment domains do not add exceptions in either mode. + ## Platform capacity cap `PLATFORM_MAX_TOKENS_PER_MONTH` is an optional deployment-wide safety ceiling, diff --git a/docs/deployment.md b/docs/deployment.md index 662f01d..4d1fbdc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -279,7 +279,8 @@ and keep secrets out of image layers. The current community image deliberately bakes the neutral single-host topology: `SITE_URL=http://cbk.localhost:3000`, with no external zones. -The two app shells answer at `http://cbk-apps.localhost:3000` and +The Community and Studio stacks configure the two app shells at +`http://cbk-apps.localhost:3000` and `http://cbk-labs.localhost:3000` through `APP_MAIN_ORIGIN` and `APP_LABS_ORIGIN`. Browsers resolve any `*.localhost` name to loopback, so a space site published @@ -289,11 +290,10 @@ stacks default `SPACE_APEX` to `cbk-space.localhost` and `PORTAL_APEX` to `cbk-portal.localhost`. Space and portal routing read these values at server 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. - -The remaining apexes and app-shell origins must name the same hosts as the -build, which the Compose files ensure; changing those hosts still needs a -rebuild with the matching build arguments. Runtime service variables +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 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 diff --git a/platform/app/apps/chat/components/ChatArea.jsx b/platform/app/apps/chat/components/ChatArea.jsx index 63dbb1c..466c564 100644 --- a/platform/app/apps/chat/components/ChatArea.jsx +++ b/platform/app/apps/chat/components/ChatArea.jsx @@ -395,9 +395,11 @@ export function ChatArea({ {...props} className={clsx( 'chat-area', - 'sticky left-0 right-0 bottom-0', + 'left-0 right-0 bottom-0', { - 'pb-2': hasMessages, + // @note sticky positioning can pull the empty composer over the centered intro + 'sticky pb-2': hasMessages, + relative: !hasMessages, // @note used to hide the the text as it scrolls behind the area diff --git a/platform/components/Auth.jsx b/platform/components/Auth.jsx index 68b72f7..9c8c506 100644 --- a/platform/components/Auth.jsx +++ b/platform/components/Auth.jsx @@ -309,7 +309,11 @@ export default function Auth({ const title = _title || 'ChatBotKit' return sessionStatus === 'loading' ? null : ( -
+ {partner ? ( <> @@ -418,10 +422,6 @@ export default function Auth({ -

- This deployment trusts whoever reaches it: no code is sent - and the account is created on first use. -

) : null} {providers diff --git a/platform/layouts/Dashboard.jsx b/platform/layouts/Dashboard.jsx index 1a331cf..3d180a2 100644 --- a/platform/layouts/Dashboard.jsx +++ b/platform/layouts/Dashboard.jsx @@ -11,6 +11,7 @@ import Link from '@/components/Link' import MenuButton from '@/components/MenuButton' import Meta from '@/components/Meta' import { NestedAccordionMenu } from '@/components/NestedAccordion' +import NoRubberBand from '@/components/NoRubberBand' import NotificationsButton from '@/components/NotificationsButton' import PartnerBanner from '@/components/PartnerBanner' import ProfileBar from '@/components/ProfileBar' @@ -656,6 +657,8 @@ export default SessionContext.withSessionContext(function Dashboard({ enabled={isBuilderExperience} ownerId={session?.user?.id} > + {/* @note suppress document bounce while preserving overscroll inside panels */} +
{children} + } + ` + ) + await write( + 'app/apps/action-probe/actions.js', + ` + 'use server' + import { appendFile } from 'node:fs/promises' + import { cookies, headers } from 'next/headers' + + export async function submit(formData) { + await appendFile('action-results.jsonl', JSON.stringify({ + value: formData.get('value'), + session: (await cookies()).get('audit-session')?.value, + host: (await headers()).get('host'), + }) + '\\n') + } + ` + ) + await write( + 'app/apps/action-probe/page.jsx', + ` + import { submit } from './actions' + + export const dynamic = 'force-dynamic' + + export default function Page() { + return + } + ` + ) + + for (const slug of ['chat', 'action-probe']) { + await write( + `app/apps/${slug}/app.manifest`, + JSON.stringify({ + name: slug, + description: `Fixture app ${slug}`, + start: `/apps/${slug}`, + }) + ) + } + + await write('public/favicon.ico', 'fixture favicon') + await execute(process.execPath, [next, 'build', '--webpack'], { cwd: directory, env: environment, timeout: 90000, maxBuffer: 2 * 1024 * 1024, }) + + standaloneConfig = JSON.stringify( + JSON.parse( + await fs.readFile( + path.join(directory, '.next/required-server-files.json'), + 'utf8' + ) + ).config + ) }) afterEach(stop) @@ -589,6 +681,428 @@ describe.each(['', '/platform'])( expect(response.headers.get('x-fixture-route')).toBe('apps/index') }) + it.each([ + { + name: 'same-origin form on the runtime portal apex', + host: 'test.portal.localhost:3000', + origin: 'http://test.portal.localhost:3000', + allowed: true, + }, + { + name: 'reverse proxy preserving the public forwarded host', + host: 'test.portal.localhost:3000', + origin: 'http://test.portal.localhost:3000', + forwardedHost: 'test.portal.localhost:3000', + allowed: true, + }, + { + name: 'internal host with the public host forwarded to an explicit app route', + host: 'internal:3000', + origin: 'http://test.portal.localhost:3000', + forwardedHost: 'test.portal.localhost:3000', + pathname: '/apps/action-probe', + allowed: true, + }, + { + name: 'foreign origin with a valid portal host and session cookie', + host: 'test.portal.localhost:3000', + origin: 'https://attacker.invalid', + allowed: false, + }, + { + name: 'forwarded host that disagrees with the public origin', + host: 'test.portal.localhost:3000', + origin: 'http://test.portal.localhost:3000', + forwardedHost: 'internal:3000', + allowed: false, + }, + { + name: 'build-time portal apex grants no cross-origin exception', + host: 'test.portal.localhost:3000', + origin: 'https://test.cbk-portal.localhost', + allowed: false, + }, + { + name: 'runtime portal apex does not add a cross-origin exception to the standalone build', + host: 'test.portal.localhost:3000', + origin: 'https://test.portal.localhost', + allowed: false, + }, + { + name: 'build-time shell origin grants no cross-origin exception', + host: 'apps.localhost:3000', + origin: 'http://cbk-apps.localhost:3000', + allowed: false, + }, + { + name: 'custom portal domain preserved across a backend host rewrite', + host: 'portal-customer-com.portal.localhost:3000', + origin: 'https://portal.customer.com', + forwardedHost: 'portal.customer.com', + allowed: true, + }, + { + name: 'foreign origin forwarded through a custom portal domain', + host: 'portal-customer-com.portal.localhost:3000', + origin: 'https://attacker.invalid', + forwardedHost: 'portal.customer.com', + allowed: false, + }, + { + name: 'same-origin main shell after changing its runtime origin', + host: 'apps.localhost:3000', + origin: 'http://apps.localhost:3000', + allowed: true, + }, + { + name: 'same-origin labs shell after changing its runtime origin', + host: 'labs.localhost:3000', + origin: 'http://labs.localhost:3000', + allowed: true, + }, + { + name: 'same-origin registered app after changing the runtime apex', + host: 'action-probe.app.localhost:3000', + origin: 'http://action-probe.app.localhost:3000', + pathname: '/', + allowed: true, + }, + { + name: 'foreign origin on a registered app with a session cookie', + host: 'action-probe.app.localhost:3000', + origin: 'https://attacker.invalid', + pathname: '/', + allowed: false, + }, + ])( + 'Server Actions: $name', + async ({ + host, + origin: actionOrigin, + forwardedHost, + pathname = '/action-probe', + allowed, + }) => { + await start() + + const resultsPath = path.join(directory, 'action-results.jsonl') + + await fs.rm(resultsPath, { force: true }) + + const page = await request(host, pathname) + + expect(page.status).toBe(200) + + const html = await page.text() + const actionField = html.match(/name="(\$ACTION_ID_[^"]+)"/) + + expect(actionField).not.toBeNull() + + const form = new FormData() + + form.set(actionField[1], '') + form.set('value', 'saved') + + const body = new Response(form) + const result = await request(host, pathname, { + method: 'POST', + body: Buffer.from(await body.arrayBuffer()), + headers: { + origin: actionOrigin, + cookie: 'audit-session=test-session', + 'content-type': body.headers.get('content-type'), + ...(forwardedHost ? { 'x-forwarded-host': forwardedHost } : {}), + }, + }) + + if (allowed) { + expect(result.status).toBe(200) + expect(JSON.parse(await fs.readFile(resultsPath, 'utf8'))).toEqual({ + value: 'saved', + session: 'test-session', + host, + }) + } else { + expect(result.status).toBe(500) + await expect(fs.stat(resultsPath)).rejects.toMatchObject({ + code: 'ENOENT', + }) + } + } + ) + + it('serves a registered app on the runtime app apex', async () => { + await start() + + const response = await request('chat.app.localhost:3000') + + expect(response.status).toBe(200) + expect(response.headers.get('x-fixture-route')).toBe( + 'apps/chat/[[...path]]' + ) + expect(response.headers.get('x-fixture-host')).toBe( + 'chat.app.localhost:3000' + ) + }) + + it('serves both app shells on their runtime origins', async () => { + await start() + + for (const host of ['apps.localhost:3000', 'labs.localhost:3000']) { + const root = await request(host) + const chat = await request(host, '/chat/conversation?q=one%20two') + + expect(root.status).toBe(200) + expect(root.headers.get('x-fixture-route')).toBe('apps/index') + expect(chat.headers.get('x-fixture-route')).toBe( + 'apps/chat/[[...path]]' + ) + expect(JSON.parse(chat.headers.get('x-fixture-query')).path).toEqual([ + 'conversation', + ]) + expect(JSON.parse(chat.headers.get('x-fixture-query')).q).toBe( + 'one two' + ) + } + }) + + it('preserves app paths, manifests, platform sign-in, callbacks and static files', async () => { + await start() + + const app = await request( + 'chat.app.localhost:3000', + '/conversation?q=one%20two&slug=spoofed' + ) + + expect(app.headers.get('x-fixture-route')).toBe('apps/chat/[[...path]]') + expect(JSON.parse(app.headers.get('x-fixture-query')).path).toEqual([ + 'conversation', + ]) + expect(JSON.parse(app.headers.get('x-fixture-query')).q).toBe('one two') + + for (const host of [ + 'apps.localhost:3000', + 'labs.localhost:3000', + 'chat.app.localhost:3000', + ]) { + for (const [pathname, route] of [ + ['/app.webmanifest', 'apps/app.webmanifest'], + ['/signin', 'signin'], + ['/welcome', 'welcome'], + ['/secrets/oauth/callback', 'secrets/oauth/callback'], + [ + '/secrets/demo/manager/authenticate', + 'secrets/[secretId]/manager/authenticate', + ], + [ + '/secrets/demo/manager/oauth/callback', + 'secrets/[secretId]/manager/oauth/callback', + ], + ['/redirect/target', 'redirect/target'], + ['/partner/signin/acme', 'partner/signin/acme'], + ]) { + const response = await request(host, pathname) + + expect(response.headers.get('x-fixture-route')).toBe(route) + } + + expect(await (await request(host, '/api/health')).json()).toEqual({ + ok: true, + }) + expect(await (await request(host, '/favicon.ico')).text()).toBe( + 'fixture favicon' + ) + } + }) + + it('redirects app overview to the same public host and preserves the query', async () => { + await start() + + const response = await request( + 'chat.app.localhost:3000', + '/overview?q=one%20two' + ) + + expect(response.status).toBe(307) + + const location = response.headers.get('location') + const destination = new URL(location, 'http://chat.app.localhost:3000') + + expect(destination.origin).toBe('http://chat.app.localhost:3000') + expect(destination.pathname).toBe(basePath || '/') + expect(destination.searchParams.get('q')).toBe('one two') + }) + + it('keeps app fallback routing after existing platform routes', async () => { + await start() + + const existing = await request( + 'chat.app.localhost:3000', + '/redirect/target' + ) + const missing = await request( + 'chat.app.localhost:3000', + '/redirect/missing' + ) + + expect(existing.headers.get('x-fixture-route')).toBe('redirect/target') + expect(missing.headers.get('x-fixture-route')).toBe( + 'apps/chat/[[...path]]' + ) + expect(JSON.parse(missing.headers.get('x-fixture-query')).path).toEqual([ + '404', + ]) + }) + + it('preserves client-side page data routing and overview redirects', async () => { + await start() + + const buildId = ( + await fs.readFile(path.join(directory, '.next/BUILD_ID'), 'utf8') + ).trim() + + const locale = basePath ? '/en' : '' + + for (const [host, pathname, route] of [ + ['apps.localhost:3000', 'chat', 'apps/chat/[[...path]]'], + ['chat.app.localhost:3000', 'history', 'apps/chat/[[...path]]'], + ['test.portal.localhost:3000', 'chat', 'apps/chat/[[...path]]'], + ]) { + const response = await request( + host, + `/_next/data/${buildId}${locale}/${pathname}.json?q=one`, + { + headers: { 'x-nextjs-data': '1' }, + } + ) + + expect(response.status).toBe(200) + expect(response.headers.get('x-fixture-route')).toBe(route) + expect(await response.json()).toHaveProperty('pageProps') + } + + const response = await request( + 'chat.app.localhost:3000', + `/_next/data/${buildId}${locale}/overview.json?q=one`, + { + headers: { 'x-nextjs-data': '1' }, + } + ) + + expect(response.status).toBe(307) + + const destination = new URL( + response.headers.get('x-nextjs-redirect'), + 'http://chat.app.localhost:3000' + ) + + expect(destination.origin).toBe('http://chat.app.localhost:3000') + expect(destination.pathname).toBe(basePath || '/') + expect(destination.searchParams.get('q')).toBe('one') + }) + + it('does not select app routes from stale, unknown, unrelated or spoofed hosts', async () => { + await start() + + for (const host of [ + 'cbk-apps.localhost:3000', + 'cbk-labs.localhost:3000', + 'chat.cbk-app.localhost:3000', + 'unknown.app.localhost:3000', + 'app.localhost:3000', + 'child.apps.localhost:3000', + 'chat.app.localhost.attacker.example', + 'chatXapp.localhost:3000', + ]) { + const response = await request(host, '/', { + headers: { + 'x-cbk-app': 'chat', + 'x-cbk-app-shell': '1', + 'x-forwarded-host': 'chat.app.localhost:3000', + }, + }) + + expect(response.status).toBe(307) + expect(response.headers.get('x-fixture-route')).toBeNull() + } + + const portal = await request('test.portal.localhost:3000', '/', { + headers: { 'x-cbk-app': 'chat', 'x-cbk-app-shell': '1' }, + }) + const space = await request('test.space.localhost:3000', '/', { + headers: { 'x-cbk-app': 'chat', 'x-cbk-app-shell': '1' }, + }) + + expect(portal.headers.get('x-fixture-route')).toBe('apps/index') + expect(space.headers.get('x-space-site')).toBe('public') + }) + + it('supports hosted app domains with the same build', async () => { + await start('chatbotkit.space', 'chatbotkit.agency', { + APP_APEX: 'chatbotkit.app', + APP_MAIN_ORIGIN: 'https://apps.chatbotkit.com', + APP_LABS_ORIGIN: 'https://labs.chatbotkit.com', + }) + + for (const [host, route] of [ + ['chat.chatbotkit.app', 'apps/chat/[[...path]]'], + ['apps.chatbotkit.com', 'apps/index'], + ['labs.chatbotkit.com', 'apps/index'], + ]) { + expect((await request(host)).headers.get('x-fixture-route')).toBe(route) + } + }) + + it('disables app host routing without disabling path-based apps', async () => { + await start('space.localhost', 'portal.localhost', { + APP_APEX: '', + APP_MAIN_ORIGIN: '', + APP_LABS_ORIGIN: '', + }) + + for (const host of [ + 'chat.app.localhost:3000', + 'apps.localhost:3000', + 'labs.localhost:3000', + ]) { + expect((await request(host)).status).toBe(307) + } + + expect( + (await request('cbk.localhost:3000', '/apps/chat')).headers.get( + 'x-fixture-route' + ) + ).toBe('apps/chat/[[...path]]') + }) + + it('keeps app hosts out of the built rewrite and redirect manifest', async () => { + const manifest = JSON.parse( + await fs.readFile( + path.join(directory, '.next/routes-manifest.json'), + 'utf8' + ) + ) + const appRules = Object.values(manifest.rewrites) + .flat() + .filter( + (rule) => + rule.destination.startsWith(`${basePath}/`) && + rule.destination.includes('/apps/') + ) + + expect(appRules.length).toBeGreaterThan(0) + expect( + appRules + .flatMap((rule) => rule.has || []) + .filter((condition) => condition.type === 'host') + ).toEqual([]) + expect( + manifest.redirects + .flatMap((rule) => rule.has || []) + .filter((condition) => condition.type === 'host') + ).toEqual([]) + }) + it('disables portal routing when the runtime apex is unset', async () => { await start('space.localhost', '') diff --git a/platform/next.config.d/actions.config.js b/platform/next.config.d/actions.config.js index 0cfd7f0..b10491b 100644 --- a/platform/next.config.d/actions.config.js +++ b/platform/next.config.d/actions.config.js @@ -1,8 +1,5 @@ -/* eslint-disable import/no-anonymous-default-export, import/extensions */ +/* eslint-disable import/no-anonymous-default-export */ // @ts-check -import { APEXES } from '../config/apexes.js' -import { hosts } from '../config/hosts.js' -import { ORIGIN_HOSTS } from '../config/origins.js' /** @type {import('next').NextConfig} */ export default { @@ -10,31 +7,18 @@ export default { serverActions: { bodySizeLimit: '4.5mb', // @note load this from a constant so that the value can be used - allowedOrigins: [ - // development environment - - ...(process.env.NODE_ENV === 'development' + // @note deployed hosts need no exceptions when gateways preserve the + // browser Origin and public forwarded host; deployment domains must not + // automatically grant cross-origin access to actions + allowedOrigins: + process.env.NODE_ENV === 'development' ? [...Array(10)].flatMap((_, i) => [ + // @note the local proxy deliberately impersonates a platform + // host while the browser stays on its localhost listen port `localhost:808${i}`, `localhost:909${i}`, ]) - : []), - - // the deployment's own host and every configured host surface - server - // actions must be callable from all of them - - ...(process.env.SITE_URL ? [new URL(process.env.SITE_URL).host] : []), - - ...hosts.match, - - ...(ORIGIN_HOSTS.appMain ? [ORIGIN_HOSTS.appMain] : []), - ...(ORIGIN_HOSTS.appLabs ? [ORIGIN_HOSTS.appLabs] : []), - - ...(APEXES.app ? [`*.${APEXES.app}`] : []), - ...(APEXES.portal ? [`*.${APEXES.portal}`] : []), - ...(APEXES.partners ? [`*.${APEXES.partners}`] : []), - ...(APEXES.space ? [`*.${APEXES.space}`] : []), - ], + : [], }, }, } diff --git a/platform/next.config.d/actions.config.utest.js b/platform/next.config.d/actions.config.utest.js index 55e8546..bf26c46 100644 --- a/platform/next.config.d/actions.config.utest.js +++ b/platform/next.config.d/actions.config.utest.js @@ -1,48 +1,38 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ - -function loadConfig(hosts) { +async function loadConfig(nodeEnv) { + const previous = process.env.NODE_ENV let config - jest.isolateModules(() => { - process.env.HOSTS_CONFIG = JSON.stringify(hosts) + try { + process.env.NODE_ENV = nodeEnv - config = require('./actions.config').default - }) + await jest.isolateModulesAsync(async () => { + config = (await import('./actions.config')).default + }) - return config + return config + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previous + } + } } -describe('actions.config', () => { - const original = process.env.HOSTS_CONFIG +describe('Server Actions origin policy', () => { + it('does not turn configured production domains into cross-origin permissions', async () => { + const config = await loadConfig('production') - afterEach(() => { - process.env.HOSTS_CONFIG = original + expect(config.experimental.serverActions.allowedOrigins).toEqual([]) }) - it('allows every configured match host', () => { - const config = loadConfig({ - family: { - match: ['example.com', 'api.example.com', 'static.example.com'], - site: 'example.com', - api: 'api.example.com', - static: 'static.example.com', - widgets: 'widgets.example.com', - }, - legacy: { - match: ['legacy.example.com'], - site: 'legacy.example.com', - api: 'api.legacy.example.com', - static: 'static.legacy.example.com', - widgets: 'widgets.legacy.example.com', - }, - }) + it('retains the local development proxy exceptions without trusting deployment domains', async () => { + const config = await loadConfig('development') expect(config.experimental.serverActions.allowedOrigins).toEqual( - expect.arrayContaining([ - 'example.com', - 'api.example.com', - 'static.example.com', - 'legacy.example.com', + [...Array(10)].flatMap((_, i) => [ + `localhost:808${i}`, + `localhost:909${i}`, ]) ) }) diff --git a/platform/next.config.d/apps.config.js b/platform/next.config.d/apps.config.js index b631294..04c0808 100644 --- a/platform/next.config.d/apps.config.js +++ b/platform/next.config.d/apps.config.js @@ -1,7 +1,5 @@ /* eslint-disable import/extensions */ // @ts-check -import { APEXES } from '../config/apexes.js' -import { ORIGINS } from '../config/origins.js' import { buildCaptureAllSource, escapeRegex, @@ -83,16 +81,9 @@ function readAppManifestsFrom(baseDir) { .filter(Boolean) } -/** - * The deployment's app host configuration. APP_APEX controls standalone app - * subdomains and APP_MAIN_ORIGIN / APP_LABS_ORIGIN control exact shell hosts. - * Empty means no host-based app routing: every available app serves - * path-based under /apps. - */ - /** * The dict of all app manifests. Manifests carry no host at all: hostnames - * are pure deployment routing, derived from APP_APEX when it is configured. + * are derived from runtime deployment settings in config/apps.ts. */ const appManifests = z .array( @@ -149,22 +140,25 @@ if (process.env.NODE_ENV === 'test') { process.env.APP_MANIFESTS_JSON = APP_MANIFESTS_JSON } -// --- host routing ----------------------------------------------------------- -// Every rule below exists only when the deployment names the domain it routes. - -// @note next strips the port before matching a `has: host` rule, so the -// pattern is built from the origin hostname, not its port-carrying host -const shellHostList = [ORIGINS.appMain, ORIGINS.appLabs].flatMap((origin) => - origin ? [new URL(origin).hostname] : [] -) +// @note proxy.ts assigns these markers using runtime host configuration; +// the app catalogue and path rules remain part of the build -const shellHostPattern = shellHostList.length - ? `(?(?:${shellHostList.map(escapeRegex).join('|')}))` - : '' +const builtinAppPattern = builtinAppSlugs.map(escapeRegex).join('|') -const appApexHostPattern = APEXES.app - ? `(?(?:${builtinAppSlugs.join('|')})).${escapeRegex(APEXES.app)}` - : '' +const shellHas = [ + { + type: /** @type {'header'} */ ('header'), + key: 'x-cbk-app-shell', + value: '1', + }, +] +const appHas = [ + { + type: /** @type {'header'} */ ('header'), + key: 'x-cbk-app', + value: `(?${builtinAppPattern})`, + }, +] /** * The pages every host keeps serving from the platform itself. @@ -194,149 +188,62 @@ export default { async rewrites() { return { beforeFiles: [ - // the app shells - - ...(shellHostPattern + // the main and labs shells + { + source: buildCaptureAllSource({ + excludes: [...COMMON_EXCLUDES, ...builtinAppSlugs], + }), + has: shellHas, + destination: '/apps/:path*', + }, + { + source: '/app.webmanifest', + has: shellHas, + destination: '/apps/app.webmanifest', + }, + ...(builtinAppPattern ? [ { - source: buildCaptureAllSource({ - excludes: [ - ...COMMON_EXCLUDES, - - // @note we want to whitelist the builtin apps - - ...builtinAppSlugs, - ], - }), - has: [ - { - type: /** @type {'host'} */ ('host'), - value: shellHostPattern, - }, - ], - destination: `/apps/:path*`, - }, - { - source: '/app.webmanifest', - has: [ - { - type: /** @type {'host'} */ ('host'), - value: shellHostPattern, - }, - ], - destination: `/apps/app.webmanifest`, - }, - { - source: `/:path((?:${builtinAppSlugs.join('|')}).*)`, - has: [ - { - type: /** @type {'host'} */ ('host'), - value: shellHostPattern, - }, - ], + source: `/:path((?:${builtinAppPattern}).*)`, + has: shellHas, destination: '/apps/:path*', }, - { - source: '/', - has: [ - { - type: /** @type {'host'} */ ('host'), - value: shellHostPattern, - }, - ], - destination: '/apps', - }, ] : []), - - // . - - ...(appApexHostPattern + // @note keep the root after the catch-all so it cannot prefix /apps twice + { + source: '/', + has: shellHas, + destination: '/apps', + }, + + // registered app subdomains + ...(builtinAppPattern ? [ { - source: buildCaptureAllSource({ - excludes: COMMON_EXCLUDES, - }), - has: [ - { - type: /** @type {'host'} */ ('host'), - value: appApexHostPattern, - }, - ], - destination: `/apps/:slug/:path*`, + source: buildCaptureAllSource({ excludes: COMMON_EXCLUDES }), + has: appHas, + destination: '/apps/:slug/:path*', }, { source: '/app.webmanifest', - has: [ - { - type: /** @type {'host'} */ ('host'), - value: appApexHostPattern, - }, - ], - destination: `/apps/app.webmanifest`, + has: appHas, + destination: '/apps/app.webmanifest', }, { source: '/', - has: [ - { - type: /** @type {'host'} */ ('host'), - value: appApexHostPattern, - }, - ], - destination: `/apps/:slug`, + has: appHas, + destination: '/apps/:slug', }, ] : []), ], - afterFiles: [], - - fallback: [ - // 404 - - // . - - ...(appApexHostPattern - ? [ - { - source: '/:path*', - has: [ - { - type: /** @type {'host'} */ ('host'), - value: appApexHostPattern, - }, - ], - destination: `/apps/:slug/404`, - }, - ] - : []), - ], + fallback: builtinAppPattern + ? [{ source: '/:path*', has: appHas, destination: '/apps/:slug/404' }] + : [], } }, - async redirects() { - return [ - // . - - ...(appApexHostPattern - ? [ - { - source: '/overview', - has: [ - { - type: /** @type {'host'} */ ('host'), - value: appApexHostPattern, - }, - ], - destination: '/', - permanent: false, - }, - ] - : []), - ] - }, - - env: { - APP_MANIFESTS_JSON, - }, + env: { APP_MANIFESTS_JSON }, } diff --git a/platform/pages/datasets/[datasetId]/_index.utest.jsx b/platform/pages/datasets/[datasetId]/_index.utest.jsx index a3d90cc..1456714 100644 --- a/platform/pages/datasets/[datasetId]/_index.utest.jsx +++ b/platform/pages/datasets/[datasetId]/_index.utest.jsx @@ -1,4 +1,4 @@ -import { Bots } from './index' +import Index, { Bots } from './index' import '@testing-library/jest-dom' import { act, fireEvent, render, screen } from '@testing-library/react' @@ -109,6 +109,13 @@ jest.mock( return null } ) +jest.mock( + '@/components/Link', + () => + function Link({ children, href }) { + return {children} + } +) jest.mock( '@/components/ThisSolution', () => @@ -138,6 +145,8 @@ jest.mock('@/hooks/usePopup', () => })) ) jest.mock('@/hooks/useRouter', () => jest.fn(() => ({ push: jest.fn() }))) +jest.mock('@/hooks/usePlatformExperience', () => jest.fn(() => true)) +jest.mock('@/hooks/useScopedCreateData', () => jest.fn(() => jest.fn())) async function link(data) { const [, options] = mockOpenPopup.mock.calls[0] @@ -151,6 +160,39 @@ function getItems() { return JSON.parse(screen.getByTestId('bot-list').getAttribute('data-items')) } +describe('Dataset navigation', () => { + it('keeps SDK and Events behind More', async () => { + render( + + ) + + expect( + (await screen.findAllByRole('tab')).map((tab) => tab.textContent) + ).toEqual([ + 'Configuration', + 'Files', + 'Bot', + 'Integrations', + 'Chat', + 'Search', + ]) + + fireEvent.click(screen.getByRole('button', { name: 'More' })) + + expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([ + 'SDK', + 'Events', + ]) + }) +}) + describe('Dataset Bots', () => { beforeEach(() => { jest.clearAllMocks() diff --git a/platform/pages/datasets/[datasetId]/index.jsx b/platform/pages/datasets/[datasetId]/index.jsx index cf885b3..0b1b25b 100644 --- a/platform/pages/datasets/[datasetId]/index.jsx +++ b/platform/pages/datasets/[datasetId]/index.jsx @@ -1072,7 +1072,7 @@ export default function Index({ dataset }) { ) : null} */} {dataset.id ? ( -
+
Add data to this dataset either as records or from files @@ -1094,7 +1094,7 @@ export default function Index({ dataset }) { ) : null} {dataset.id ? ( -
+
Keep tabs on your dataset events. diff --git a/platform/pages/skillsets/[skillsetId]/_index.utest.jsx b/platform/pages/skillsets/[skillsetId]/_index.utest.jsx index 85f4549..b0d9103 100644 --- a/platform/pages/skillsets/[skillsetId]/_index.utest.jsx +++ b/platform/pages/skillsets/[skillsetId]/_index.utest.jsx @@ -1,4 +1,4 @@ -import { Bots } from './index' +import Index, { Bots } from './index' import '@testing-library/jest-dom' import { act, fireEvent, render, screen } from '@testing-library/react' @@ -99,6 +99,13 @@ jest.mock( return null } ) +jest.mock( + '@/components/Link', + () => + function Link({ children, href }) { + return {children} + } +) jest.mock( '@/components/SkillsetAbilityList', () => @@ -106,6 +113,13 @@ jest.mock( return null } ) +jest.mock( + '@/components/SkillsetConnectionList', + () => + function SkillsetConnectionList() { + return null + } +) jest.mock( '@/components/ThisSolution', () => @@ -135,6 +149,16 @@ jest.mock('@/hooks/usePopup', () => })) ) jest.mock('@/hooks/useRouter', () => jest.fn(() => ({ push: jest.fn() }))) +jest.mock('@/hooks/usePlatformExperience', () => jest.fn(() => true)) +jest.mock('@/hooks/useScopedCreateData', () => jest.fn(() => jest.fn())) +jest.mock('@/hooks/useSecretAuthenticate', () => jest.fn(() => jest.fn())) +jest.mock('@/hooks/useSkillsetSecrets', () => + jest.fn(() => ({ + secrets: [], + loading: false, + refresh: jest.fn(), + })) +) async function link(data) { const [, options] = mockOpenPopup.mock.calls[0] @@ -148,6 +172,39 @@ function getItems() { return JSON.parse(screen.getByTestId('bot-list').getAttribute('data-items')) } +describe('Skillset navigation', () => { + it('keeps SDK and Events behind More', async () => { + render( + + ) + + expect( + (await screen.findAllByRole('tab')).map((tab) => tab.textContent) + ).toEqual([ + 'Configuration', + 'Bot', + 'Abilities', + 'Connections', + 'Integrations', + 'Chat', + ]) + + fireEvent.click(screen.getByRole('button', { name: 'More' })) + + expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([ + 'SDK', + 'Events', + ]) + }) +}) + describe('Skillset Bots', () => { beforeEach(() => { jest.clearAllMocks() diff --git a/platform/pages/skillsets/[skillsetId]/index.jsx b/platform/pages/skillsets/[skillsetId]/index.jsx index 87b7a41..4224e47 100644 --- a/platform/pages/skillsets/[skillsetId]/index.jsx +++ b/platform/pages/skillsets/[skillsetId]/index.jsx @@ -757,7 +757,7 @@ export default function Index({ skillset }) { ) : null} */} {skillset.id ? ( -
+
Run abilities directly from your integrations and backend @@ -779,7 +779,7 @@ export default function Index({ skillset }) { ) : null} {skillset.id ? ( -
+
Monitor your skillset activity and events. diff --git a/platform/proxy.ts b/platform/proxy.ts index 11665ae..f8bbf75 100644 --- a/platform/proxy.ts +++ b/platform/proxy.ts @@ -1,23 +1,35 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { apps } from '@/config/apps' + import { getPortalSlugFromHostname } from '@/lib/portal.hostname' import { getSpaceSiteSlug } from '@/lib/space.site' +// @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]] : [] + ) +) + /** - * Selects space and portal hosts using the running deployment's apexes. + * Selects space, portal and app hosts using the running deployment's 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 headers = new Headers(request.headers) // @note routing markers are always replaced, including on excluded paths; - // client-supplied values must never select space or portal rewrites + // 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') // @note a space host takes precedence if the configured apexes overlap; // assign only one marker so rewrite phases cannot route the request twice @@ -31,6 +43,31 @@ export function proxy(request: NextRequest): NextResponse { headers.set('x-cbk-portal', '1') break + + // @note shell slugs start with a colon and are never routable app slugs + case !!appSlug && appSlug.startsWith(':'): + headers.set('x-cbk-app-shell', '1') + + break + + case !!appSlug: { + headers.set('x-cbk-app', appSlug) + + // @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 + + return NextResponse.redirect(destination) + } + + break + } } return NextResponse.next({ request: { headers } }) diff --git a/platform/proxy.utest.js b/platform/proxy.utest.js index 17e3154..da6930a 100644 --- a/platform/proxy.utest.js +++ b/platform/proxy.utest.js @@ -4,11 +4,14 @@ import { NextRequest } from 'next/server' import { config } from './proxy' -async function loadProxy(apex, portalApex = '') { +async function loadProxy(apex, portalApex = '', appConfiguration = {}) { const previous = { NODE_ENV: process.env.NODE_ENV, SPACE_APEX: process.env.SPACE_APEX, PORTAL_APEX: process.env.PORTAL_APEX, + ...Object.fromEntries( + Object.keys(appConfiguration).map((key) => [key, process.env[key]]) + ), } let proxy @@ -16,6 +19,7 @@ async function loadProxy(apex, portalApex = '') { process.env.NODE_ENV = 'production' process.env.SPACE_APEX = apex process.env.PORTAL_APEX = portalApex + Object.assign(process.env, appConfiguration) await jest.isolateModulesAsync(async () => { proxy = (await import('./proxy')).proxy }) @@ -235,6 +239,169 @@ describe('runtime portal host selection', () => { }) }) +describe('runtime app host routing', () => { + const appConfiguration = { + APP_APEX: 'app.localhost', + APP_MAIN_ORIGIN: 'https://apps.localhost:444', + APP_LABS_ORIGIN: 'https://labs.localhost:445', + APP_MANIFESTS_JSON: JSON.stringify([ + { + slug: 'chat', + start: '/apps/chat', + name: 'Chat', + description: 'Test app', + }, + ]), + } + + it.each([ + ['apps.localhost:3000', '1', null], + ['LABS.LOCALHOST:3000', '1', null], + ['CHAT.APP.LOCALHOST:3000', null, 'chat'], + ])('classifies %s using runtime hostnames', async (host, shell, app) => { + const proxy = await loadProxy( + 'space.localhost', + 'portal.localhost', + appConfiguration + ) + const response = proxy( + new NextRequest('http://localhost:3000/', { + headers: { + host, + 'x-cbk-app-shell': '1', + 'x-cbk-app': 'spoofed', + 'x-cbk-space-site': '1', + 'x-cbk-portal': '1', + }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-app-shell')).toBe( + shell + ) + expect(response.headers.get('x-middleware-request-x-cbk-app')).toBe(app) + expect( + response.headers.get('x-middleware-request-x-cbk-space-site') + ).toBeNull() + expect(response.headers.get('x-middleware-request-x-cbk-portal')).toBeNull() + expect(response.headers.get('x-middleware-request-host')).toBe(host) + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + }) + + it.each([ + 'unknown.app.localhost', + 'app.localhost', + 'chatXapp.localhost', + 'chat.app.localhost.attacker.example', + 'a.chat.app.localhost', + 'child.apps.localhost', + 'cbk-apps.localhost', + ':main.app.localhost', + ])('does not route unregistered or unrelated host %s', async (host) => { + const proxy = await loadProxy('', '', appConfiguration) + const response = proxy( + new NextRequest('http://localhost:3000/overview', { + headers: { + host, + 'x-cbk-app': 'chat', + 'x-cbk-app-shell': '1', + 'x-forwarded-host': 'chat.app.localhost', + }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-app')).toBeNull() + expect( + response.headers.get('x-middleware-request-x-cbk-app-shell') + ).toBeNull() + expect(response.headers.get('location')).toBeNull() + }) + + it('disables app host routing when the apex and shell origins are empty', async () => { + const proxy = await loadProxy('', '', { + ...appConfiguration, + APP_APEX: '', + APP_MAIN_ORIGIN: '', + APP_LABS_ORIGIN: '', + }) + + for (const host of [ + 'chat.app.localhost', + 'apps.localhost', + 'labs.localhost', + ]) { + const response = proxy( + new NextRequest('http://localhost:3000/', { + headers: { host, 'x-cbk-app': 'chat', 'x-cbk-app-shell': '1' }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-app')).toBeNull() + expect( + response.headers.get('x-middleware-request-x-cbk-app-shell') + ).toBeNull() + } + }) + + it.each([ + ['', '/'], + ['/platform', '/platform'], + ])( + 'redirects app overview with base path %s', + async (basePath, expectedPath) => { + const proxy = await loadProxy('', '', appConfiguration) + const response = proxy( + new NextRequest( + `http://localhost:3000${basePath}/overview?q=one%20two`, + { + headers: { host: 'chat.app.localhost:3000' }, + nextConfig: { basePath }, + } + ) + ) + const destination = new URL(response.headers.get('location')) + + expect(response.status).toBe(307) + expect(destination.origin).toBe('http://chat.app.localhost:3000') + expect(destination.pathname).toBe(expectedPath) + expect(destination.searchParams.get('q')).toBe('one two') + } + ) + + it('preserves trailing slashes in an app overview redirect', 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 }, + }) + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://chat.app.localhost:3000/platform/' + ) + }) + + it('does not redirect overview on a shell that overlaps an app host', async () => { + const proxy = await loadProxy('', '', { + ...appConfiguration, + APP_MAIN_ORIGIN: 'http://chat.app.localhost:3000', + }) + const response = proxy( + new NextRequest('http://localhost:3000/overview', { + headers: { host: 'chat.app.localhost:3000' }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-app-shell')).toBe( + '1' + ) + expect(response.headers.get('x-middleware-request-x-cbk-app')).toBeNull() + expect(response.headers.get('location')).toBeNull() + }) +}) + describe('routing marker isolation', () => { it.each([ ['test.space.localhost:3000', '1', null],