From 0898723168b49265ffae0265b19afcc1f68b036d Mon Sep 17 00:00:00 2001 From: Peter White Date: Wed, 14 Oct 2026 10:18:25 +0200 Subject: [PATCH] Expose preview segment claims to expressions --- packages/gitbook/src/lib/visitors.test.ts | 26 +++++++++++++++++++++++ packages/gitbook/src/lib/visitors.ts | 18 ++++++++++++++-- packages/gitbook/src/middleware.ts | 20 ++++++++++++++--- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/gitbook/src/lib/visitors.test.ts b/packages/gitbook/src/lib/visitors.test.ts index f5e2f3c0b..1a299651e 100644 --- a/packages/gitbook/src/lib/visitors.test.ts +++ b/packages/gitbook/src/lib/visitors.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test'; import type { JwtPayload } from 'jwt-decode'; +import { NextRequest } from 'next/server'; import { type ResponseCookies, @@ -14,6 +15,7 @@ import { isRevalidationRequest, normalizeVisitorURL, parseHostnameList, + serveVisitorClaimsDataRequest, shouldSendVisitorCountry, } from './visitors'; @@ -691,3 +693,27 @@ describe('parseHostnameList', () => { ); }); }); + +describe('serveVisitorClaimsDataRequest', () => { + it('should expose the claims of the preview API token', async () => { + const siteRequestURL = new URL( + 'https://sites.gitbook.com/preview/site_foo/~gitbook/visitor' + ); + const claims = { apiUrl: 'https://client-123.example.com', isBeta: true }; + const previewAPIToken = [ + btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })), + btoa(JSON.stringify({ kind: 'site', site: 'site_foo', claims })), + 'signature', + ].join('.'); + + const response = serveVisitorClaimsDataRequest( + new NextRequest(siteRequestURL), + siteRequestURL, + previewAPIToken + ); + + expect(await response.json()).toEqual({ + visitor: { claims: { unsigned: {}, ...claims } }, + }); + }); +}); diff --git a/packages/gitbook/src/lib/visitors.ts b/packages/gitbook/src/lib/visitors.ts index 33a7ed937..e38794fe3 100644 --- a/packages/gitbook/src/lib/visitors.ts +++ b/packages/gitbook/src/lib/visitors.ts @@ -3,7 +3,7 @@ import { type JwtPayload, jwtDecode } from 'jwt-decode'; import { type NextRequest, NextResponse } from 'next/server'; import hash from 'object-hash'; -import type { SiteVisitorPayload } from '@gitbook/api'; +import type { SiteAPIToken, SiteVisitorPayload } from '@gitbook/api'; import { MAX_CHUNKED_COOKIE_LENGTH, @@ -571,7 +571,21 @@ export function getVisitorAuthCookieMaxAge(decoded: JwtPayload): number { /** * Handler for the /~gitbook/visitor middleware route to expose visitor data. */ -export function serveVisitorClaimsDataRequest(request: NextRequest, siteRequestURL: URL) { +export function serveVisitorClaimsDataRequest( + request: NextRequest, + siteRequestURL: URL, + previewAPIToken?: string +) { + // A site preview carries the visitor segment claims in its API token, not in a visitor token. + if (previewAPIToken) { + try { + const { claims } = jwtDecode(previewAPIToken); + return NextResponse.json({ visitor: { claims: { unsigned: {}, ...claims } } }); + } catch (error) { + console.warn('Error decoding preview API token', error); + } + } + const { visitorToken, unsignedClaims } = getVisitorData({ cookies: request.cookies.getAll(), headers: request.headers, diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 8a75df506..cdab10a2c 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -210,7 +210,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // Handler that returns visitor data for the app to consume. if (siteRequestURL.pathname.endsWith('/~gitbook/visitor')) { - return serveVisitorClaimsDataRequest(request, siteRequestURL); + const previewAPIToken = isPreviewRequest(siteRequestURL) + ? getAPITokenFromCookies( + request.cookies.getAll(), + getAPITokenCookieName(getPreviewScopePath(siteRequestURL)) + ) + : undefined; + return serveVisitorClaimsDataRequest(request, siteRequestURL, previewAPIToken); } // @@ -625,7 +631,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { request.headers.set('x-gitbook-disable-tracking', 'true'); return serveWithQueryAPIToken({ // We scope the API token to the site ID. - scopePath: ['preview', getPreviewRequestIdentifier(siteRequestURL)].join('/'), + scopePath: getPreviewScopePath(siteRequestURL), // We keep the original request URL when using `url` mode requestURL: mode === 'url' ? requestURL : siteRequestURL, requestCookies: request.cookies, @@ -677,6 +683,14 @@ function serveErrorResponse(error: Error) { throw error; } +function getPreviewScopePath(siteRequestURL: URL) { + return ['preview', getPreviewRequestIdentifier(siteRequestURL)].join('/'); +} + +function getAPITokenCookieName(scopePath: string) { + return getPathScopedCookieName('gitbook-api-token', scopePath); +} + /** * Serve a response with an API token obtained from the query params. */ @@ -689,7 +703,7 @@ async function serveWithQueryAPIToken(input: { const { scopePath, requestURL, requestCookies, serve } = input; // We store the API token in a cookie that is scoped to the specific route // to avoid errors when multiple previews are opened in different tabs. - const cookieName = getPathScopedCookieName('gitbook-api-token', scopePath); + const cookieName = getAPITokenCookieName(scopePath); // Extract a potential GitBook API token passed in the request // If found, we redirect to the same URL but with the token in the cookie