diff --git a/packages/gitbook-v2/src/middleware.ts b/packages/gitbook-v2/src/middleware.ts index 5cb34052f..916d53d61 100644 --- a/packages/gitbook-v2/src/middleware.ts +++ b/packages/gitbook-v2/src/middleware.ts @@ -10,7 +10,7 @@ import { type ResponseCookies, getPathScopedCookieName, getResponseCookiesForVisitorAuth, - getVisitorPayload, + getVisitorData, normalizeVisitorAuthURL, } from '@/lib/visitors'; import { serveResizedImage } from '@/routes/image'; @@ -95,7 +95,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // // Detect and extract the visitor authentication token from the request // - const { visitorToken, unsignedClaims } = getVisitorPayload({ + const { visitorToken, unsignedClaims, visitorParamsCookie } = getVisitorData({ cookies: request.cookies.getAll(), url: siteRequestURL, }); @@ -121,7 +121,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { apiToken, }) ); - const cookies: ResponseCookies = []; + + const cookies: ResponseCookies = visitorParamsCookie + ? [ + // If visitor.* params were passed to the site URL, include a session cookie to persist these params across navigation. + visitorParamsCookie, + ] + : []; // // Handle redirects diff --git a/packages/gitbook/src/lib/visitors.test.ts b/packages/gitbook/src/lib/visitors.test.ts index 82060fcdf..4337371fa 100644 --- a/packages/gitbook/src/lib/visitors.test.ts +++ b/packages/gitbook/src/lib/visitors.test.ts @@ -178,7 +178,7 @@ describe('getVisitorUnsignedClaims', () => { const url = new URL('https://example.com/'); const claims = getVisitorUnsignedClaims({ cookies, url }); - expect(claims).toStrictEqual({ + expect(claims.all).toStrictEqual({ bucket: { flags: { SITE_AI: true, SITE_PREVIEW: true } }, launchdarkly: { flags: { ALPHA: true, API: true } }, }); @@ -191,7 +191,12 @@ describe('getVisitorUnsignedClaims', () => { const claims = getVisitorUnsignedClaims({ cookies: [], url }); - expect(claims).toStrictEqual({ + expect(claims.all).toStrictEqual({ + isEnterprise: true, + language: 'fr', + country: 'fr', + }); + expect(claims.fromVisitorParams).toStrictEqual({ isEnterprise: true, language: 'fr', country: 'fr', @@ -203,10 +208,13 @@ describe('getVisitorUnsignedClaims', () => { const claims = getVisitorUnsignedClaims({ cookies: [], url }); - expect(claims).toStrictEqual({ + expect(claims.all).toStrictEqual({ isEnterprise: true, // otherParam is not present }); + expect(claims.fromVisitorParams).toStrictEqual({ + isEnterprise: true, + }); }); it('should support nested query param keys via dot notation', () => { @@ -216,7 +224,14 @@ describe('getVisitorUnsignedClaims', () => { const claims = getVisitorUnsignedClaims({ cookies: [], url }); - expect(claims).toStrictEqual({ + expect(claims.all).toStrictEqual({ + isEnterprise: true, + flags: { + ALPHA: true, + API: false, + }, + }); + expect(claims.fromVisitorParams).toStrictEqual({ isEnterprise: true, flags: { ALPHA: true, @@ -235,7 +250,7 @@ describe('getVisitorUnsignedClaims', () => { const url = new URL('https://example.com/'); const claims = getVisitorUnsignedClaims({ cookies, url }); - expect(claims).toStrictEqual({}); + expect(claims.all).toStrictEqual({}); }); it('should merge claims from cookies and visitor.* query params', () => { @@ -250,16 +265,27 @@ describe('getVisitorUnsignedClaims', () => { }, ]; const url = new URL( - 'https://example.com/?visitor.isEnterprise=true&visitor.flags.ALPHA=true&visitor.flags.API=false' + 'https://example.com/?visitor.isEnterprise=true&visitor.flags.ALPHA=true&visitor.flags.API=false&visitor.bucket.flags.HELLO=false' ); const claims = getVisitorUnsignedClaims({ cookies, url }); - expect(claims).toStrictEqual({ + expect(claims.all).toStrictEqual({ role: 'admin', language: 'fr', bucket: { - flags: { SITE_AI: true, SITE_PREVIEW: true }, + flags: { HELLO: false, SITE_AI: true, SITE_PREVIEW: true }, + }, + isEnterprise: true, + flags: { + ALPHA: true, + API: false, + }, + }); + + expect(claims.fromVisitorParams).toStrictEqual({ + bucket: { + flags: { HELLO: false }, }, isEnterprise: true, flags: { diff --git a/packages/gitbook/src/lib/visitors.ts b/packages/gitbook/src/lib/visitors.ts index c6a7aae98..7ae970666 100644 --- a/packages/gitbook/src/lib/visitors.ts +++ b/packages/gitbook/src/lib/visitors.ts @@ -41,13 +41,15 @@ type ClaimPrimitive = | ClaimPrimitive[]; /** - * The result of a visitor info lookup that can include: + * The result of a visitor data lookup that can include: * - a visitor token (JWT) * - a record of visitor public/unsigned claims (JSON object) + * - a session cookie response to persist any visitor query params across navigations. */ -export type VisitorPayloadLookup = { +export type VisitorDataLookup = { visitorToken: VisitorTokenLookup; unsignedClaims: Record; + visitorParamsCookie: ResponseCookie | undefined; }; /** @@ -74,21 +76,26 @@ export type VisitorTokenLookup = | undefined; /** - * Get the visitor info for the request including its token and/or unsigned claims when present. + * Get the visitor data for the request potentially including: + * - a JWT token that may contain signed claims or can be used for VA authentication. + * - a record of the unsigned claims passed via a cookie or visitor.* params. + * - a session cookie response that is used to persist any visitor.* params that were passed via the site URL. */ -export function getVisitorPayload({ +export function getVisitorData({ cookies, url, }: { cookies: RequestCookies; url: URL | NextRequest['nextUrl']; -}): VisitorPayloadLookup { +}): VisitorDataLookup { const visitorToken = getVisitorToken({ cookies, url }); const unsignedClaims = getVisitorUnsignedClaims({ cookies, url }); + const visitorParamsCookie = getResponseCookieForVisitorParams(unsignedClaims.fromVisitorParams); return { visitorToken, - unsignedClaims, + unsignedClaims: unsignedClaims.all, + visitorParamsCookie, }; } @@ -128,9 +135,19 @@ export function getVisitorToken({ export function getVisitorUnsignedClaims(args: { cookies: RequestCookies; url: URL | NextRequest['nextUrl']; -}): Record { +}): { + /** + * The unsigned claims coming from both `gitbook-visitor-public` cookies and `visitor.*` query params. + */ + all: Record; + /** + * The unsigned claims from the `visitor.*` query params. + */ + fromVisitorParams: Record; +} { const { cookies, url } = args; const claims: Record = {}; + const searchParamsClaims: Record = {}; for (const cookie of cookies) { if (cookie.name.startsWith(VISITOR_UNSIGNED_CLAIMS_PREFIX)) { @@ -149,11 +166,13 @@ export function getVisitorUnsignedClaims(args: { if (key.startsWith('visitor.')) { const claimPath = key.substring('visitor.'.length); const claimValue = parseVisitorQueryParamValue(value); + setVisitorClaimByPath(claims, claimPath, claimValue); + setVisitorClaimByPath(searchParamsClaims, claimPath, claimValue); } } - return claims; + return { all: claims, fromVisitorParams: searchParamsClaims }; } /** @@ -221,6 +240,26 @@ function parseVisitorQueryParamValue(value: string): ClaimPrimitive { return value; } +/** + * Returns to cookie response to use in order to persist visitor params that were passed to the URL. + */ +function getResponseCookieForVisitorParams( + visitorParamsClaims: Record +): ResponseCookie | undefined { + if (Object.keys(visitorParamsClaims).length === 0) { + return undefined; + } + + return { + name: VISITOR_UNSIGNED_CLAIMS_PREFIX, + value: JSON.stringify(visitorParamsClaims), + options: { + sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined, + secure: process.env.NODE_ENV === 'production', + }, + }; +} + /** * 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 04b1b40dd..3e5f682cc 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -24,7 +24,7 @@ import { type ResponseCookies, type VisitorTokenLookup, getResponseCookiesForVisitorAuth, - getVisitorPayload, + getVisitorData, normalizeVisitorAuthURL, } from '@/lib/visitors'; @@ -392,7 +392,7 @@ async function lookupSiteInProxy(request: NextRequest, url: URL): Promise { - const { visitorToken } = getVisitorPayload({ + const { visitorToken } = getVisitorData({ cookies: request.cookies.getAll(), url, }); @@ -609,7 +609,7 @@ async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promis const target = new URL(targetStr); target.search = url.search; - const { visitorToken } = getVisitorPayload({ + const { visitorToken } = getVisitorData({ cookies: request.cookies.getAll(), url: target, });