Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/publish-ghcr-platform.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,9 @@ jobs:
--volume "$volume:/data" \
"$APPLICATION_IMAGE"

# @note /signin renders per request, so its markup must carry the
# runtime site host stamped by the request context
# @note the markup stamps the configured origins from the runtime
# environment, so the probe proves SITE_URL supplied at run time
# reached the page rather than the image's build-time default
for attempt in $(seq 1 90); do
if docker exec "$container" node -e \
"fetch('http://127.0.0.1:3000/signin').then(async (response) => { const body = await response.text(); process.exit(response.status < 500 && body.includes('smoke-test.invalid') ? 0 : 1) }).catch(() => process.exit(1))"
Expand Down
79 changes: 79 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,80 @@ here. The release version is defined in the workspace root `package.json`.

## [Unreleased]

## [0.2.1] - 2026-09-10

### Fixed

- Use the runtime API URL in both Twilio webhook instructions and the install
popup, preserving the deployment scheme, port and route prefix.
- Carry the port everywhere a host is named. The `chatbotkit.host` cookie, the
`data-*-host` attributes and their fallbacks now all hold the host as the
request context records it; app, portal and partner lookups reduce it to a
hostname first, and a `HOSTS_CONFIG` mapping is selected by exact host,
then by hostname, so a deployment reached on a port
resolves its shells, the API, static and widget origins, portal sign-in,
partner domains, embed frame policies and canonical URLs the same way one
on the default port does. Portal origins minted from `PORTAL_APEX` take the
`SITE_URL` port, and apex links built in the browser follow the page port;
a target on a different port needs a `HOSTS_CONFIG` mapping. The
cookie is written by the proxy at runtime from the host the context trusts
and is `Secure` only when `SITE_URL` is https, so plain-http deployments
receive it too.
- Keep a mapped host that spells its default port on the page's scheme, and
let links to the page's own host follow the scheme and port the page was
reached on rather than the configured origin.
- Recognize mapped API hosts with explicit default ports in HTTP tools, and
bracket IPv6 addresses when the local proxy forwards a host with a port.
- Return app-host sign-ins to their app instead of the platform onboarding
route, which is not served on main, labs or individual app hosts.
- Advertise the mapped API host and its route prefix in the OpenAPI spec,
including deployments whose frontend and API use separate hosts.
- Retain the configured site port in app manifest IDs when no request host is
available.
- Use the runtime API URL in both Recall webhook instructions and the install
popup.
- Use the resolved deployment in API docs examples: raw HTTP includes the
required `/api` prefix on shared sites, and Node and Go clients use the
deployment's API origin.
- Select portal and partner authentication by hostname when the request host
includes a port.
- Advertise the OpenAPI server URL with the deployment's scheme when the
request carries none, so a plain-http deployment no longer publishes an
https server it does not serve.
- Use the deployment's scheme and port when the Slack integrations app builds
install manifests, including the configured-site fallback.
- Use the deployment's scheme and port in widget frame policies and preview
capture URLs, and resolve auto-widget partner branding by hostname.
- Include the deployment's scheme and port in portal URLs returned by GraphQL
and preserve the site port in generated portal hosts.
- Preserve static and widget host ports, resolve mapped asset URL schemes, and
include the served widget origin in MCP resource policies.
- Preserve configured ports in host values and read server-provided origins
when initializing browser configuration.
- Allow tool-specific MCP widgets to load their validated bundle origin in the
resource policy, including its scheme and port.
- Strip ports before portal login email domain and team invitation partner
lookups.
- Keep local testing proxy redirects on the browser's origin when accessed
through an HTTPS tunnel or a different local host and port.
- Honor the configured scheme of a separate API origin, including APIs on a
different port of the site hostname and HTTPS loopback origins.
- Clear a dedicated API's port when in-app HTTP tools translate its URL onto
a frontend origin using the default port.
- Send API playground requests through the frontend's actual port when the
entered API URL uses another port or omits it.
- Keep app install manifests discoverable when a host mapping assigns a
separate frontend hostname to the main or labs shell.
- Preserve static app asset paths, portal configuration lookup and missing-page
handling when the mapped frontend differs from the app or portal routing host.
- Resolve space-site storage from its routing hostname when its host mapping
names a separate frontend, preventing valid space sites from returning 404.
- Match Pipedream relative-path integrations by hostname when the target URL
includes a port.
- Resolve request-context host mappings by exact host first, then hostname,
and match API, static and app routing targets without their ports, including
IPv6 app origins.

