From 23de1dc51b93481def3247a0aa60b21c569fd3f3 Mon Sep 17 00:00:00 2001 From: pdparchitect Date: Sat, 5 Sep 2026 23:21:29 +0000 Subject: [PATCH 1/2] refactor: remove space.site.routing unit tests and update routing configurations (+2 more) - refactor: remove space.site.routing unit tests and update routing configurations - feat: add studio distribution stack with Docker Compose configuration - feat: enhance host selection logic to preserve configured ports and explicit hostname mappings --- .github/workflows/publish-ghcr-platform.yaml | 44 +- docker/Dockerfile | 13 +- docker/distro/AGENTS.md | 6 +- docker/distro/community/compose.yml | 5 +- docker/distro/studio/compose.yml | 270 ++++++++ docs/configuration.md | 15 +- docs/deployment.md | 31 +- platform/lib/host.ts | 25 +- platform/lib/host.utest.js | 59 ++ platform/lib/proxy.routing.utest.js | 603 ++++++++++++++++++ platform/next.config.d/portals.config.js | 46 +- platform/next.config.d/spaces.config.js | 46 +- platform/next.config.d/spaces.config.utest.js | 73 +++ platform/package.json | 2 +- platform/proxy.ts | 43 ++ platform/proxy.utest.js | 273 ++++++++ 16 files changed, 1446 insertions(+), 108 deletions(-) create mode 100644 docker/distro/studio/compose.yml create mode 100644 platform/lib/proxy.routing.utest.js create mode 100644 platform/next.config.d/spaces.config.utest.js create mode 100644 platform/proxy.ts create mode 100644 platform/proxy.utest.js diff --git a/.github/workflows/publish-ghcr-platform.yaml b/.github/workflows/publish-ghcr-platform.yaml index a534119..ebadfcc 100644 --- a/.github/workflows/publish-ghcr-platform.yaml +++ b/.github/workflows/publish-ghcr-platform.yaml @@ -78,21 +78,18 @@ jobs: echo "build=false" >> "$GITHUB_OUTPUT" fi - # @note reuse is decided once for the single flavor; a second flavor - # needs a per-flavor lookup and output here - - name: Resolve image names + # @note the build matrix is skipped as a whole, so reuse requires both + # component images for every flavor at the same source revision + - name: Resolve image prefix if: steps.diff.outputs.build == 'true' id: image env: - FLAVOR: community REPOSITORY_NAME: ${{ github.event.repository.name }} REPOSITORY_OWNER: ${{ github.repository_owner }} run: | owner="${REPOSITORY_OWNER,,}" repository="${REPOSITORY_NAME,,}" - stack="${REGISTRY}/${owner}/${repository}-${FLAVOR}" - echo "application=${stack}-app" >> "$GITHUB_OUTPUT" - echo "initializer=${stack}-init" >> "$GITHUB_OUTPUT" + echo "prefix=${REGISTRY}/${owner}/${repository}" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx if: steps.diff.outputs.build == 'true' @@ -109,14 +106,15 @@ jobs: # @note candidates are the pushed commit (a fast-forward or a re-run) # and its parents (the promotion merge, whose second parent is the next # head). A candidate counts only when its tree is byte-identical to the - # pushed tree and both component images carry its sha- tag, which only - # publish creates and only after verify passed + # pushed tree and every flavor's component images carry its sha- tag, + # which only publish creates and only after verify passed - name: Find a published image of the same tree if: steps.diff.outputs.build == 'true' id: reuse env: - APPLICATION_IMAGE: ${{ steps.image.outputs.application }} - INITIALIZER_IMAGE: ${{ steps.image.outputs.initializer }} + # @note keep this list in sync with the build and publish matrices + FLAVORS: community studio + IMAGE_PREFIX: ${{ steps.image.outputs.prefix }} run: | tree=$(git rev-parse "${GITHUB_SHA}^{tree}") @@ -133,9 +131,18 @@ jobs: short="${candidate:0:7}" - if docker buildx imagetools inspect "${APPLICATION_IMAGE}:sha-${short}" >/dev/null 2>&1 \ - && docker buildx imagetools inspect "${INITIALIZER_IMAGE}:sha-${short}" >/dev/null 2>&1 - then + complete=true + + for flavor in $FLAVORS; do + if ! docker buildx imagetools inspect "${IMAGE_PREFIX}-${flavor}-app:sha-${short}" >/dev/null 2>&1 \ + || ! docker buildx imagetools inspect "${IMAGE_PREFIX}-${flavor}-init:sha-${short}" >/dev/null 2>&1 + then + complete=false + break + fi + done + + if [ "$complete" = true ]; then echo "Reusing images built from ${candidate}" echo "reuse=${short}" >> "$GITHUB_OUTPUT" exit 0 @@ -184,6 +191,11 @@ jobs: - name: community application_target: application initializer_target: initializer + # @note studio starts with the same package selection as community; + # both share Docker targets until studio's implementation diverges + - name: studio + application_target: application + initializer_target: initializer architecture: - name: amd64 platform: linux/amd64 @@ -244,7 +256,7 @@ jobs: file: docker/Dockerfile target: ${{ matrix.flavor.application_target }} platforms: ${{ matrix.architecture.platform }} - # @note community images embed full source maps on purpose - the + # @note distribution images embed full source maps on purpose - the # source is public and self-hosted debugging needs them build-args: | BUILD_SOURCEMAPS=full @@ -380,6 +392,8 @@ jobs: # @note keep this list in sync with the build job's flavor matrix - name: community runner: ubuntu-latest-8-cores-amd64 + - name: studio + runner: ubuntu-latest-8-cores-amd64 steps: - uses: actions/checkout@v7 diff --git a/docker/Dockerfile b/docker/Dockerfile index 1c98176..657855a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -95,16 +95,11 @@ 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 apex host rewrites are generated at build time, so the image bakes -# `.localhost` names browsers resolve to loopback without DNS: space sites at -# `.cbk-space.localhost`, portals at `.cbk-portal.localhost`. -# The runtime environment must name the same apexes (the compose files do). -# @todo move the apex host rewrites out of next.config.d into a runtime proxy +# @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 -ARG SPACE_APEX=cbk-space.localhost -ENV SPACE_APEX=$SPACE_APEX -ARG PORTAL_APEX=cbk-portal.localhost -ENV PORTAL_APEX=$PORTAL_APEX ARG APP_APEX= ENV APP_APEX=$APP_APEX ARG PARTNERS_APEX= diff --git a/docker/distro/AGENTS.md b/docker/distro/AGENTS.md index a456d3d..021b8b4 100644 --- a/docker/distro/AGENTS.md +++ b/docker/distro/AGENTS.md @@ -47,9 +47,11 @@ docker compose -f oci://ghcr.io/chatbotkit/platform-:latest up -d Swap only what the flavor changes (e.g. a `postgres` service replacing the SQLite volume); keep service names, healthchecks and the variable surface consistent. -3. Add the flavor to the matrix in +3. Add the flavor to the build and publish matrices in `.github/workflows/publish-ghcr-platform.yaml` with its application and - initializer targets. Everything downstream - images, smoke test, artifact + initializer targets, and to the `FLAVORS` list used by the image reuse + lookup. A build is reused only when every flavor has both component images + from the same revision. Everything downstream - images, smoke test, artifact publish - is parameterized on `matrix.flavor` and needs no other change. 4. Verify before relying on CI: `docker compose -f docker/distro//compose.yml config --quiet`, then publish to a diff --git a/docker/distro/community/compose.yml b/docker/distro/community/compose.yml index fe9631f..64022ba 100644 --- a/docker/distro/community/compose.yml +++ b/docker/distro/community/compose.yml @@ -73,9 +73,8 @@ 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 deployment-issued subdomains; must match the apexes baked into - # the image (docker/Dockerfile). Browsers resolve `*.localhost` to - # loopback, so `acme.cbk-space.localhost:3000` works with no DNS setup + # @note space and portal subdomains read their apexes 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 diff --git a/docker/distro/studio/compose.yml b/docker/distro/studio/compose.yml new file mode 100644 index 0000000..09cc47b --- /dev/null +++ b/docker/distro/studio/compose.yml @@ -0,0 +1,270 @@ +# ============================================================================= +# ChatBotKit Platform - studio distribution stack +# ============================================================================= +# The self-contained, image-only Compose application published to +# ghcr.io/chatbotkit/platform-studio as an OCI artifact. Consumers run the +# whole platform - application, database initialization, Redis, Qdrant and +# Garage object storage - with a single command and no checkout: +# +# docker compose -f oci://ghcr.io/chatbotkit/platform-studio:latest up +# +# The same file runs directly from the repository: +# +# docker compose -f docker/distro/studio/compose.yml up -d +# +# Everything a service needs travels inside this file or inside a published +# image: configuration is inlined through the top-level `configs` element and +# the Garage provisioning script ships inside the initializer image, so there +# are no bind mounts - the one restriction `docker compose publish` enforces. +# The developer stack with profiles, source builds and hot reload lives in +# docker-compose.yml at the repository root; this file never builds anything. +# +# One folder per package flavor lives under docker/distro/. Each publishes as +# ghcr.io/chatbotkit/platform-, pinned by CI to the matching +# application and initializer image digests of the same flavor. +# ============================================================================= + +# @note the storage environment every application service shares. The store +# is Garage, spoken to over the plain S3 protocol. Left empty, the access key +# is generated by Garage on first boot and persisted in the platform-data +# volume (garage-init provisions it, the application entrypoint sources it); +# set STORAGE_ACCESS_KEY_ID / STORAGE_SECRET_ACCESS_KEY to use a fixed pair. +# +# @note the store is a separate, published service with one name everywhere, +# like the relay and the app shells: browsers resolve `cbk-storage.localhost` +# to loopback with no DNS setup, and inside the network the garage service +# carries it as an alias on the same port (STORAGE_PORT). Presigned URLs embed +# this endpoint and are fetched by both browsers and the application itself, +# so the two must agree. Set STORAGE_URL to an address both can reach (and +# TLS if the site has it) when browsers do not reach the machine itself. +x-storage-env: &storage-env + STORAGE_ENDPOINT: ${STORAGE_URL:-http://cbk-storage.localhost:${STORAGE_PORT:-3900}} + STORAGE_REGION: garage + STORAGE_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY_ID:-} + STORAGE_SECRET_ACCESS_KEY: ${STORAGE_SECRET_ACCESS_KEY:-} + STORAGE_FORCE_PATH_STYLE: 'true' + FILE_S3_BUCKET_NAME: file + IMAGE_S3_BUCKET_NAME: image + VIDEO_S3_BUCKET_NAME: video + AUDIO_S3_BUCKET_NAME: audio + CONVERSATION_S3_BUCKET_NAME: conversation + NAMESPACE_S3_BUCKET_NAME: namespace + SESSION_S3_BUCKET_NAME: session + SPACE_S3_BUCKET_NAME: space + TEMP_S3_BUCKET_NAME: temp + OUTPUT_S3_BUCKET_NAME: output + +services: + platform: + image: ${PLATFORM_IMAGE:-ghcr.io/chatbotkit/platform-studio-app:next} + ports: + - '3000:3000' + # @note the built-in realtime relay - see RELAY_URL below + - '${RELAY_PORT:-3001}:3001' + environment: + <<: *storage-env + NODE_ENV: production + PORT: 3000 + SITE_URL: ${SITE_URL:-http://cbk.localhost:3000} + 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 + # browser dial RELAY_URL, so loopback serves both; a browser elsewhere + # 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; + # 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_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 + # persists them in the platform-data volume; set explicitly to override + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-} + QUEUE_SECRET: ${QUEUE_SECRET:-} + JWT_TOKEN_SECRET_KEY: ${JWT_TOKEN_SECRET_KEY:-} + CLOAK_ENCRYPTION_KEY: ${CLOAK_ENCRYPTION_KEY:-} + PRISMA_DATABASE_URL: file:/data/chatbotkit.db + # @note sandbox workspaces - what agents write and install - live in the + # data volume so they survive restarts + SANDBOX_DATA_DIR: /data/sandbox + # @note optional: encrypts stored credentials in the database; unset, + # they are stored as given. See docs/configuration.md, "Encryption at rest" + PRISMA_FIELD_ENCRYPTION_KEY: ${PRISMA_FIELD_ENCRYPTION_KEY:-} + # @note optional platform-wide provider keys; users can also supply + # their own keys through the application itself. Any variable the + # application honours (further providers, the *_CONFIG seams - see + # docs/configuration.md) can be persisted in the platform-data volume + # instead of a .env file, prompted for or set directly: + # docker compose -f oci://... run --rm --no-deps platform setup + # docker compose -f oci://... run --rm --no-deps platform setup OPENROUTER_MODELS_API_KEY=... + # Values given here or in .env win over persisted ones, and a running + # service restarts itself when the persisted file changes - see + # docker/entrypoint.sh. An override file remains the other route: + # docker compose -f oci://... -f my-override.yml up -d + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENROUTER_MODELS_API_KEY: ${OPENROUTER_MODELS_API_KEY:-} + VERCEL_MODELS_API_KEY: ${VERCEL_MODELS_API_KEY:-} + REDIS_URL: redis://redis:6379 + QDRANT_URL: http://qdrant:6333 + volumes: + - platform-data:/data + depends_on: + db-init: + condition: service_completed_successfully + redis: + condition: service_healthy + qdrant: + condition: service_healthy + garage-init: + condition: service_completed_successfully + restart: unless-stopped + healthcheck: + # Use node for the healthcheck since the slim image has no wget/curl + test: + [ + 'CMD', + 'node', + '-e', + "fetch('http://localhost:3000/').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s + + db-init: + image: ${PLATFORM_INIT_IMAGE:-ghcr.io/chatbotkit/platform-studio-init:next} + environment: + PRISMA_DATABASE_URL: file:/data/chatbotkit.db + volumes: + - platform-data:/data + restart: 'no' + + redis: + image: redis:7-alpine + command: ['redis-server', '--appendonly', 'yes'] + volumes: + - redis-data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + qdrant: + image: qdrant/qdrant:v1.15.1 + volumes: + - qdrant-data:/qdrant/storage + healthcheck: + # @note the qdrant image ships no curl or wget, so the check is bash + # opening a TCP connection to the HTTP port + test: ['CMD-SHELL', 'bash -c ": > /dev/tcp/127.0.0.1/6333" || exit 1'] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + garage: + image: dxflrs/garage:v2.1.0 + environment: + # @note the 10s healthcheck logs three INFO lines per run; keep only + # warnings and errors + RUST_LOG: warn + ports: + # @note published on every interface: browsers talk to the store + # directly through presigned URLs (see x-storage-env). Same port on + # both sides, so the one endpoint works from inside the network too + - '${STORAGE_PORT:-3900}:${STORAGE_PORT:-3900}' + networks: + default: + aliases: + - cbk-storage.localhost + configs: + - source: garage-config + target: /etc/garage.toml + volumes: + - garage-data:/var/lib/garage + healthcheck: + # @note the image is a bare binary - no shell - so the check is the + # garage CLI talking to the daemon over RPC + test: ['CMD', '/garage', '-c', '/etc/garage.toml', 'status'] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + garage-init: + # @note one-shot: provisions the single-node layout, the development + # access key and one bucket per storage scope through Garage's admin API. + # Idempotent, so it reruns harmlessly on every `up`. The script ships + # inside the initializer image (the same one db-init runs), so this stack + # needs no bind mount into a checkout. + image: ${PLATFORM_INIT_IMAGE:-ghcr.io/chatbotkit/platform-studio-init:next} + command: ['node', '/garage-init.mjs'] + environment: + GARAGE_ADMIN_URL: http://garage:3903 + GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN:-dev-admin-token} + GARAGE_S3_URL: http://garage:${STORAGE_PORT:-3900} + # @note origins allowed to use presigned URLs from a browser; the URLs + # themselves are the access control + STORAGE_CORS_ORIGINS: ${STORAGE_CORS_ORIGINS:-*} + STORAGE_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY_ID:-} + STORAGE_SECRET_ACCESS_KEY: ${STORAGE_SECRET_ACCESS_KEY:-} + volumes: + # @note shares the application volume so the generated access key + # persists where the application entrypoint can source it + - platform-data:/data + depends_on: + garage: + condition: service_healthy + restart: 'no' + +configs: + # @note the inline copy of docker/garage/garage.toml, embedded so the + # published OCI artifact is self-contained. Keep the two in sync. + garage-config: + content: | + # Garage (S3-compatible object storage) - single-node configuration. + # + # WARNING: the rpc_secret and admin token default to known development + # values. Neither the RPC nor the admin port is published outside the + # compose network (the S3 port is), but a hardened setup overrides them (GARAGE_RPC_SECRET, `openssl rand -hex + # 32`, and GARAGE_ADMIN_TOKEN) - and a real deployment almost certainly + # runs a real store with replication rather than this single-node + # layout. + + metadata_dir = "/var/lib/garage/meta" + data_dir = "/var/lib/garage/data" + db_engine = "sqlite" + + replication_factor = 1 + + rpc_bind_addr = "[::]:3901" + rpc_public_addr = "127.0.0.1:3901" + rpc_secret = "${GARAGE_RPC_SECRET:-1799bccfd7411eddcf9ebd316bc1f5287ad12a68094e1c6ac6abde7e6feae1ec}" + + [s3_api] + # @note the region is part of every SigV4 signature: STORAGE_REGION + # must match it, or every request fails authentication + s3_region = "garage" + api_bind_addr = "[::]:${STORAGE_PORT:-3900}" + root_domain = ".s3.garage.localhost" + + [admin] + # @note the garage-init service provisions the layout, the access key + # and the buckets through this API + api_bind_addr = "[::]:3903" + admin_token = "${GARAGE_ADMIN_TOKEN:-dev-admin-token}" + +volumes: + platform-data: + redis-data: + qdrant-data: + garage-data: diff --git a/docs/configuration.md b/docs/configuration.md index 331a0e2..c26316b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -209,9 +209,18 @@ SPACE_APEX=example.site PARTNERS_APEX=example.partners ``` -The apex host rewrites are generated when Next builds, so the runtime values -must match the ones the image was built with. The community image bakes -`SPACE_APEX=cbk-space.localhost` and `PORTAL_APEX=cbk-portal.localhost`; see +Space and portal host routing read `SPACE_APEX` and `PORTAL_APEX` when the +server starts. Set `SPACE_APEX` to +`space.localhost`, for example, to serve a site named `test` publicly at +`http://test.space.localhost:3000/`. Recreate the container after changing the +variable; the same image supports the new domain without rebuilding. The +Community and Studio stacks default to `SPACE_APEX=cbk-space.localhost` +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 [Deployment](./deployment.md#production-boundary). ## App shell origins diff --git a/docs/deployment.md b/docs/deployment.md index 7b966d8..bf49485 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -186,6 +186,18 @@ its workspaces kept under `/data/sandbox` in the same volume. | Flavor | Database | Cache | Vector | Storage | | ----------- | ----------------------- | ----- | ------ | ------- | | `community` | SQLite (default module) | Redis | Qdrant | Garage | +| `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 +[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 +as Community. Once published from `main`, run it with: + +```bash +docker compose -f oci://ghcr.io/chatbotkit/platform-studio:latest up +``` A PostgreSQL flavor would swap the database column only; the other services travel unchanged. @@ -220,17 +232,22 @@ parts of host and subscription configuration, is therefore not baked into the 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. Two -apexes are baked alongside it so deployment-issued subdomains work out of the -box: `SPACE_APEX=cbk-space.localhost` and `PORTAL_APEX=cbk-portal.localhost`, -and the two app shells answer at `http://cbk-apps.localhost:3000` and +topology: `SITE_URL=http://cbk.localhost:3000`, with no external zones. +The two app shells answer 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 as `acme` answers at `http://acme.cbk-space.localhost:3000` with no DNS or -hosts-file setup (`curl` needs `--resolve`). The runtime apexes and shell -origins must name the same hosts as the build, which the compose files ensure; -a different host needs a rebuild with the matching build arguments. Runtime service variables +hosts-file setup (`curl` needs `--resolve`). The Community and Studio Compose +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 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/lib/host.ts b/platform/lib/host.ts index 31b33e8..a7375d7 100644 --- a/platform/lib/host.ts +++ b/platform/lib/host.ts @@ -22,6 +22,10 @@ import { isLocalhost } from '@/lib/localhost' import { z } from 'zod' +// @note callbacks outside a request need the port from SITE_URL; hostname alone +// sends the community stack's background clock to port 80 instead of 3000 +const configuredSite = new URL(siteUrl) + // @note these variables are hosts, not URLs - the URL builders below prepend // the scheme themselves, so a value like `https://api.example.com` would // produce `https://https/...` targets. Normalise rather than reject: strip any @@ -94,7 +98,7 @@ export function getLocalHost(): string { // When running remotely, the localhost is the host of the incoming request, // or the default host configured by the site URL. - return getContextRequestHost() || siteHostname + return getContextRequestHost() || configuredSite.host } /** @@ -136,7 +140,7 @@ export function getExternalHost(): string { return ( // getContextFrontendHost() || // @note causes issues with infinite redirect in fetch - getContextRequestHost() || siteHostname + getContextRequestHost() || configuredSite.host ) } @@ -263,16 +267,21 @@ export function getExternalAPIHost(host?: string): string { const siteHost = siteHostname.startsWith('api.') ? siteHostname.slice(4) : siteHostname.startsWith('next.') - ? siteHostname.slice(5) - : siteHostname + ? siteHostname.slice(5) + : siteHostname const bareHost = host.startsWith('api.') ? host.slice(4) : host.startsWith('next.') - ? host.slice(5) - : host - - return bareHost === siteHost ? apiHostname : host + ? host.slice(5) + : host + + // @note keep hostname-only callers working while also matching the configured + // port carried by request hosts and background callbacks + return bareHost === siteHost || + (configuredSite.port && bareHost === `${siteHost}:${configuredSite.port}`) + ? apiHostname + : host } /** diff --git a/platform/lib/host.utest.js b/platform/lib/host.utest.js index d7a529b..ad6835b 100644 --- a/platform/lib/host.utest.js +++ b/platform/lib/host.utest.js @@ -222,6 +222,65 @@ function loadHostScenario({ } describe('host selection', () => { + it.each([ + 'platform.example.com', + 'api.platform.example.com', + 'next.platform.example.com', + ])('preserves explicit hostname API mappings for %s', (explicitHost) => { + const host = loadHostScenario({ + testSiteUrl: 'https://platform.example.com:8443', + apiUrl: 'https://api.platform.example.com:9443', + }) + + expect(host.getExternalAPIHost(explicitHost)).toBe( + 'api.platform.example.com' + ) + expect( + host.getExternalAPIHostURL( + '/v1/models', + host.getExternalAPIHost(explicitHost) + ) + ).toBe('https://api.platform.example.com:9443/v1/models') + }) + + it.each([ + 'http://cbk.localhost:3000', + 'http://127.0.0.1:3000', + 'http://[::1]:3000', + 'http://platform.internal:3000', + 'https://platform.example.com:8443', + ])( + 'preserves the configured port without a request for %s', + (testSiteUrl) => { + const host = loadHostScenario({ testSiteUrl }) + const route = '/api/system/clock/queue' + const expectedUrl = `${testSiteUrl}${route}` + + expect(host.getLocalAPIHostURL(route)).toBe(expectedUrl) + expect(host.getLocalHost()).toBe(new URL(testSiteUrl).host) + expect(host.getExternalHost()).toBe(new URL(testSiteUrl).host) + expect(host.getLocalHostURL(route)).toBe(expectedUrl) + expect(host.getExternalHostURL(route)).toBe(expectedUrl) + expect(host.getExternalFrontendHostURL(route)).toBe(expectedUrl) + expect(host.getExternalAPIHostURL(route)).toBe(expectedUrl) + } + ) + + it.each([null, 'platform.example.com:8443'])( + 'keeps the configured API origin for a site with a port and request host %s', + (requestHost) => { + const host = loadHostScenario({ + testSiteUrl: 'https://platform.example.com:8443', + apiUrl: 'https://api.platform.example.com:9443', + requestHost, + }) + + expect(host.getExternalAPIHostURL('/api/system/clock/queue')).toBe( + 'https://api.platform.example.com:9443/api/system/clock/queue' + ) + } + ) + it('uses the configured site host by default in production', () => { const host = loadHostScenario() diff --git a/platform/lib/proxy.routing.utest.js b/platform/lib/proxy.routing.utest.js new file mode 100644 index 0000000..a146930 --- /dev/null +++ b/platform/lib/proxy.routing.utest.js @@ -0,0 +1,603 @@ +/** @jest-environment node */ +import { execFile, spawn } from 'node:child_process' +import { once } from 'node:events' +import fs from 'node:fs/promises' +import { request as httpRequest } from 'node:http' +import { createServer } from 'node:net' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' + +const execute = promisify(execFile) +const project = process.cwd() +const next = path.join(project, 'node_modules/next/dist/bin/next') + +jest.setTimeout(120000) + +// @note compile the real routing files into a small Next production app so +// build-time environment capture and framework rewrite behavior are exercised +// without rebuilding the platform or connecting to its database +describe.each(['', '/platform'])( + 'host routing in a production build with base path "%s"', + (basePath) => { + let directory + let server + let origin + let output = '' + + const environment = { + ...process.env, + NODE_ENV: 'production', + NEXT_TELEMETRY_DISABLED: '1', + SPACE_APEX: 'cbk-space.localhost', + PORTAL_APEX: 'cbk-portal.localhost', + } + + async function write(file, content) { + const target = path.join(directory, file) + + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, content) + } + + async function start( + apex = 'space.localhost', + portalApex = 'portal.localhost' + ) { + const socket = createServer() + + socket.listen(0, '127.0.0.1') + await once(socket, 'listening') + + const port = socket.address().port + + await new Promise((resolve) => socket.close(resolve)) + + origin = `http://127.0.0.1:${port}` + output = '' + server = spawn( + process.execPath, + [next, 'start', '-H', '127.0.0.1', '-p', String(port)], + { + cwd: directory, + env: { ...environment, SPACE_APEX: apex, PORTAL_APEX: portalApex }, + stdio: ['ignore', 'pipe', 'pipe'], + } + ) + server.stdout.on('data', (chunk) => { + output += chunk + }) + server.stderr.on('data', (chunk) => { + output += chunk + }) + + for (let attempt = 0; attempt < 200; attempt++) { + if (server.exitCode !== null) { + throw new Error(`Next exited before startup: ${output}`) + } + + try { + const response = await request('127.0.0.1', '/api/health') + + if (response.ok) { + return + } + } catch { + // @note wait for the production server's listening socket + } + + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + throw new Error(`Next did not start: ${output}`) + } + + async function stop() { + if (server && server.exitCode === null) { + const exited = once(server, 'exit') + + server.kill('SIGTERM') + await exited + } + + server = undefined + } + + function request(host, pathname = '/', init = {}) { + // @note Node fetch replaces Host with the URL host; use HTTP directly to + // exercise virtual hosts while connecting to the fixture's loopback socket + return new Promise((resolve, reject) => { + const req = httpRequest( + `${origin}${basePath}${basePath && pathname === '/' ? '' : pathname}`, + { + ...init, + headers: { host, ...init.headers }, + }, + (response) => { + const chunks = [] + + response.on('data', (chunk) => chunks.push(chunk)) + response.on('error', reject) + response.on('end', () => { + const headers = new Headers() + + for (const [name, value] of Object.entries(response.headers)) { + for (const entry of [].concat(value || [])) { + headers.append(name, entry) + } + } + + resolve( + new Response( + init.method === 'HEAD' ? null : Buffer.concat(chunks), + { + status: response.statusCode, + headers, + } + ) + ) + }) + } + ) + + req.on('error', reject) + req.end() + }) + } + + 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' + ) + await write( + 'package.json', + JSON.stringify({ private: true, type: 'module' }) + ) + await write( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + paths: { '@/*': ['./*'] }, + esModuleInterop: true, + allowJs: true, + }, + }) + ) + + // @note build the actual runtime proxy and its configuration + for (const file of [ + 'proxy.ts', + 'config/apexes.js', + 'config/debug.ts', + 'lib/portal.hostname.ts', + 'lib/context.store.js', + 'lib/debug.ts', + 'lib/redact.secrets.ts', + 'lib/space.site.ts', + 'lib/response.js', + 'lib/error.js', + 'lib/json.ts', + 'lib/struct.ts', + 'lib/nextjs.config.rewrites.js', + 'next.config.d/portals.config.js', + 'next.config.d/spaces.config.js', + 'next.config.d/transpile.config.js', + ]) { + await write(file, await fs.readFile(path.join(project, file), 'utf8')) + } + + await write( + 'next.config.mjs', + ` + import spaces from './next.config.d/spaces.config.js' + import transpile from './next.config.d/transpile.config.js' + import portals from './next.config.d/portals.config.js' + export default { ...transpile, experimental: { cpus: 1 }, + basePath: ${JSON.stringify(basePath)}, + i18n: ${ + basePath + ? "{ locales: ['en', 'fr'], defaultLocale: 'en' }" + : 'undefined' + }, + async rewrites() { + const portalRules = await portals.rewrites() + const spaceRules = await spaces.rewrites() + return { + beforeFiles: [...portalRules.beforeFiles, ...spaceRules.beforeFiles], + afterFiles: [...portalRules.afterFiles, ...spaceRules.afterFiles], + fallback: [...portalRules.fallback, ...spaceRules.fallback], + } + }, + typescript: { ignoreBuildErrors: true } } + ` + ) + await write( + 'pages/index.js', + ` + export default function Index() { return null } + export function getServerSideProps() { + return { redirect: { destination: '/signin?callbackUrl=%2Foverview', permanent: false } } + } + ` + ) + await write( + 'pages/api/health.js', + 'export default function handler(req, res) { res.json({ ok: true }) }' + ) + + for (const route of [ + 'apps/index', + 'apps/chat/[[...path]]', + 'apps/oauth/callback', + 'apps/assets/[file]', + 'apps/404', + 'secrets/oauth/callback', + 'secrets/[secretId]/manager/authenticate', + 'secrets/[secretId]/manager/oauth/callback', + 'integrations/widget/v1.js', + 'integrations/widget/[integrationId]/frame', + 'integrations/widget/[integrationId]/test', + 'integrations/mcpserver/v1.js', + 'integrations/mcpserver/[integrationId]/frame', + 'integrations/mcpserver/[integrationId]/test', + 'redirect/target', + 'partner/signin/acme', + ]) { + await write( + `pages/${route}.js`, + ` + export default function Page() { return null } + export function getServerSideProps({ req, res, query }) { + res.setHeader('x-fixture-route', ${JSON.stringify(route)}) + res.setHeader('x-fixture-host', req.headers.host) + res.setHeader('x-fixture-query', JSON.stringify(query)) + 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( + 'pages/api/v1/space/system/site/[[...path]].js', + ` + export default function handler(req, res) { + res.setHeader('x-space-site', 'public') + res.json({ host: req.headers.host, path: req.query.path || [], query: req.query.q }) + } + ` + ) + + await execute(process.execPath, [next, 'build', '--webpack'], { + cwd: directory, + env: environment, + timeout: 90000, + maxBuffer: 2 * 1024 * 1024, + }) + }) + + afterEach(stop) + afterAll(async () => { + await stop() + + if (directory) { + await fs.rm(directory, { recursive: true, force: true }) + } + }) + + it('does not bake the space domain into the rewrite manifest', async () => { + const manifest = await fs.readFile( + path.join(directory, '.next/routes-manifest.json'), + 'utf8' + ) + + const hostConditions = JSON.parse(manifest) + .rewrites.beforeFiles.filter((rule) => + rule.destination.includes('/api/v1/space/system/site') + ) + .flatMap((rule) => rule.has || []) + .filter((condition) => condition.type === 'host') + + expect(hostConditions).toEqual([]) + }) + + it('serves an anonymous space on the runtime apex instead of redirecting to sign-in', async () => { + await start() + + const response = await request('test.space.localhost:3000') + + expect(response.status).toBe(200) + expect(response.headers.get('x-space-site')).toBe('public') + expect(response.headers.get('location')).toBeNull() + }) + + it('does not retain the space apex from the build', async () => { + await start() + + const response = await request('test.cbk-space.localhost:3000') + + expect(response.headers.get('x-space-site')).toBeNull() + expect(response.status).toBe(307) + }) + + it('preserves asset paths, encoded filenames, queries and HEAD requests', async () => { + await start() + + const response = await request( + 'test.space.localhost:3000', + '/assets/hello%20world.css?q=one%20two' + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + host: 'test.space.localhost:3000', + path: ['assets', 'hello world.css'], + query: 'one two', + }) + + const head = await request('test.space.localhost:3000', '/index.html', { + method: 'HEAD', + }) + + expect(head.headers.get('x-space-site')).toBe('public') + expect(await head.text()).toBe('') + }) + + it('leaves API requests and unrelated application hosts on their existing routes', async () => { + await start() + + const api = await request('test.space.localhost:3000', '/api/health') + + expect(await api.json()).toEqual({ ok: true }) + + const app = await request('cbk.localhost:3000') + + expect(app.status).toBe(307) + expect(app.headers.get('x-space-site')).toBeNull() + }) + + it('supports the hosted space domain with the same build', async () => { + await start('chatbotkit.space') + + const response = await request('test.chatbotkit.space') + + expect(response.status).toBe(200) + expect(response.headers.get('x-space-site')).toBe('public') + }) + + it('routes space and portal paths with the configured base path and locales', async () => { + await start() + + const locale = basePath ? '/fr' : '' + const space = await request( + 'test.space.localhost:3000', + `${locale}/docs?q=one` + ) + const portal = await request( + 'test.portal.localhost:3000', + `${locale}/chat` + ) + + expect(space.status).toBe(200) + expect(await space.json()).toEqual({ + host: 'test.space.localhost:3000', + path: ['docs'], + query: 'one', + }) + expect(portal.headers.get('x-fixture-route')).toBe( + 'apps/chat/[[...path]]' + ) + }) + + it('rejects spoofed space markers and prevents cross-routing between spaces and portals', async () => { + await start() + + const headers = { 'x-cbk-space-site': '1', 'x-cbk-portal': '1' } + const app = await request('cbk.localhost:3000', '/', { headers }) + const portal = await request('test.portal.localhost:3000', '/', { + headers, + }) + const space = await request('test.space.localhost:3000', '/', { headers }) + const excluded = await request( + 'test.space.localhost:3000', + '/redirect/target', + { headers } + ) + + expect(app.status).toBe(307) + expect(app.headers.get('x-space-site')).toBeNull() + expect(app.headers.get('x-fixture-route')).toBeNull() + expect(portal.headers.get('x-space-site')).toBeNull() + expect(portal.headers.get('x-fixture-route')).toBe('apps/index') + expect(space.headers.get('x-space-site')).toBe('public') + expect(space.headers.get('x-fixture-route')).toBeNull() + expect(excluded.headers.get('x-fixture-route')).toBe('redirect/target') + }) + + it('disables space routing when the runtime apex is unset', async () => { + await start('') + + const response = await request('test.cbk-space.localhost:3000') + + expect(response.status).toBe(307) + expect(response.headers.get('x-space-site')).toBeNull() + }) + + it('serves a portal on the runtime apex instead of redirecting to platform sign-in', async () => { + await start() + + const response = await request('test.portal.localhost:3000') + + expect(response.status).toBe(200) + expect(response.headers.get('x-fixture-route')).toBe('apps/index') + expect(response.headers.get('x-fixture-host')).toBe( + 'test.portal.localhost:3000' + ) + expect(response.headers.get('location')).toBeNull() + }) + + it('does not bake the portal domain into the rewrite manifest', async () => { + const manifest = await fs.readFile( + path.join(directory, '.next/routes-manifest.json'), + 'utf8' + ) + + const hostConditions = Object.values(JSON.parse(manifest).rewrites) + .flat() + .flatMap((rule) => rule.has || []) + .filter((condition) => condition.type === 'host') + + expect(hostConditions).toEqual([]) + }) + + it('preserves portal app paths, OAuth, encoded assets, queries and HEAD', async () => { + await start() + + for (const [pathname, route] of [ + ['/chat/conversation?q=one%20two', 'apps/chat/[[...path]]'], + ['/oauth/callback', 'apps/oauth/callback'], + ['/assets/hello%20world.css?q=one%20two', 'apps/assets/[file]'], + ]) { + const response = await request('test.portal.localhost:3000', pathname) + + expect(response.status).toBe(200) + expect(response.headers.get('x-fixture-route')).toBe(route) + expect(response.headers.get('x-fixture-host')).toBe( + 'test.portal.localhost:3000' + ) + + if (pathname.includes('?')) { + expect(JSON.parse(response.headers.get('x-fixture-query')).q).toBe( + 'one two' + ) + } + + if (pathname.includes('hello')) { + expect(JSON.parse(response.headers.get('x-fixture-query')).file).toBe( + 'hello world.css' + ) + } + } + + const head = await request('test.portal.localhost:3000', '/chat', { + method: 'HEAD', + }) + + expect(head.headers.get('x-fixture-route')).toBe('apps/chat/[[...path]]') + expect(await head.text()).toBe('') + }) + + it('keeps existing portal callback, embed, redirect and API routes', async () => { + await start() + + for (const [pathname, route] of [ + ['/secrets/oauth/callback', 'secrets/oauth/callback'], + [ + '/secrets/demo/manager/authenticate', + 'secrets/[secretId]/manager/authenticate', + ], + [ + '/secrets/demo/manager/oauth/callback', + 'secrets/[secretId]/manager/oauth/callback', + ], + ['/integrations/widget/v1.js', 'integrations/widget/v1.js'], + [ + '/integrations/widget/demo/frame', + 'integrations/widget/[integrationId]/frame', + ], + [ + '/integrations/widget/demo/test', + 'integrations/widget/[integrationId]/test', + ], + ['/integrations/mcpserver/v1.js', 'integrations/mcpserver/v1.js'], + [ + '/integrations/mcpserver/demo/frame', + 'integrations/mcpserver/[integrationId]/frame', + ], + [ + '/integrations/mcpserver/demo/test', + 'integrations/mcpserver/[integrationId]/test', + ], + ['/redirect/target', 'redirect/target'], + ['/partner/signin/acme', 'partner/signin/acme'], + ]) { + const response = await request('test.portal.localhost:3000', pathname) + + expect(response.headers.get('x-fixture-route')).toBe(route) + } + + const api = await request('test.portal.localhost:3000', '/api/health') + + expect(await api.json()).toEqual({ ok: true }) + }) + + it('uses the portal fallback only after an excluded route fails to resolve', async () => { + await start() + + const existing = await request( + 'test.portal.localhost:3000', + '/redirect/target' + ) + const missing = await request( + 'test.portal.localhost:3000', + '/redirect/missing' + ) + + expect(existing.headers.get('x-fixture-route')).toBe('redirect/target') + expect(missing.headers.get('x-fixture-route')).toBe('apps/404') + }) + + it('does not route unrelated hosts or accept a client-supplied portal marker', async () => { + await start() + + for (const host of [ + 'test.cbk-portal.localhost:3000', + 'portal.localhost:3000', + 'testXportal.localhost:3000', + 'test.portal.localhost.attacker.example', + 'custom.example.com', + ]) { + const response = await request(host, '/', { + headers: { + 'x-cbk-portal': '1', + 'x-forwarded-host': 'test.portal.localhost:3000', + }, + }) + + expect(response.status).toBe(307) + expect(response.headers.get('x-fixture-route')).toBeNull() + } + + const missing = await request('cbk.localhost:3000', '/redirect/missing', { + headers: { 'x-cbk-portal': '1' }, + }) + + expect(missing.status).toBe(404) + expect(missing.headers.get('x-fixture-route')).toBeNull() + }) + + it('supports the hosted portal domain with the same build', async () => { + await start('chatbotkit.space', 'chatbotkit.agency') + + const response = await request('test.chatbotkit.agency') + + expect(response.status).toBe(200) + expect(response.headers.get('x-fixture-route')).toBe('apps/index') + }) + + it('disables portal routing when the runtime apex is unset', async () => { + await start('space.localhost', '') + + const response = await request('test.cbk-portal.localhost:3000', '/', { + headers: { 'x-cbk-portal': '1' }, + }) + + expect(response.status).toBe(307) + expect(response.headers.get('x-fixture-route')).toBeNull() + }) + } +) diff --git a/platform/next.config.d/portals.config.js b/platform/next.config.d/portals.config.js index 5b88ad5..1c4131c 100644 --- a/platform/next.config.d/portals.config.js +++ b/platform/next.config.d/portals.config.js @@ -1,24 +1,17 @@ /* eslint-disable import/extensions, import/no-anonymous-default-export */ // @ts-check -import { - buildCaptureAllSource, - escapeRegex, -} from '../lib/nextjs.config.rewrites.js' -import { APEXES } from '../config/apexes.js' +import { buildCaptureAllSource } from '../lib/nextjs.config.rewrites.js' /** @type {import('next').NextConfig} */ export default { async rewrites() { - // @note rules exist only when the deployment names a portal apex - - // custom portal domains are routed by their own host handling - if (!APEXES.portal) { - return { beforeFiles: [], afterFiles: [], fallback: [] } - } - + // @note proxy.ts replaces this marker on every request using the runtime + // PORTAL_APEX; no deployment hostname is captured in the build const has = [ { - type: /** @type {'host'} */ ('host'), - value: `(?.+?).${escapeRegex(APEXES.portal)}`, + type: /** @type {'header'} */ ('header'), + key: 'x-cbk-portal', + value: '1', }, ] @@ -60,27 +53,26 @@ export default { oauth: true, }, }), - has: has, + has, destination: '/apps/:path*', }, + // @note localized roots need an explicit match after the catch-all; + // putting it first would let the catch-all prefix /apps a second time + { + source: '/', + has, + destination: '/apps', + }, ], afterFiles: [], fallback: [ - // 404 - - ...[ - // the portal apex hosts - - ...[ - { - source: '/:path*', - has: has, - destination: `/apps/404`, - }, - ], - ], + { + source: '/:path*', + has, + destination: '/apps/404', + }, ], } }, diff --git a/platform/next.config.d/spaces.config.js b/platform/next.config.d/spaces.config.js index 07b434d..f5a43fc 100644 --- a/platform/next.config.d/spaces.config.js +++ b/platform/next.config.d/spaces.config.js @@ -1,60 +1,40 @@ /* eslint-disable import/extensions, import/no-anonymous-default-export */ // @ts-check -import { APEXES } from '../config/apexes.js' -import { - buildCaptureAllSource, - escapeRegex, -} from '../lib/nextjs.config.rewrites.js' - -// @note SpaceSite static-website hosting. A `.` host is -// rewritten to the public serving route, which resolves the SpaceSite by its -// slug and serves the backing space's storage. This is -// the SpaceSite analogue of portals.config.js (which maps portal hosts to -// `/apps`). Unlike portals there is no launcher at the root, so the bare `/` -// also routes to the serving handler (which serves the directory index). -// -// The destination `system` is a literal segment under `/api/v1/space`, so it -// takes precedence over the `[spaceId]` management routes (which only ever see -// real space ids). -// @note rules exist only when the deployment names a space apex - without -// one there are no space-site hosts to serve -const has = APEXES.space - ? [ - { - type: /** @type {'host'} */ ('host'), - value: `(?.+?).${escapeRegex(APEXES.space)}`, - }, - ] - : null +import { buildCaptureAllSource } from '../lib/nextjs.config.rewrites.js' /** @type {import('next').NextConfig} */ export default { async rewrites() { - if (!has) { - return { beforeFiles: [], afterFiles: [], fallback: [] } - } + // @note proxy.ts replaces this marker on every request using the runtime + // SPACE_APEX; no deployment hostname is captured in the build + const has = [ + { + type: /** @type {'header'} */ ('header'), + key: 'x-cbk-space-site', + value: '1', + }, + ] return { beforeFiles: [ { source: buildCaptureAllSource({ - // @note empty so any file extension is served from the space + // @note all file extensions belong to the space's public storage allowedExtensions: [], - excludes: ['redirect'], }), has, destination: '/api/v1/space/system/site/:path*', }, + // @note an explicit root also matches when Next prefixes the source + // with a locale; the capture-all requires a slash after that locale { source: '/', has, destination: '/api/v1/space/system/site', }, ], - afterFiles: [], - fallback: [], } }, diff --git a/platform/next.config.d/spaces.config.utest.js b/platform/next.config.d/spaces.config.utest.js new file mode 100644 index 0000000..35e07f3 --- /dev/null +++ b/platform/next.config.d/spaces.config.utest.js @@ -0,0 +1,73 @@ +/** @jest-environment node */ +import { unstable_getResponseFromNextConfig } from 'next/experimental/testing/server' + +import spaces from './spaces.config' + +describe('space site path routing', () => { + it.each([ + '/', + '/index.html', + '/assets/site.css', + '/scripts/app.js', + '/docs/page', + '/downloads/archive.zip', + '/favicon.ico', + ])('serves the public path %s', async (pathname) => { + const response = await unstable_getResponseFromNextConfig({ + nextConfig: spaces, + url: `http://localhost:3000${pathname}`, + headers: { 'x-cbk-space-site': '1' }, + }) + + expect(response.headers.get('x-middleware-rewrite')).toBe( + `http://localhost:3000/api/v1/space/system/site${pathname}` + ) + }) + + it('preserves the framework trailing-slash redirect before rewriting', async () => { + const response = await unstable_getResponseFromNextConfig({ + nextConfig: spaces, + url: 'http://localhost:3000/docs/page/', + headers: { 'x-cbk-space-site': '1' }, + }) + + expect(response.status).toBe(308) + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/docs/page' + ) + }) + + it.each([ + '/_next/static/chunk.js', + '/api/v1/space/123', + '/apiary', + '/oauth/callback', + '/monitoring-tunnel', + '/s/example', + '/apps/demo/icon', + '/partner/signin/acme', + '/redirect', + '/redirect/target', + ])('preserves the platform route exclusion for %s', async (pathname) => { + const response = await unstable_getResponseFromNextConfig({ + nextConfig: spaces, + url: `http://localhost:3000${pathname}`, + headers: { 'x-cbk-space-site': '1' }, + }) + + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + }) + + it.each([{}, { 'x-cbk-portal': '1' }, { 'x-cbk-space-site': 'untrusted' }])( + 'requires the space classification marker: %j', + async (headers) => { + const response = await unstable_getResponseFromNextConfig({ + nextConfig: spaces, + url: 'http://test.space.localhost:3000/', + headers, + }) + + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + } + ) +}) diff --git a/platform/package.json b/platform/package.json index dedf21c..3ec2aa2 100644 --- a/platform/package.json +++ b/platform/package.json @@ -40,7 +40,7 @@ "format": "run-s format:*", "graphql:gen": "graphql-codegen --require tsconfig-paths/register --config codegen.ts", "lint": "run-s lint:*", - "lint:next": "eslint --ext .js,.jsx,.ts,.tsx pages app components layouts lib", + "lint:next": "eslint --ext .js,.jsx,.ts,.tsx pages app components layouts lib proxy.ts proxy.utest.js", "lint:scripts": "eslint 'scripts/**/*.{js,ts}'", "script:cleanup-old-usage-records": "tsx scripts/cleanup-old-usage-records.js", "script:cleanup-old-event-logs": "tsx scripts/cleanup-old-event-logs.js", diff --git a/platform/proxy.ts b/platform/proxy.ts new file mode 100644 index 0000000..11665ae --- /dev/null +++ b/platform/proxy.ts @@ -0,0 +1,43 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' + +import { getPortalSlugFromHostname } from '@/lib/portal.hostname' +import { getSpaceSiteSlug } from '@/lib/space.site' + +/** + * Selects space and portal hosts using the running deployment's apexes. + * 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 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 + headers.delete('x-cbk-space-site') + headers.delete('x-cbk-portal') + + // @note a space host takes precedence if the configured apexes overlap; + // assign only one marker so rewrite phases cannot route the request twice + switch (true) { + case !!hostname && !!getSpaceSiteSlug(hostname): + headers.set('x-cbk-space-site', '1') + + break + + case !!hostname && !!getPortalSlugFromHostname(hostname): + headers.set('x-cbk-portal', '1') + + break + } + + return NextResponse.next({ request: { headers } }) +} + +export const config = { + // @note run on every path to remove untrusted routing markers before both + // the beforeFiles and fallback rewrite phases + matcher: '/:path*', +} diff --git a/platform/proxy.utest.js b/platform/proxy.utest.js new file mode 100644 index 0000000..17e3154 --- /dev/null +++ b/platform/proxy.utest.js @@ -0,0 +1,273 @@ +/** @jest-environment node */ +import { unstable_doesMiddlewareMatch } from 'next/experimental/testing/server' +import { NextRequest } from 'next/server' + +import { config } from './proxy' + +async function loadProxy(apex, portalApex = '') { + const previous = { + NODE_ENV: process.env.NODE_ENV, + SPACE_APEX: process.env.SPACE_APEX, + PORTAL_APEX: process.env.PORTAL_APEX, + } + let proxy + + try { + process.env.NODE_ENV = 'production' + process.env.SPACE_APEX = apex + process.env.PORTAL_APEX = portalApex + await jest.isolateModulesAsync(async () => { + proxy = (await import('./proxy')).proxy + }) + + return proxy + } finally { + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } + } + } +} + +describe('runtime space host routing', () => { + it('classifies a space host without rewriting its URL', async () => { + const proxy = await loadProxy('space.localhost') + const request = new NextRequest('http://localhost:3000/docs?q=one', { + headers: { + host: 'test.space.localhost:3000', + 'x-cbk-space-site': 'untrusted', + }, + }) + const response = proxy(request) + + expect(response.headers.get('x-middleware-request-x-cbk-space-site')).toBe( + '1' + ) + expect(response.headers.get('x-middleware-request-x-cbk-portal')).toBeNull() + expect(response.headers.get('x-middleware-next')).toBe('1') + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + expect(request.url).toBe('http://localhost:3000/docs?q=one') + }) + + 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/', { + headers: { host: 'test.space.localhost:3000' }, + nextConfig: { + basePath: '/platform', + i18n: { locales: ['en', 'fr'], defaultLocale: 'en' }, + }, + }) + + expect( + proxy(request).headers.get('x-middleware-request-x-cbk-space-site') + ).toBe('1') + expect(request.url).toBe('http://localhost:3000/platform/fr/docs/') + }) + + it('preserves the original host, path and query during classification', async () => { + const proxy = await loadProxy('space.localhost') + const request = new NextRequest( + 'http://localhost:3000/assets/a%20b.css?theme=dark', + { + headers: { host: 'TEST.space.localhost:3000' }, + } + ) + const response = proxy(request) + + expect(response.headers.get('x-middleware-request-x-cbk-space-site')).toBe( + '1' + ) + expect(request.url).toBe( + 'http://localhost:3000/assets/a%20b.css?theme=dark' + ) + expect(request.headers.get('host')).toBe('TEST.space.localhost:3000') + expect(response.headers.get('location')).toBeNull() + }) + + it.each([ + 'cbk.localhost:3000', + 'test.cbk-space.localhost:3000', + 'space.localhost:3000', + 'a.b.space.localhost:3000', + 'test.space.localhost.attacker.example', + 'test.notspace.localhost:3000', + ])('leaves the unrelated host %s alone', async (host) => { + const proxy = await loadProxy('space.localhost') + const response = proxy( + new NextRequest('http://localhost:3000/', { headers: { host } }) + ) + + expect(response.headers.get('x-middleware-next')).toBe('1') + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + expect( + response.headers.get('x-middleware-request-x-cbk-space-site') + ).toBeNull() + }) + + it('does not trust a forwarded host to select a public space', async () => { + const proxy = await loadProxy('space.localhost') + const response = proxy( + new NextRequest('http://localhost:3000/', { + headers: { + host: 'cbk.localhost:3000', + 'x-forwarded-host': 'test.space.localhost:3000', + }, + }) + ) + + expect(response.headers.get('x-middleware-next')).toBe('1') + expect( + response.headers.get('x-middleware-request-x-cbk-space-site') + ).toBeNull() + }) + + it('does not infer a space host from the request URL without a Host header', async () => { + const proxy = await loadProxy('space.localhost') + const response = proxy(new NextRequest('http://test.space.localhost:3000/')) + + expect(response.headers.get('x-middleware-next')).toBe('1') + expect( + response.headers.get('x-middleware-request-x-cbk-space-site') + ).toBeNull() + }) + + it('disables routing when no space apex is configured', async () => { + const proxy = await loadProxy('') + const response = proxy( + new NextRequest('http://localhost:3000/', { + headers: { host: 'test.space.localhost:3000' }, + }) + ) + + expect(response.headers.get('x-middleware-next')).toBe('1') + expect( + response.headers.get('x-middleware-request-x-cbk-space-site') + ).toBeNull() + }) +}) + +describe('runtime portal host selection', () => { + it.each([ + 'test.portal.localhost:3000', + 'TEST.PORTAL.LOCALHOST:3000', + 'a.b.portal.localhost:3000', + ])('selects portal rewrites for %s', async (host) => { + const proxy = await loadProxy('space.localhost', 'portal.localhost') + const response = proxy( + new NextRequest('http://localhost:3000/', { + headers: { host, 'x-cbk-portal': 'untrusted' }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-portal')).toBe('1') + expect(response.headers.get('x-middleware-request-host')).toBe(host) + expect(response.headers.get('location')).toBeNull() + }) + + it.each([ + 'portal.localhost:3000', + '.portal.localhost:3000', + 'test.cbk-portal.localhost:3000', + 'testXportal.localhost:3000', + 'test.portal.localhost.attacker.example', + 'custom.example.com', + ])('removes a spoofed portal marker on %s', async (host) => { + const proxy = await loadProxy('space.localhost', 'portal.localhost') + const response = proxy( + new NextRequest('http://localhost:3000/', { + headers: { + host, + 'x-cbk-portal': '1', + 'x-forwarded-host': 'test.portal.localhost:3000', + }, + }) + ) + + expect(response.headers.get('x-middleware-next')).toBe('1') + expect(response.headers.get('x-middleware-request-x-cbk-portal')).toBeNull() + }) + + it.each([ + '/', + '/api/health', + '/oauth/callback', + '/_next/static/file.js', + '/redirect/missing', + ])( + 'sanitizes the portal marker on %s even when the path bypasses space routing', + async (pathname) => { + expect( + unstable_doesMiddlewareMatch({ + config, + url: `http://localhost:3000${pathname}`, + }) + ).toBe(true) + + const proxy = await loadProxy('space.localhost', '') + const response = proxy( + new NextRequest(`http://localhost:3000${pathname}`, { + headers: { host: 'test.portal.localhost:3000', 'x-cbk-portal': '1' }, + }) + ) + + expect( + response.headers.get('x-middleware-request-x-cbk-portal') + ).toBeNull() + } + ) + + it('does not infer a portal host from the URL or forwarded headers', async () => { + const proxy = await loadProxy('', 'portal.localhost') + const response = proxy( + new NextRequest('http://test.portal.localhost:3000/', { + headers: { + 'x-cbk-portal': '1', + 'x-forwarded-host': 'test.portal.localhost:3000', + }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-portal')).toBeNull() + }) +}) + +describe('routing marker isolation', () => { + it.each([ + ['test.space.localhost:3000', '1', null], + ['test.portal.localhost:3000', null, '1'], + ['cbk.localhost:3000', null, null], + ])('replaces both markers for %s', async (host, space, portal) => { + const proxy = await loadProxy('space.localhost', 'portal.localhost') + const response = proxy( + new NextRequest('http://localhost:3000/', { + headers: { host, 'x-cbk-space-site': '1', 'x-cbk-portal': '1' }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-space-site')).toBe( + space + ) + expect(response.headers.get('x-middleware-request-x-cbk-portal')).toBe( + portal + ) + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + }) + + it('assigns only the space marker when apexes overlap', async () => { + const proxy = await loadProxy('space.localhost', 'space.localhost') + const response = proxy( + new NextRequest('http://localhost:3000/', { + headers: { host: 'test.space.localhost:3000' }, + }) + ) + + expect(response.headers.get('x-middleware-request-x-cbk-space-site')).toBe( + '1' + ) + expect(response.headers.get('x-middleware-request-x-cbk-portal')).toBeNull() + }) +}) From 4e33f0fe6793c09e2e829c4690a7992c5c29a2ce Mon Sep 17 00:00:00 2001 From: pdparchitect Date: Sun, 6 Sep 2026 08:56:52 +0000 Subject: [PATCH 2/2] test: enhance host selection tests for production site host configuration --- platform/lib/host.utest.js | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/platform/lib/host.utest.js b/platform/lib/host.utest.js index ad6835b..d728500 100644 --- a/platform/lib/host.utest.js +++ b/platform/lib/host.utest.js @@ -281,14 +281,22 @@ describe('host selection', () => { } ) - it('uses the configured site host by default in production', () => { - const host = loadHostScenario() + it.each([ + [siteUrl, new URL(siteUrl).host], + ['http://localhost:3000', 'localhost:3000'], + ['https://platform.example.com', 'platform.example.com'], + ['https://platform.example.com:8443', 'platform.example.com:8443'], + ])( + 'uses the configured site host by default in production for %s', + (testSiteUrl, expectedHost) => { + const host = loadHostScenario({ testSiteUrl }) - expect(host.getLocalHost()).toBe(siteHostname) - expect(host.getExternalHost()).toBe(siteHostname) - expect(host.getExternalFrontendHost()).toBe(siteHostname) - expect(host.getLocalAPIHost()).toBe(siteHostname) - }) + expect(host.getLocalHost()).toBe(expectedHost) + expect(host.getExternalHost()).toBe(expectedHost) + expect(host.getExternalFrontendHost()).toBe(expectedHost) + expect(host.getLocalAPIHost()).toBe(expectedHost) + } + ) it('prefers request and frontend context hosts when they are available', () => { const host = loadHostScenario({