diff --git a/bun.lock b/bun.lock index d720c44b84..6e27a46b06 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ "name": "@gitbook/cache-tags", "version": "0.3.1", "dependencies": { - "@gitbook/api": "^0.111.0", + "@gitbook/api": "^0.115.0", "assert-never": "^1.2.1", }, "devDependencies": { @@ -51,7 +51,7 @@ "name": "gitbook", "version": "0.11.1", "dependencies": { - "@gitbook/api": "*", + "@gitbook/api": "^0.115.0", "@gitbook/cache-do": "workspace:*", "@gitbook/cache-tags": "workspace:*", "@gitbook/colors": "workspace:*", @@ -143,7 +143,7 @@ "name": "gitbook-v2", "version": "0.2.5", "dependencies": { - "@gitbook/api": "*", + "@gitbook/api": "^0.115.0", "@gitbook/cache-tags": "workspace:*", "@sindresorhus/fnv1a": "^3.1.0", "assert-never": "^1.2.1", @@ -202,7 +202,7 @@ "name": "@gitbook/react-contentkit", "version": "0.7.0", "dependencies": { - "@gitbook/api": "*", + "@gitbook/api": "^0.115.0", "@gitbook/icons": "workspace:*", "classnames": "^2.5.1", }, @@ -260,7 +260,7 @@ }, "overrides": { "@codemirror/state": "6.4.1", - "@gitbook/api": "0.113.0", + "@gitbook/api": "^0.115.0", "react": "^19.0.0", "react-dom": "^19.0.0", }, @@ -625,7 +625,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="], - "@gitbook/api": ["@gitbook/api@0.113.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-PWMeAkdm4bHSl3b5OmtcmskZ6qRkkDhauCPybo8sGnjS03O14YAUtubAQiNCKX/uwbs+yiQ8KRPyeIwn+g42yw=="], + "@gitbook/api": ["@gitbook/api@0.115.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-Lyj+1WVNnE/Zuuqa/1ZdnUQfUiNE6es89RFK6CJ+Tb36TFwls6mbHKXCZsBwSYyoMYTVK39WQ3Nob6Nw6+TWCA=="], "@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"], @@ -4077,7 +4077,7 @@ "gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], - "gitbook-v2/next": ["next@15.4.0-canary.7", "", { "dependencies": { "@next/env": "15.4.0-canary.7", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.0-canary.7", "@next/swc-darwin-x64": "15.4.0-canary.7", "@next/swc-linux-arm64-gnu": "15.4.0-canary.7", "@next/swc-linux-arm64-musl": "15.4.0-canary.7", "@next/swc-linux-x64-gnu": "15.4.0-canary.7", "@next/swc-linux-x64-musl": "15.4.0-canary.7", "@next/swc-win32-arm64-msvc": "15.4.0-canary.7", "@next/swc-win32-x64-msvc": "15.4.0-canary.7", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-ZYjT0iu+4osz8XIlr31MuoXaNQKRU75UcwEgNBt93gftoh6tzV2Mebz6sOGeVReYuYUvYlLJJksMBTNcFcPbSA=="], + "gitbook-v2/next": ["next@15.4.0-canary.26", "", { "dependencies": { "@next/env": "15.4.0-canary.26", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.0-canary.26", "@next/swc-darwin-x64": "15.4.0-canary.26", "@next/swc-linux-arm64-gnu": "15.4.0-canary.26", "@next/swc-linux-arm64-musl": "15.4.0-canary.26", "@next/swc-linux-x64-gnu": "15.4.0-canary.26", "@next/swc-linux-x64-musl": "15.4.0-canary.26", "@next/swc-win32-arm64-msvc": "15.4.0-canary.26", "@next/swc-win32-x64-msvc": "15.4.0-canary.26", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-0lq0x+H4ewc6vXth3S9shrcK3eYl+4wLXQqdboVwBbJe0ykB3+QbGdXFIEICCZsmbAOaii0ag0tzqD3y/vr3bw=="], "global-dirs/ini": ["ini@1.3.7", "", {}, "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ=="], @@ -4969,23 +4969,23 @@ "gaxios/https-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], - "gitbook-v2/next/@next/env": ["@next/env@15.4.0-canary.7", "", {}, "sha512-q8S7f2lQti3Y3gcAPzE8Pj8y0EwiWHVyyilMzoLbDPXGVfxlQhXLRiFdy2cDkKN4DyjGZWDeehEtw4huvJAa3Q=="], + "gitbook-v2/next/@next/env": ["@next/env@15.4.0-canary.26", "", {}, "sha512-+WeMYRfTZWaosbIAjuNESPVjynDz/NKukoR7mF/u3Wuwr40KgScpxD0IuU0T7XbPfprnaInSKAylufFvrXRh+A=="], - "gitbook-v2/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.0-canary.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+TMxUu5CAWNe+UFRc47BZAXQxCRqZfVbGyCldddiog4MorvL7kBxSd1qlmrwI73fRRKtXkHIH1TaeItyxzC9rQ=="], + "gitbook-v2/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.0-canary.26", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HxtmV8Uoai8Z4wAU1tFWzASogAS+xVVP5Z5frbFu0yQ+1ocb9xQTjNqhiD5xPSAU8pNGWasCod8tlTCBzJzHQg=="], - "gitbook-v2/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.4.0-canary.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-veXp8lg/X/7O+pG9BDQ3OizFz3B40v29jsvEWj+ULY/W8Z6+dCSd5XPP2M8fG/gKKKA0D6L0CnnM2Mj0RRSUJw=="], + "gitbook-v2/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.4.0-canary.26", "", { "os": "darwin", "cpu": "x64" }, "sha512-1MLiD1Bj6xSi5MkkQ8IK7A13KZJG9bzoWqdXT/tveVCinmYrl/zY7z/9dgvG+84gAE6uN4BGjp6f3IxRsvYDBA=="], - "gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.4.0-canary.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-KxNGfW7BO0Z5B9rJyl9p7YVjNrxAhu06mH6h1PSdouZG7YMYpdRCconVXeuBI0PEu6g3ywNrOVxZUk1V6G5u0Q=="], + "gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.4.0-canary.26", "", { "os": "linux", "cpu": "arm64" }, "sha512-cIVgFgOdMDbnPixR/u3ICW60/HlnDbACCb2O+p9+DJj7s1dsN63Cs9qxc9pDJb7tgL0BFPhYcmGeJfd/bZ4h7w=="], - "gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.4.0-canary.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-THgXgmP/cC4DsNwvC6uqB90CebB7Ep1KyZajQL3fYKT5V4SWr46yngKLyoyJVeAYWJH908MrWddf7Ya/Zq7cyg=="], + "gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.4.0-canary.26", "", { "os": "linux", "cpu": "arm64" }, "sha512-o4YS6E3FD2DpZBDvUai9bPMLcpcNZ3THc2BzysSbZeARPiAQuKoudwPJoCpi2t7vajrvczpxBwTPG2uL05ypEA=="], - "gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.4.0-canary.7", "", { "os": "linux", "cpu": "x64" }, "sha512-kpLB3Jj7fProynQYj2ahFyZlJs0xwm71VzCVrNRu6u7qJGXn6dK5h7+hro8y/y1iqjXWgCLSdxWSHahhWK8XdQ=="], + "gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.4.0-canary.26", "", { "os": "linux", "cpu": "x64" }, "sha512-M2/MFrQcPI7Ul5Fq5AOeoARrT0B9SrGiy7BLnPuE7Iai1+xkhfSsxIMF5JeDm/GfJnzcwA2oSvrOg0e7KKdaCA=="], - "gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.4.0-canary.7", "", { "os": "linux", "cpu": "x64" }, "sha512-rnGAKvl4cWPVV9D+SybWOGijm0VmKXyqQ+IN0A6WDgdlYZAZP0ZnJv/rq7DSvuOh19AXS8UpQc88SelXV/3j3Q=="], + "gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.4.0-canary.26", "", { "os": "linux", "cpu": "x64" }, "sha512-p5JpQ7k/1LyBzNZglqA8JJm7GRmadPkTyHoWaqMxhiVdcQHGbjwsiNjjAtMNjetNOXxj8ebxjiBsAt+34Ak1IQ=="], - "gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.4.0-canary.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-/PRbn//EuR3UGiquk050gqvjxLliEgGBy1Cx9KkpAT7szaHOBj1mDDQmxMTEhRex4i3YfKGJXWn5mLMCveya6Q=="], + "gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.4.0-canary.26", "", { "os": "win32", "cpu": "arm64" }, "sha512-FlXIBNOSwnGxxN+HekUfz4Y0n4gPGzqcY3wa3p+5JhzFT7r0oCxMxOdRbs7w8jF5b6uSkWVIQXWFL43F6+8J4g=="], - "gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.0-canary.7", "", { "os": "win32", "cpu": "x64" }, "sha512-7a92XL+DlrbWyycCpQjjQMHOrsA0p+VvS7iA2dyi89Xsq0qtOPzFH0Gb56fsjh6M6BQGFhboOSzjmpjlkMTilQ=="], + "gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.0-canary.26", "", { "os": "win32", "cpu": "x64" }, "sha512-h9CKrDiEeBof+8IgHStYATYrKVuUt8ggy6429kViWlDbuY6gkuIplf3IRlfpdWAB32I1e4qqUVl/s2xRMgQdqg=="], "gitbook-v2/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], diff --git a/package.json b/package.json index 00f323e2f4..447997ab82 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "packageManager": "bun@1.2.8", "overrides": { "@codemirror/state": "6.4.1", - "@gitbook/api": "0.113.0", + "@gitbook/api": "^0.115.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/packages/cache-tags/package.json b/packages/cache-tags/package.json index 3d25aa8f48..8850dafbd6 100644 --- a/packages/cache-tags/package.json +++ b/packages/cache-tags/package.json @@ -10,7 +10,7 @@ }, "version": "0.3.1", "dependencies": { - "@gitbook/api": "^0.111.0", + "@gitbook/api": "^0.115.0", "assert-never": "^1.2.1" }, "devDependencies": { diff --git a/packages/gitbook-v2/package.json b/packages/gitbook-v2/package.json index 50cf96a0d7..f19b6951d8 100644 --- a/packages/gitbook-v2/package.json +++ b/packages/gitbook-v2/package.json @@ -3,17 +3,17 @@ "version": "0.2.5", "private": true, "dependencies": { + "@gitbook/api": "^0.115.0", + "@gitbook/cache-tags": "workspace:*", + "@sindresorhus/fnv1a": "^3.1.0", + "assert-never": "^1.2.1", + "jwt-decode": "^4.0.0", "next": "canary", "react": "^19.0.0", "react-dom": "^19.0.0", - "@gitbook/api": "*", - "@gitbook/cache-tags": "workspace:*", - "@sindresorhus/fnv1a": "^3.1.0", - "server-only": "^0.0.1", - "warn-once": "^0.1.1", "rison": "^0.1.1", - "jwt-decode": "^4.0.0", - "assert-never": "^1.2.1" + "server-only": "^0.0.1", + "warn-once": "^0.1.1" }, "devDependencies": { "gitbook": "*", diff --git a/packages/gitbook-v2/src/lib/data/lookup.ts b/packages/gitbook-v2/src/lib/data/lookup.ts index 498f6e8859..4c999bd7a4 100644 --- a/packages/gitbook-v2/src/lib/data/lookup.ts +++ b/packages/gitbook-v2/src/lib/data/lookup.ts @@ -1,55 +1,102 @@ import { race, tryCatch } from '@/lib/async'; import { joinPath, joinPathWithBaseURL } from '@/lib/paths'; import { trace } from '@/lib/tracing'; -import type { PublishedSiteContentLookup } from '@gitbook/api'; +import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api'; import { apiClient } from './api'; import { getExposableError } from './errors'; import type { DataFetcherResponse } from './types'; import { getURLLookupAlternatives, stripURLSearch } from './urls'; +interface LookupPublishedContentByUrlInput { + url: string; + redirectOnError: boolean; + apiToken: string | null; + visitorPayload: SiteVisitorPayload; +} + +/** + * Lookup a content by its URL using the GitBook resolvePublishedContentByUrl API endpoint. + * To optimize caching, we try multiple lookup alternatives and return the first one that matches. + */ +export async function resolvePublishedContentByUrl(input: LookupPublishedContentByUrlInput) { + return lookupPublishedContentByUrl({ + url: input.url, + fetchLookupAPIResult: ({ url, signal }) => { + const api = apiClient({ apiToken: input.apiToken }); + return trace( + { + operation: 'resolvePublishedContentByUrl', + name: url, + }, + () => + tryCatch( + api.urls.resolvePublishedContentByUrl( + { + url, + ...(input.visitorPayload ? { visitor: input.visitorPayload } : {}), + redirectOnError: input.redirectOnError, + }, + { signal } + ) + ) + ); + }, + }); +} + /** - * Lookup a content by its URL using the GitBook API. + * Lookup a content by its URL using the GitBook getPublishedContentByUrl API endpoint. * To optimize caching, we try multiple lookup alternatives and return the first one that matches. + * + * @deprecated use resolvePublishedContentByUrl. + * */ -export async function getPublishedContentByURL(input: { +export async function getPublishedContentByURL(input: LookupPublishedContentByUrlInput) { + return lookupPublishedContentByUrl({ + url: input.url, + fetchLookupAPIResult: ({ url, signal }) => { + const api = apiClient({ apiToken: input.apiToken }); + return trace( + { + operation: 'getPublishedContentByURL', + name: url, + }, + () => + tryCatch( + api.urls.getPublishedContentByUrl( + { + url, + visitorAuthToken: input.visitorPayload.jwtToken ?? undefined, + redirectOnError: input.redirectOnError, + // @ts-expect-error - cacheVersion is not a real query param + cacheVersion: 'v2', + }, + { signal } + ) + ) + ); + }, + }); +} + +type TryCatch = ReturnType>; + +async function lookupPublishedContentByUrl(input: { url: string; - visitorAuthToken: string | null; - redirectOnError: boolean; - apiToken: string | null; + fetchLookupAPIResult: (args: { + url: string; + signal: AbortSignal; + }) => TryCatch>>; }): Promise> { const lookupURL = new URL(input.url); const url = stripURLSearch(lookupURL); const lookup = getURLLookupAlternatives(url); const result = await race(lookup.urls, async (alternative, { signal }) => { - const api = await apiClient({ apiToken: input.apiToken }); - - const callResult = await trace( - { - operation: 'getPublishedContentByURL', - name: alternative.url, - }, - () => - tryCatch( - api.urls.getPublishedContentByUrl( - { - url: alternative.url, - visitorAuthToken: input.visitorAuthToken ?? undefined, - redirectOnError: input.redirectOnError, - - // As this endpoint is cached by our API, we version the request - // to void getting stale data with missing properties. - // this could be improved by ensuring our API cache layer is versioned - // or invalidated when needed - // @ts-expect-error - cacheVersion is not a real query param - cacheVersion: 'v2', - }, - { - signal, - } - ) - ) - ); + const callResult = await input.fetchLookupAPIResult({ + url: alternative.url, + signal, + }); if (callResult.error) { if (alternative.primary) { diff --git a/packages/gitbook-v2/src/middleware.ts b/packages/gitbook-v2/src/middleware.ts index dd983b536a..5cb34052f8 100644 --- a/packages/gitbook-v2/src/middleware.ts +++ b/packages/gitbook-v2/src/middleware.ts @@ -10,15 +10,16 @@ import { type ResponseCookies, getPathScopedCookieName, getResponseCookiesForVisitorAuth, - getVisitorToken, + getVisitorPayload, normalizeVisitorAuthURL, -} from '@/lib/visitor-token'; +} from '@/lib/visitors'; import { serveResizedImage } from '@/routes/image'; import { DataFetcherError, getPublishedContentByURL, getVisitorAuthBasePath, normalizeURL, + resolvePublishedContentByUrl, throwIfDataError, } from '@v2/lib/data'; import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env'; @@ -33,6 +34,15 @@ export const config = { type URLWithMode = { url: URL; mode: 'url' | 'url-host' }; +/** + * Temporary list of hosts to test adaptive content using the new resolution API. + */ +const ADAPTIVE_CONTENT_HOSTS = [ + 'docs.gitbook.com', + 'adaptive-docs.gitbook-staging.com', + 'enriched-content-playground.gitbook-staging.io', +]; + export async function middleware(request: NextRequest) { try { const requestURL = new URL(request.url); @@ -85,17 +95,22 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // // Detect and extract the visitor authentication token from the request // - // @ts-ignore - request typing - const visitorToken = getVisitorToken({ + const { visitorToken, unsignedClaims } = getVisitorPayload({ cookies: request.cookies.getAll(), url: siteRequestURL, }); const withAPIToken = async (apiToken: string | null) => { + const resolve = ADAPTIVE_CONTENT_HOSTS.includes(siteRequestURL.hostname) + ? resolvePublishedContentByUrl + : getPublishedContentByURL; const siteURLData = await throwIfDataError( - getPublishedContentByURL({ + resolve({ url: siteRequestURL.toString(), - visitorAuthToken: visitorToken?.token ?? null, + visitorPayload: { + jwtToken: visitorToken?.token ?? undefined, + unsignedClaims, + }, // When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow // redirecting when the token is invalid as we could be dealing with stale token stored in the cookie. // For example when the VA backend signature has changed but the token stored in the cookie is not yet expired. diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index d5e49b705f..83181c3c9a 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -12,7 +12,7 @@ import { VISITOR_TOKEN_COOKIE, getVisitorAuthCookieName, getVisitorAuthCookieValue, -} from '@/lib/visitor-token'; +} from '@/lib/visitors'; import { getSiteAPIToken } from '../tests/utils'; import { diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 5d0939b249..4294327546 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -16,7 +16,7 @@ "clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math" }, "dependencies": { - "@gitbook/api": "*", + "@gitbook/api": "^0.115.0", "@gitbook/cache-do": "workspace:*", "@gitbook/cache-tags": "workspace:*", "@gitbook/colors": "workspace:*", diff --git a/packages/gitbook/src/lib/visitor-token.test.ts b/packages/gitbook/src/lib/visitors.test.ts similarity index 61% rename from packages/gitbook/src/lib/visitor-token.test.ts rename to packages/gitbook/src/lib/visitors.test.ts index a1f07d2504..82060fcdfa 100644 --- a/packages/gitbook/src/lib/visitor-token.test.ts +++ b/packages/gitbook/src/lib/visitors.test.ts @@ -6,7 +6,8 @@ import { getVisitorAuthCookieName, getVisitorAuthCookieValue, getVisitorToken, -} from './visitor-token'; + getVisitorUnsignedClaims, +} from './visitors'; describe('getVisitorAuthToken', () => { it('should return the token from the query parameters', () => { @@ -158,3 +159,113 @@ function assertVisitorAuthCookieValue( throw new Error('Expected a VisitorAuthCookieValue'); } + +describe('getVisitorUnsignedClaims', () => { + it('should merge claims from multiple public cookies', () => { + const cookies = [ + { + name: 'gitbook-visitor-public-bucket', + value: JSON.stringify({ bucket: { flags: { SITE_AI: true, SITE_PREVIEW: true } } }), + }, + { + name: 'gitbook-visitor-public-launchdarkly', + value: JSON.stringify({ + launchdarkly: { flags: { ALPHA: true, API: true } }, + }), + }, + ]; + + const url = new URL('https://example.com/'); + const claims = getVisitorUnsignedClaims({ cookies, url }); + + expect(claims).toStrictEqual({ + bucket: { flags: { SITE_AI: true, SITE_PREVIEW: true } }, + launchdarkly: { flags: { ALPHA: true, API: true } }, + }); + }); + + it('should parse visitor.* query params with simple types', () => { + const url = new URL( + 'https://example.com/?visitor.isEnterprise=true&visitor.language=fr&visitor.country=fr' + ); + + const claims = getVisitorUnsignedClaims({ cookies: [], url }); + + expect(claims).toStrictEqual({ + isEnterprise: true, + language: 'fr', + country: 'fr', + }); + }); + + it('should ignore params that do not match visitor.* convention', () => { + const url = new URL('https://example.com/?visitor.isEnterprise=true&otherParam=true'); + + const claims = getVisitorUnsignedClaims({ cookies: [], url }); + + expect(claims).toStrictEqual({ + isEnterprise: true, + // otherParam is not present + }); + }); + + it('should support nested query param keys via dot notation', () => { + const url = new URL( + 'https://example.com/?visitor.isEnterprise=true&visitor.flags.ALPHA=true&visitor.flags.API=false' + ); + + const claims = getVisitorUnsignedClaims({ cookies: [], url }); + + expect(claims).toStrictEqual({ + isEnterprise: true, + flags: { + ALPHA: true, + API: false, + }, + }); + }); + + it('should ignore invalid JSON in cookie values', () => { + const cookies = [ + { + name: 'gitbook-visitor-public', + value: '{not: "json"}', + }, + ]; + const url = new URL('https://example.com/'); + const claims = getVisitorUnsignedClaims({ cookies, url }); + + expect(claims).toStrictEqual({}); + }); + + it('should merge claims from cookies and visitor.* query params', () => { + const cookies = [ + { + name: 'gitbook-visitor-public', + value: JSON.stringify({ role: 'admin', language: 'fr' }), + }, + { + name: 'gitbook-visitor-public-bucket', + value: JSON.stringify({ bucket: { flags: { SITE_AI: true, SITE_PREVIEW: true } } }), + }, + ]; + const url = new URL( + 'https://example.com/?visitor.isEnterprise=true&visitor.flags.ALPHA=true&visitor.flags.API=false' + ); + + const claims = getVisitorUnsignedClaims({ cookies, url }); + + expect(claims).toStrictEqual({ + role: 'admin', + language: 'fr', + bucket: { + flags: { SITE_AI: true, SITE_PREVIEW: true }, + }, + isEnterprise: true, + flags: { + ALPHA: true, + API: false, + }, + }); + }); +}); diff --git a/packages/gitbook/src/lib/visitor-token.ts b/packages/gitbook/src/lib/visitors.ts similarity index 70% rename from packages/gitbook/src/lib/visitor-token.ts rename to packages/gitbook/src/lib/visitors.ts index 64f0c97931..c6a7aae986 100644 --- a/packages/gitbook/src/lib/visitor-token.ts +++ b/packages/gitbook/src/lib/visitors.ts @@ -4,6 +4,7 @@ import hash from 'object-hash'; const VISITOR_AUTH_PARAM = 'jwt_token'; export const VISITOR_TOKEN_COOKIE = 'gitbook-visitor-token'; +const VISITOR_UNSIGNED_CLAIMS_PREFIX = 'gitbook-visitor-public'; /** * Typing for a cookie, matching the internal type of Next.js. @@ -30,6 +31,25 @@ type VisitorAuthCookieValue = { token: string; }; +type ClaimPrimitive = + | string + | number + | boolean + | null + | undefined + | { [key: string]: ClaimPrimitive } + | ClaimPrimitive[]; + +/** + * The result of a visitor info lookup that can include: + * - a visitor token (JWT) + * - a record of visitor public/unsigned claims (JSON object) + */ +export type VisitorPayloadLookup = { + visitorToken: VisitorTokenLookup; + unsignedClaims: Record; +}; + /** * The result of a visitor token lookup. */ @@ -53,6 +73,25 @@ export type VisitorTokenLookup = /** Not visitor token was found */ | undefined; +/** + * Get the visitor info for the request including its token and/or unsigned claims when present. + */ +export function getVisitorPayload({ + cookies, + url, +}: { + cookies: RequestCookies; + url: URL | NextRequest['nextUrl']; +}): VisitorPayloadLookup { + const visitorToken = getVisitorToken({ cookies, url }); + const unsignedClaims = getVisitorUnsignedClaims({ cookies, url }); + + return { + visitorToken, + unsignedClaims, + }; +} + /** * Get the visitor token for the request. This token can either be in the * query parameters or stored as a cookie. @@ -82,6 +121,106 @@ export function getVisitorToken({ } } +/** + * Get the visitor unsigned/public claims for the request. They can either be in `visitor.` query + * parameters or stored in special `gitbook-visitor-public-*` cookies. + */ +export function getVisitorUnsignedClaims(args: { + cookies: RequestCookies; + url: URL | NextRequest['nextUrl']; +}): Record { + const { cookies, url } = args; + const claims: Record = {}; + + for (const cookie of cookies) { + if (cookie.name.startsWith(VISITOR_UNSIGNED_CLAIMS_PREFIX)) { + try { + const parsed = JSON.parse(cookie.value); + if (typeof parsed === 'object' && parsed !== null) { + Object.assign(claims, parsed); + } + } catch (_err) { + console.warn(`Invalid JSON in unsigned claim cookie "${cookie.name}"`); + } + } + } + + for (const [key, value] of url.searchParams.entries()) { + if (key.startsWith('visitor.')) { + const claimPath = key.substring('visitor.'.length); + const claimValue = parseVisitorQueryParamValue(value); + setVisitorClaimByPath(claims, claimPath, claimValue); + } + } + + return claims; +} + +/** + * Set the value of claims in a claims object at a specific path. + */ +function setVisitorClaimByPath( + claims: Record, + keyPath: string, + value: ClaimPrimitive +): void { + const keys = keyPath.split('.'); + let current = claims; + + for (let index = 0; index < keys.length; index++) { + const key = keys[index]; + + if (index === keys.length - 1) { + current[key] = value; + } else { + if (!(key in current) || !isClaimPrimitiveObject(current[key])) { + current[key] = {}; + } + + current = current[key]; + } + } +} + +function isClaimPrimitiveObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Parse the value expected in a `visitor.` URL query parameter. + */ +function parseVisitorQueryParamValue(value: string): ClaimPrimitive { + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + if (value === 'null') { + return null; + } + + if (value === 'undefined') { + return undefined; + } + + const num = Number(value); + if (!Number.isNaN(num) && value.trim() !== '') { + return num; + } + + try { + const parsed = JSON.parse(value); + if (typeof parsed === 'object' && parsed !== null) { + return parsed; + } + } catch {} + + return value; +} + /** * Return the lookup result for content served with visitor auth. */ diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 30f7b44e6a..04b1b40dd9 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -24,9 +24,9 @@ import { type ResponseCookies, type VisitorTokenLookup, getResponseCookiesForVisitorAuth, - getVisitorToken, + getVisitorPayload, normalizeVisitorAuthURL, -} from '@/lib/visitor-token'; +} from '@/lib/visitors'; import { joinPath, withLeadingSlash } from '@/lib/paths'; import { getProxyModeBasePath } from '@/lib/proxy'; @@ -392,17 +392,17 @@ async function lookupSiteInProxy(request: NextRequest, url: URL): Promise { - const visitorAuthToken = getVisitorToken({ + const { visitorToken } = getVisitorPayload({ cookies: request.cookies.getAll(), url, }); - const lookup = await lookupSiteByAPI(url, visitorAuthToken); + const lookup = await lookupSiteByAPI(url, visitorToken); return { ...lookup, - ...('basePath' in lookup && visitorAuthToken - ? getLookupResultForVisitorAuth(lookup.basePath, visitorAuthToken) + ...('basePath' in lookup && visitorToken + ? getLookupResultForVisitorAuth(lookup.basePath, visitorToken) : {}), - visitorToken: visitorAuthToken?.token, + visitorToken: visitorToken?.token, }; } @@ -609,12 +609,12 @@ async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promis const target = new URL(targetStr); target.search = url.search; - const visitorAuthToken = getVisitorToken({ + const { visitorToken } = getVisitorPayload({ cookies: request.cookies.getAll(), url: target, }); - const lookup = await lookupSiteByAPI(target, visitorAuthToken); + const lookup = await lookupSiteByAPI(target, visitorToken); if ('error' in lookup) { return lookup; } @@ -641,10 +641,10 @@ async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promis ...lookup, siteBasePath: joinPath(target.host, lookup.siteBasePath), basePath: joinPath(target.host, lookup.basePath), - ...('basePath' in lookup && visitorAuthToken - ? getLookupResultForVisitorAuth(lookup.basePath, visitorAuthToken) + ...('basePath' in lookup && visitorToken + ? getLookupResultForVisitorAuth(lookup.basePath, visitorToken) : {}), - visitorToken: visitorAuthToken?.token, + visitorToken: visitorToken?.token, }; } diff --git a/packages/react-contentkit/package.json b/packages/react-contentkit/package.json index d2c8bd8ee7..09e6cc8e7c 100644 --- a/packages/react-contentkit/package.json +++ b/packages/react-contentkit/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "classnames": "^2.5.1", - "@gitbook/api": "*", + "@gitbook/api": "^0.115.0", "@gitbook/icons": "workspace:*" }, "peerDependencies": {