## [0.2.0] - 2026-09-08

### Added
Expand All @@ -19,6 +93,11 @@ here. The release version is defined in the workspace root `package.json`.
without TLS, matching the existing websocket allowance. The app shells on a
local http deployment load banners from the site or static origin, which the
browser refused before.
- Recognise the labs shell host in the browser, so its sidebar links resolve
to `/<slug>` instead of `/apps/<slug>`, which the shell rewrite turned into a
404. Only the main shell host was overlaid from the runtime attributes.
- Preserve explicit ports and bracket IPv6 addresses for both request and
asserted frontend hosts in the local reverse proxy.

## [0.1.0] - 2026-09-08

Expand Down
41 changes: 33 additions & 8 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,15 +280,23 @@ answers on several domain families.

| Field | Purpose |
| --------- | --------------------------------------------------------- |
| `match` | Exact incoming hostnames that select this mapping |
| `match` | Exact incoming hosts that select this mapping |
| `site` | Site or application host for request-affine frontend URLs |
| `api` | API host for request-affine API URLs and clean API routes |
| `static` | Static host for public assets and static-host routing |
| `widgets` | Host for private MCP widget bundles |

Values are exact hostnames without a protocol, wildcard, path, query, or hash.
Every target that can receive a request should also appear in `match`, so a
request arriving on an API or static host selects the same mapping.
Values are exact hosts - a hostname plus the port when the deployment is
reached on one - without a protocol, wildcard, path, query, or hash. A
mapping whose `match` names the exact request host is selected first, so
mappings may differ by port; otherwise selection is by hostname, so `match`
entries may be listed without the port. When several mappings share a
hostname and none names the request host exactly, the first in configuration
order is used. The `site`, `api`, `static` and
`widgets` targets are used verbatim and should carry the port when the
deployment does. Every target
that can receive a request should also appear in `match`, so a request
arriving on an API or static host selects the same mapping.

Every `api` and `static` target is enabled at server startup. Targets that are
also site hosts are excluded from host-specific routing, so a shared host
Expand All @@ -297,8 +305,10 @@ continues serving the application and its API at `/api/v1`. Changing targets,
the routing table. At runtime, context injection selects the mapping once when
the authenticated frontend host or normalized request host appears in `match`.
Server URL helpers read the resolved targets from that context. The HTML
document exposes only the resolved site, API, static, and widget hosts for
client hooks. An unknown host keeps the existing custom-domain behavior.
document exposes the resolved site, API, static, and widget hosts, the
configured origins and the API route decision for client hooks (see
Reverse-proxy headers below). An unknown host keeps the existing custom-domain
behavior.

The proxy also uses this API host classification for browser security headers.
A dedicated API host is exempt regardless of its name; an ordinary site named
Expand All @@ -322,8 +332,8 @@ derivation: advertised API URLs follow `API_URL`, and unset it they stay on
the site host. App shells and apex-based routing are controlled independently
by the scalar variables above.

The configuration fails validation on malformed hostnames, missing fields,
unknown fields, or a hostname matched by more than one mapping.
The configuration fails validation on malformed hosts, missing fields,
unknown fields, or a host matched by more than one mapping.

## `ZONE_CONFIG`

Expand Down Expand Up @@ -427,6 +437,21 @@ is disabled, the forwarded values are ignored and the application uses the
ordinary `Host` header, the request URL protocol where available, and the
directly connected socket address for rate limiting and audit records.

The same trusted host is what the platform hands the browser: the
`chatbotkit.host` cookie, written on every response with the forwarded host
when it is trusted and the `Host` header otherwise (port included, `Secure`
only when `SITE_URL` is https), and the `data-audience` and `data-*-host`
attributes on `<html>`, which carry the request-affine site, static, widget
and API hosts, next to the `data-*-url` attributes carrying the configured
origins and `data-api-clean-routes`, the server's decision whether the API
host serves `/v1` at its root or under `/api`. A page rendered for a request
carries them all; a page prerendered at build time carries everything but the
request host itself, for which the browser falls back to the cookie and then
the configured site host. Note that a portal gateway's signed frontend
host assertion is not consulted for the cookie; on such pages the attributes
carry the public host and the cookie the gateway's upstream host. Client
code takes hosts from those sources and only the scheme from the page.

Behind a reverse proxy this flag also decides whether the sign-in abuse
controls work at all: without it every client shares the proxy's socket
address, so the per-address budgets for code issuance and verification become
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "platform",
"version": "0.2.0",
"version": "0.2.1",
"private": true,
"license": "Apache-2.0",
"packageManager": "pnpm@11.24.0",
Expand Down
19 changes: 13 additions & 6 deletions platform/app/apps/(adhoc)/b4d0c8f2/components.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { memo, useCallback, useEffect, useMemo, useState } from 'react'

import { getExternalAPIHost, getExternalAPIHostURL } from '@/lib/host'
import useExternalAPIURL from '@/hooks/useExternalAPIURL'
import { LuBookMarked, LuCopy, LuRefreshCcw, LuSearch } from 'react-icons/lu'

import toast from '@/lib/toast'
Expand Down Expand Up @@ -238,6 +238,8 @@ function Section({ title, badge, children }) {
}

function Inspector({ operation, loading }) {
const getAPIURL = useExternalAPIURL()

if (!operation) {
return (
<div className="flex min-w-0 flex-1 items-center justify-center p-8 text-center font-mono text-xs text-gray-400">
Expand Down Expand Up @@ -272,7 +274,7 @@ function Inspector({ operation, loading }) {
/v1{operation.path}
</code>
<CopyButton
text={getExternalAPIHostURL(`/v1${operation.path}`)}
text={getAPIURL(`/v1${operation.path}`)}
message="Endpoint URL copied to your clipboard"
className="tag hover:tag-darker ml-auto inline-flex h-6 shrink-0 cursor-pointer items-center gap-1.5 px-2 text-[11px] leading-none"
>
Expand Down Expand Up @@ -608,7 +610,8 @@ function CodeTabs({ tabs, activeTab, setActiveTab }) {
function RequestCodeBlock({ requestSchema, method, path }) {
const tabs = ['Node', 'Go', 'JavaScript', 'cURL', 'HTTP']
const [activeTab, setActiveTab] = useState(tabs[0])
const url = getExternalAPIHostURL(`/v1${path}`)
const getAPIURL = useExternalAPIURL()
const url = getAPIURL(`/v1${path}`)

function generateCurl(body) {
return [
Expand All @@ -628,9 +631,11 @@ function RequestCodeBlock({ requestSchema, method, path }) {
}

function generateHttp(body) {
const endpoint = new URL(url)

return [
`${method.toUpperCase()} /v1${path} HTTP/1.1`,
`Host: ${getExternalAPIHost()}`,
`${method.toUpperCase()} ${endpoint.pathname}${endpoint.search} HTTP/1.1`,
`Host: ${endpoint.host}`,
'Authorization: Bearer CBK_API_SECRET',
...(method !== 'get'
? ['Content-Type: application/json', '', toPrettyJson(body)]
Expand Down Expand Up @@ -668,7 +673,8 @@ function RequestCodeBlock({ requestSchema, method, path }) {
"import { ChatBotKit } from '@chatbotkit/sdk'",
'',
'const cbk = new ChatBotKit({',
' secret: process.env.CHATBOTKIT_API_KEY!',
' secret: process.env.CHATBOTKIT_API_KEY!,',
` baseUrl: ${JSON.stringify(new URL(url).origin)},`,
'})',
'',
`const response = await cbk.${methodChain}(`,
Expand Down Expand Up @@ -704,6 +710,7 @@ function RequestCodeBlock({ requestSchema, method, path }) {
'',
'client := sdk.New(sdk.Options{',
' Secret: os.Getenv("CHATBOTKIT_API_KEY"),',
` BaseURL: ${JSON.stringify(new URL(url).origin)},`,
'})',
'',
`response, err := client.${clientChain}(ctx${
Expand Down
87 changes: 87 additions & 0 deletions platform/app/apps/(adhoc)/b4d0c8f2/components.utest.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import useExternalAPIURL from '@/hooks/useExternalAPIURL'

import { Main } from './components'

import { fireEvent, render, screen } from '@testing-library/react'

jest.mock('./server', () => ({
fetchSpecOperation: jest.fn(),
listSpecOperations: jest.fn(),
}))

jest.mock('@/hooks/useExternalAPIURL', () => ({
__esModule: true,
default: jest.fn(),
}))

jest.mock('@/components/CodeBlock', () => ({
__esModule: true,
default: ({ children }) => <pre data-testid="request-code">{children}</pre>,
}))

describe('API documentation request snippets', () => {
it.each([
['Node', 'baseUrl: "http://console.localhost:3000"'],
['Go', 'BaseURL: "http://console.localhost:3000"'],
])('points the %s SDK example at the serving deployment', (tab, expected) => {
useExternalAPIURL.mockReturnValue(
(path) => `http://console.localhost:3000/api${path}`
)

render(
<Main
initialData={{ groups: [] }}
initialSlug="bot.list"
initialOperation={{
operationId: 'bot.list',
method: 'get',
path: '/bot/list',
}}
/>
)

fireEvent.click(screen.getByRole('button', { name: tab, exact: true }))

expect(screen.getByTestId('request-code').textContent).toContain(expected)
})

it.each([
[
'http://console.localhost:3000/api',
'/api/v1/bot/list',
'console.localhost:3000',
],
['https://api.example.com', '/v1/bot/list', 'api.example.com'],
])(
'uses the resolved endpoint in the HTTP tab on %s',
(baseURL, pathname, host) => {
document.documentElement.dataset.apiHost = host
useExternalAPIURL.mockReturnValue((path) => `${baseURL}${path}`)

try {
render(
<Main
initialData={{ groups: [] }}
initialSlug="bot.list"
initialOperation={{
operationId: 'bot.list',
method: 'get',
path: '/bot/list',
}}
/>
)

fireEvent.click(
screen.getByRole('button', { name: 'HTTP', exact: true })
)

const snippet = screen.getByTestId('request-code').textContent

expect(snippet).toContain(`GET ${pathname} HTTP/1.1`)
expect(snippet).toContain(`Host: ${host}`)
} finally {
delete document.documentElement.dataset.apiHost
}
}
)
})
5 changes: 3 additions & 2 deletions platform/app/apps/(adhoc)/f49c75da/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use server'

import { siteHostname } from '@/config/site'
import { siteHost } from '@/config/site'
import { getExternalFrontendHostURL } from '@/lib/host'

import type { UnwrapPromise } from '@chatbotkit-dev/typescript-utils/promise'

Expand Down Expand Up @@ -53,7 +54,7 @@ export const listIntegrations = appActionHandler(
icon: '@clearbit/slack.com',
manifestUrl: buildSlackManifestInstallUrl(
i,
`https://${context.host || siteHostname}`
new URL(getExternalFrontendHostURL('/', context.host || siteHost)).origin
),
signingSecret: i.signingSecret,
botToken: i.botToken,
Expand Down
Loading