From a2170a352a5841225d365793f1aa6950d4fa5ea2 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Tue, 1 Sep 2026 18:17:39 +0200 Subject: [PATCH] Add PPR token exchange functionality: implement endpoint for exchanging revalidation tokens for scoped content API tokens, enhancing caching and claims management across components. --- .changeset/ppr-token-exchange.md | 5 + .../composite/deploy-cloudflare/action.yaml | 1 + .github/composite/deploy-vercel/action.yaml | 1 + AGENTS.md | 16 ++- packages/gitbook/next.config.mjs | 1 + packages/gitbook/scripts/ppr-dev-proxy.ts | 74 ++++++++++++- .../(content)/[pagePath]/page.tsx | 8 +- .../[pprDefaults]/(content)/layout.tsx | 25 +++-- packages/gitbook/src/app/utils.test.ts | 96 ++++++++++++---- packages/gitbook/src/app/utils.ts | 104 +++++++++++++----- .../gitbook/src/app/~gitbook/env/route.ts | 2 + packages/gitbook/src/lib/adaptive.ts | 13 --- packages/gitbook/src/lib/env/globals.ts | 7 ++ packages/gitbook/src/lib/ppr-token.test.ts | 86 +++++++++++++++ packages/gitbook/src/lib/ppr-token.ts | 68 ++++++++++++ 15 files changed, 428 insertions(+), 79 deletions(-) create mode 100644 .changeset/ppr-token-exchange.md create mode 100644 packages/gitbook/src/lib/ppr-token.test.ts create mode 100644 packages/gitbook/src/lib/ppr-token.ts diff --git a/.changeset/ppr-token-exchange.md b/.changeset/ppr-token-exchange.md new file mode 100644 index 000000000..8fe94b32d --- /dev/null +++ b/.changeset/ppr-token-exchange.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Exchange the PPR revalidation token for a content API token scoped to each PPR component, so the API receives claims it understands and the header and table of contents can be cached across pages. diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index 77bc5689d..46ce2c847 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -48,6 +48,7 @@ runs: GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ inputs.opItem }}/NEXT_SERVER_ACTIONS_ENCRYPTION_KEY GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET + GITBOOK_EXCHANGE_TOKEN_URL: ${{ inputs.opItem }}/GITBOOK_EXCHANGE_TOKEN_URL GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL diff --git a/.github/composite/deploy-vercel/action.yaml b/.github/composite/deploy-vercel/action.yaml index 4aa9fbbfe..17e24808f 100644 --- a/.github/composite/deploy-vercel/action.yaml +++ b/.github/composite/deploy-vercel/action.yaml @@ -50,6 +50,7 @@ runs: GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET + GITBOOK_EXCHANGE_TOKEN_URL: ${{ inputs.opItem }}/GITBOOK_EXCHANGE_TOKEN_URL GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL diff --git a/AGENTS.md b/AGENTS.md index e7a1122cc..6319921d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,21 @@ PPR requests are normally resolved upstream and arrive with a large set of `x-gb can't be reproduced by hitting the dev server directly. `bun run dev:ppr` (from `packages/gitbook`) starts a dev-only proxy on port 3001 that resolves the URL, injects those headers and signs them. The app rejects an unsigned set, so `GITBOOK_SECRET` must be set in `.env.local` (any value works -locally, as long as both processes read the same one): +locally, as long as both processes read the same one). + +The app also exchanges the PPR token for one scoped to each component, against +`GITBOOK_EXCHANGE_TOKEN_URL`. That endpoint only accepts a revalidation token, which the +published-URLs lookup never returns, so the proxy mints one with `PPR_DEV_API_TOKEN_SECRET` — a +local-only stand-in for the API token secret the cache worker holds in production. It must match the +secret the target `/token` endpoint verifies with, so local PPR needs a local gitbook-x sites stack. +The secret is read only by the proxy script: never add it to `src/lib/env`, `next.config.mjs` or a +deploy workflow. + +``` +GITBOOK_SECRET= +GITBOOK_EXCHANGE_TOKEN_URL=http://localhost:8788/token +PPR_DEV_API_TOKEN_SECRET= +``` ``` http://localhost:3001/url/ diff --git a/packages/gitbook/next.config.mjs b/packages/gitbook/next.config.mjs index e046e2c6e..f2ef694d4 100644 --- a/packages/gitbook/next.config.mjs +++ b/packages/gitbook/next.config.mjs @@ -88,6 +88,7 @@ const nextConfig = { GITBOOK_API_TOKEN: process.env.GITBOOK_API_TOKEN, GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX, GITBOOK_SECRET: process.env.GITBOOK_SECRET, + GITBOOK_EXCHANGE_TOKEN_URL: process.env.GITBOOK_EXCHANGE_TOKEN_URL, GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE, GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL, diff --git a/packages/gitbook/scripts/ppr-dev-proxy.ts b/packages/gitbook/scripts/ppr-dev-proxy.ts index 279e7886d..c4df3a824 100644 --- a/packages/gitbook/scripts/ppr-dev-proxy.ts +++ b/packages/gitbook/scripts/ppr-dev-proxy.ts @@ -41,8 +41,29 @@ function requireSecret(): string { return secret; } +// This proxy mints API tokens, so it must never be reachable outside a developer machine. +if (process.env.NODE_ENV === 'production') { + log('This proxy mints API tokens and must never run in production.'); + process.exit(1); +} + const SECRET = requireSecret(); +// The app always exchanges the PPR token, and the exchange endpoint only accepts a revalidation +// token, which the published-URLs lookup never returns: we have to mint one ourselves. +function requireAPITokenSecret(): string { + const secret = process.env.PPR_DEV_API_TOKEN_SECRET; + if (!secret) { + log( + 'PPR_DEV_API_TOKEN_SECRET is not set: the token exchange rejects the lookup token. Add it to .env.local.' + ); + process.exit(1); + } + return secret; +} + +const API_TOKEN_SECRET = requireAPITokenSecret(); + const cache = new Map(); const inflight = new Map>(); @@ -132,6 +153,57 @@ async function getDefaults(content: PublishedSiteContent) { } } +/** + * Turn the lookup's content token into the revalidation token the cache worker would send: the same + * payload, with the claims split into the per-scope buckets the exchange endpoint narrows down. + * Reusing the payload keeps `spaces`, `iat` and `exp` valid. + */ +async function mintRevalidationToken(apiToken: string): Promise { + const { claims: _claims, ...payload } = decodeJWTPayload(apiToken); + + const result = signJWT( + { + ...payload, + target: 'content', + // Empty buckets: local dev has no revalidation run to compute adaptive claims from. + siteClaims: {}, + revisionClaims: {}, + pageClaims: {}, + }, + API_TOKEN_SECRET + ); + console.log('minted revalidation token', await result); + return result; +} + +function decodeJWTPayload(token: string): Record { + const payload = token.split('.')[1]; + if (!payload) { + throw new Error('API token is not a JWT'); + } + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); +} + +async function signJWT(payload: Record, secret: string): Promise { + const signingInput = `${base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${base64url( + JSON.stringify(payload) + )}`; + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput)); + + return `${signingInput}.${base64url(Buffer.from(signature))}`; +} + +function base64url(value: string | Buffer): string { + return (typeof value === 'string' ? Buffer.from(value, 'utf8') : value).toString('base64url'); +} + async function setPPRHeaders( headers: Headers, content: PublishedSiteContent & { revision: string }, @@ -158,7 +230,7 @@ async function setPPRHeaders( ); headers.set(PPRRequestHeaders.Revision, content.revision); headers.set(PPRRequestHeaders.ChangeRequest, content.changeRequest ?? ''); - headers.set(PPRRequestHeaders.APIToken, content.apiToken); + headers.set(PPRRequestHeaders.APIToken, await mintRevalidationToken(content.apiToken)); headers.set(PPRRequestHeaders.RevalidationID, REVALIDATION_ID || content.revision); // Unlike the other optional headers this one is checked with `has()`, so it must be sent even // when the site has no sections. diff --git a/packages/gitbook/src/app/sites/ppr/[mode]/[siteURL]/[siteData]/[revisionId]/[revalidationId]/[pprDefaults]/(content)/[pagePath]/page.tsx b/packages/gitbook/src/app/sites/ppr/[mode]/[siteURL]/[siteData]/[revisionId]/[revalidationId]/[pprDefaults]/(content)/[pagePath]/page.tsx index 0a44050ad..fda831a9d 100644 --- a/packages/gitbook/src/app/sites/ppr/[mode]/[siteURL]/[siteData]/[revisionId]/[revalidationId]/[pprDefaults]/(content)/[pagePath]/page.tsx +++ b/packages/gitbook/src/app/sites/ppr/[mode]/[siteURL]/[siteData]/[revisionId]/[revalidationId]/[pprDefaults]/(content)/[pagePath]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata, Viewport } from 'next'; -import { type PPRRouteParams, getPPRRouteParams, getPagePathFromParams } from '@/app/utils'; +import { type PPRRouteParams, getPPRPageRouteParams, getPagePathFromParams } from '@/app/utils'; import { PPRPageBody, cachedGenerateSitePageMetadata, @@ -17,15 +17,15 @@ export default async function Page(props: PageProps) { const params = await props.params; const pathname = getPagePathFromParams(params); - return ; + return ; } export async function generateViewport(props: PageProps): Promise { const params = await props.params; - return cachedGenerateSitePageViewport(getPPRRouteParams(params)); + return cachedGenerateSitePageViewport(await getPPRPageRouteParams(params)); } export async function generateMetadata(props: PageProps): Promise { const params = await props.params; - return cachedGenerateSitePageMetadata(getPPRRouteParams(params)); + return cachedGenerateSitePageMetadata(await getPPRPageRouteParams(params)); } diff --git a/packages/gitbook/src/app/sites/ppr/[mode]/[siteURL]/[siteData]/[revisionId]/[revalidationId]/[pprDefaults]/(content)/layout.tsx b/packages/gitbook/src/app/sites/ppr/[mode]/[siteURL]/[siteData]/[revisionId]/[revalidationId]/[pprDefaults]/(content)/layout.tsx index 241b12118..42dde40c5 100644 --- a/packages/gitbook/src/app/sites/ppr/[mode]/[siteURL]/[siteData]/[revisionId]/[revalidationId]/[pprDefaults]/(content)/layout.tsx +++ b/packages/gitbook/src/app/sites/ppr/[mode]/[siteURL]/[siteData]/[revisionId]/[revalidationId]/[pprDefaults]/(content)/layout.tsx @@ -3,9 +3,10 @@ import type React from 'react'; import { type PPRRouteLayoutParams, getPPRHeaderRouteParams, - getPPRRouteParams, + getPPRPageRouteParams, getPPRStaticSiteContext, getPPRTableOfContentsRouteParams, + getPPRVisitorAuthClaims, } from '@/app/utils'; import { CustomizationRootLayout } from '@/components/RootLayout'; import { @@ -25,12 +26,16 @@ export default async function SitePPRLayout({ children, }: React.PropsWithChildren) { const routeParams = await params; - const pageParams = getPPRRouteParams(routeParams); - const headerParams = getPPRHeaderRouteParams(routeParams); - const tableOfContentsParams = getPPRTableOfContentsRouteParams(routeParams); + const [pageParams, headerParams, tableOfContentsParams, visitorAuthClaims] = await Promise.all([ + getPPRPageRouteParams(routeParams), + getPPRHeaderRouteParams(routeParams), + getPPRTableOfContentsRouteParams(routeParams), + // Each component holds a token narrowed to one scope, so the client claims need their union. + getPPRVisitorAuthClaims(routeParams), + ]); // The layout resolves context from the same page params as PPRPageBody, so it shares its // cache scope and its data entries rather than adding a fourth set of fetches. - const { context, visitorAuthClaims } = await getPPRStaticSiteContext(pageParams, 'body'); + const { context } = await getPPRStaticSiteContext(pageParams, 'body'); const withTracking = shouldTrackEvents(); return ( @@ -56,11 +61,17 @@ export default async function SitePPRLayout({ } export async function generateViewport({ params }: SitePPRLayoutProps) { - const { context } = await getPPRStaticSiteContext(getPPRRouteParams(await params), 'body'); + const { context } = await getPPRStaticSiteContext( + await getPPRPageRouteParams(await params), + 'body' + ); return generateSiteLayoutViewport(context); } export async function generateMetadata({ params }: SitePPRLayoutProps) { - const { context } = await getPPRStaticSiteContext(getPPRRouteParams(await params), 'body'); + const { context } = await getPPRStaticSiteContext( + await getPPRPageRouteParams(await params), + 'body' + ); return generateSiteLayoutMetadata(context); } diff --git a/packages/gitbook/src/app/utils.test.ts b/packages/gitbook/src/app/utils.test.ts index 89b988b30..26ba2f0dc 100644 --- a/packages/gitbook/src/app/utils.test.ts +++ b/packages/gitbook/src/app/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, mock } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'; import jwt from 'jsonwebtoken'; import rison from 'rison'; @@ -12,12 +12,35 @@ mock.module('@/lib/context', () => ({ getBaseContext: (input: unknown) => input, fetchSiteContextByURLLookup: async (_baseContext: unknown, data: unknown) => data, })); +// Stand in for the exchange endpoint, which is the only thing that can narrow the claims. It is +// stubbed at the network boundary rather than with `mock.module`, which would replace +// `@/lib/ppr-token` for the entire test process and break its own test file. +const realFetch = globalThis.fetch; + +beforeAll(() => { + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const { scope } = JSON.parse(String(init?.body)); + // Deterministic per scope, so the assertions below can compare tokens across pages. + return Response.json({ + token: jwt.sign( + { exp: Math.floor(Date.now() / 1000) + 3600, claims: { scope } }, + 'secret' + ), + }); + }) as typeof fetch; +}); + +afterAll(() => { + globalThis.fetch = realFetch; +}); const { getPPRHeaderRouteParams, + getPPRPageRouteParams, getPPRRouteParams, getPPRStaticSiteContext, getPPRTableOfContentsRouteParams, + getPPRVisitorAuthClaims, getSiteURLDataFromParams, } = await import('./utils'); type PPRRouteParams = import('./utils').PPRRouteParams; @@ -25,7 +48,9 @@ type PPRRouteParams = import('./utils').PPRRouteParams; const apiToken = jwt.sign( { exp: Math.floor(Date.now() / 1000) + 3600, - siteStructureClaims: { scope: 'site-structure' }, + siteClaims: { audience: 'external' }, + revisionClaims: { account: { tier: 'pro' } }, + pageClaims: { unsigned: { locale: 'fr' } }, }, 'secret' ); @@ -85,7 +110,7 @@ describe('PPR cache region params', () => { ...routeParams, siteData: encodeURIComponent( rison.encode({ - apiToken: jwt.sign({ siteStructureClaims: {} }, 'other-secret'), + apiToken: jwt.sign({ siteClaims: {} }, 'other-secret'), site: 'site-id', siteSection: 'new-page-site-section-id', siteSpace: 'new-page-site-space-id', @@ -98,14 +123,13 @@ describe('PPR cache region params', () => { ), }; - it('keeps header params stable apart from the API token', () => { - const headerData = getSiteURLDataFromParams(getPPRHeaderRouteParams(routeParams)); + it('keeps header params stable, API token included', async () => { + const headerData = getSiteURLDataFromParams(await getPPRHeaderRouteParams(routeParams)); const changedHeaderData = getSiteURLDataFromParams( - getPPRHeaderRouteParams(changedRouteParams) + await getPPRHeaderRouteParams(changedRouteParams) ); expect(headerData).toMatchObject({ - apiToken, siteSection: 'default-site-section-id', siteSpace: 'default-site-space-id', space: 'default-space-id', @@ -113,15 +137,23 @@ describe('PPR cache region params', () => { // path of the variant the visitor is on. basePath: '/docs/', }); - expect({ ...headerData, apiToken: undefined }).toEqual({ - ...changedHeaderData, - apiToken: undefined, - }); + // The whole point of the exchange: the site-scoped token no longer varies per page, so the + // header can be cached once for the site instead of once per page. + expect(headerData).toEqual(changedHeaderData); }); - it('keeps the visited base path when the defaults point at the visited variant', () => { + it('narrows the header token to the site scope', async () => { + const { apiToken: headerToken } = getSiteURLDataFromParams( + await getPPRHeaderRouteParams(routeParams) + ); + + expect(headerToken).not.toBe(apiToken); + expect(jwt.decode(headerToken)).toMatchObject({ claims: { scope: 'site' } }); + }); + + it('keeps the visited base path when the defaults point at the visited variant', async () => { const headerData = getSiteURLDataFromParams( - getPPRHeaderRouteParams({ + await getPPRHeaderRouteParams({ ...routeParams, pprDefaults: encodeURIComponent( rison.encode({ @@ -139,48 +171,64 @@ describe('PPR cache region params', () => { }); }); - it('keeps TOC params stable apart from its current location and API token', () => { - const tocData = getSiteURLDataFromParams(getPPRTableOfContentsRouteParams(routeParams)); + it('keeps TOC params stable apart from its current location', async () => { + const tocData = getSiteURLDataFromParams( + await getPPRTableOfContentsRouteParams(routeParams) + ); const changedTOCData = getSiteURLDataFromParams( - getPPRTableOfContentsRouteParams(changedRouteParams) + await getPPRTableOfContentsRouteParams(changedRouteParams) ); expect(tocData).toMatchObject({ - apiToken, siteSection: 'page-site-section-id', siteSpace: 'page-site-space-id', space: 'space-id', basePath: '/docs/v/page-variant/', }); + // The revision-scoped token drops the page claims, so it is shared by every page of the + // space; only the location data still varies. + expect(tocData.apiToken).toBe(changedTOCData.apiToken); expect({ ...tocData, - apiToken: undefined, siteSection: undefined, siteSpace: undefined, space: undefined, basePath: undefined, }).toEqual({ ...changedTOCData, - apiToken: undefined, siteSection: undefined, siteSpace: undefined, space: undefined, basePath: undefined, }); }); + + it('narrows the TOC and page tokens to their own scopes', async () => { + const tocData = getSiteURLDataFromParams( + await getPPRTableOfContentsRouteParams(routeParams) + ); + const pageData = getSiteURLDataFromParams(await getPPRPageRouteParams(routeParams)); + + expect(jwt.decode(tocData.apiToken)).toMatchObject({ claims: { scope: 'revision' } }); + expect(jwt.decode(pageData.apiToken)).toMatchObject({ claims: { scope: 'page' } }); + expect(tocData.apiToken).not.toBe(pageData.apiToken); + }); +}); + +describe('getPPRVisitorAuthClaims', () => { + it('resolves every scope at once through a full exchange', async () => { + // The scoped tokens each carry one bucket, so the client claims can only come from `full`. + expect(await getPPRVisitorAuthClaims(routeParams)).toEqual({ scope: 'full' }); + }); }); describe('getPPRStaticSiteContext', () => { it('uses the supplied API token without resolving published content again', async () => { - const { context, visitorAuthClaims } = await getPPRStaticSiteContext( - getPPRRouteParams(routeParams), - 'body' - ); + const { context } = await getPPRStaticSiteContext(getPPRRouteParams(routeParams), 'body'); expect(context).toMatchObject({ apiToken, revision: 'ppr-revision-id', }); - expect(visitorAuthClaims).toEqual({ scope: 'site-structure' }); }); }); diff --git a/packages/gitbook/src/app/utils.ts b/packages/gitbook/src/app/utils.ts index 1b0279fef..ba39412c7 100644 --- a/packages/gitbook/src/app/utils.ts +++ b/packages/gitbook/src/app/utils.ts @@ -5,13 +5,14 @@ import rison from 'rison'; import type { SiteAPIToken } from '@gitbook/api'; import { - getPPRVisitorAuthClaimsFromToken, + type VisitorAuthClaims, getVisitorAuthClaims, getVisitorAuthClaimsFromToken, } from '@/lib/adaptive'; import type { PPRCacheScope } from '@/lib/cache-tags'; import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@/lib/context'; import { getDynamicCustomizationSettings } from '@/lib/customization'; +import { PPR_TOKEN_SCOPE, type PPRTokenScope, exchangePPRToken } from '@/lib/ppr-token'; export type RouteParamMode = 'url-host' | 'url'; @@ -44,7 +45,6 @@ export type PPRRouteParams = PPRRouteLayoutParams & { */ export async function getStaticSiteContext( params: RouteLayoutParams, - getClaims = getVisitorAuthClaimsFromToken, options?: { pprScope?: PPRCacheScope } ) { const siteURL = getSiteURLFromParams(params); @@ -69,7 +69,7 @@ export async function getStaticSiteContext( return { context, - visitorAuthClaims: getClaims(decoded), + visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded), }; } @@ -174,32 +174,45 @@ export function getPPRRouteParams(params: PPRRouteLayoutParams): RouteLayoutPara } /** - * Project PPR params for the shared header by replacing page-varying location data. - * TODO: We'll need to exchange the api token provided by the original PPR request for one that the API will understand + * Project PPR params for the current page, with a token scoped to the page claims. */ -export function getPPRHeaderRouteParams(params: PPRRouteLayoutParams): RouteLayoutParams { +export function getPPRPageRouteParams(params: PPRRouteParams): Promise; +export function getPPRPageRouteParams(params: PPRRouteLayoutParams): Promise; +export function getPPRPageRouteParams(params: PPRRouteLayoutParams): Promise { + return withExchangedPPRToken(getPPRRouteParams(params), PPR_TOKEN_SCOPE.body); +} + +/** + * Project PPR params for the shared header by replacing page-varying location data. + */ +export async function getPPRHeaderRouteParams( + params: PPRRouteLayoutParams +): Promise { const routeParams = getPPRRouteParams(params); const { revision, ...siteURLData } = getSiteURLDataFromParams(routeParams); const defaults = getPPRDefaults(params); - return { - ...routeParams, - siteData: encodeSiteData({ - ...siteURLData, - // For the header, we keep site section and space data from the PPR defaults, so that the header can be cached across all pages in a site. - siteSection: defaults.siteSection ?? undefined, - siteSpace: defaults.siteSpace, - space: defaults.space, - // The base path has to describe the same variant as the ids above, or the header - // prefixes one variant's page paths with another variant's base path. The site default - // variant is published at the site root; defaults pointing at the visited variant keep - // its own base path. - basePath: - defaults.siteSpace === siteURLData.siteSpace - ? siteURLData.basePath - : siteURLData.siteBasePath, - }), - }; + return withExchangedPPRToken( + { + ...routeParams, + siteData: encodeSiteData({ + ...siteURLData, + // For the header, we keep site section and space data from the PPR defaults, so that the header can be cached across all pages in a site. + siteSection: defaults.siteSection ?? undefined, + siteSpace: defaults.siteSpace, + space: defaults.space, + // The base path has to describe the same variant as the ids above, or the header + // prefixes one variant's page paths with another variant's base path. The site default + // variant is published at the site root; defaults pointing at the visited variant keep + // its own base path. + basePath: + defaults.siteSpace === siteURLData.siteSpace + ? siteURLData.basePath + : siteURLData.siteBasePath, + }), + }, + PPR_TOKEN_SCOPE.header + ); } /** rison can't encode undefined values, so they are dropped like the middleware does. */ @@ -216,11 +229,44 @@ function encodeSiteData(siteURLData: Record): string { /** * Project PPR params for the table of contents, keeping its current location data. * The table of contents depends only on the space you're in and the claims of that revision, not on the page, - * and the layout params carry no page path, so the PPR params can be used as-is. - * TODO: We'll need to exchange the api token provided by the original PPR request for one that the API will understand + * and the layout params carry no page path, so only the token has to be narrowed. */ -export function getPPRTableOfContentsRouteParams(params: PPRRouteLayoutParams): RouteLayoutParams { - return getPPRRouteParams(params); +export function getPPRTableOfContentsRouteParams( + params: PPRRouteLayoutParams +): Promise { + return withExchangedPPRToken(getPPRRouteParams(params), PPR_TOKEN_SCOPE.toc); +} + +/** + * Replace the revalidation token carried by the PPR params with a content API token narrowed to + * `scope`. Components sharing a scope then share a token, and with it a cache entry. + */ +async function withExchangedPPRToken( + params: T, + scope: PPRTokenScope +): Promise { + const siteURLData = getSiteURLDataFromParams(params); + + return { + ...params, + siteData: encodeSiteData({ + ...siteURLData, + apiToken: await exchangePPRToken(siteURLData.apiToken, scope), + }), + }; +} + +/** + * Get the claims the client should resolve adaptive content with. Each component holds a token + * narrowed to a single scope, so the union has to come from a `full` exchange. + */ +export async function getPPRVisitorAuthClaims( + params: PPRRouteLayoutParams +): Promise { + const { apiToken } = getSiteURLDataFromParams(params); + const fullToken = await exchangePPRToken(apiToken, 'full'); + + return getVisitorAuthClaimsFromToken(jwtDecode(fullToken)); } /** @@ -228,7 +274,7 @@ export function getPPRTableOfContentsRouteParams(params: PPRRouteLayoutParams): * the tags they emit, so the component and its data are revalidated as one unit. */ export async function getPPRStaticSiteContext(params: RouteLayoutParams, pprScope: PPRCacheScope) { - return getStaticSiteContext(params, getPPRVisitorAuthClaimsFromToken, { pprScope }); + return getStaticSiteContext(params, { pprScope }); } function getPPRRouteParam(encodedParam: string, name: string): string { diff --git a/packages/gitbook/src/app/~gitbook/env/route.ts b/packages/gitbook/src/app/~gitbook/env/route.ts index 15a08167c..dd9754ea7 100644 --- a/packages/gitbook/src/app/~gitbook/env/route.ts +++ b/packages/gitbook/src/app/~gitbook/env/route.ts @@ -7,6 +7,7 @@ import { GITBOOK_APP_URL, GITBOOK_ASSETS_URL, GITBOOK_DISABLE_TRACKING, + GITBOOK_EXCHANGE_TOKEN_URL, GITBOOK_FONTS_URL, GITBOOK_ICONS_URL, GITBOOK_IMAGE_RESIZE_SIGNING_KEY, @@ -28,6 +29,7 @@ export async function GET(_req: NextRequest) { GITBOOK_API_URL, GITBOOK_API_PUBLIC_URL, GITBOOK_OAUTH_SERVER_URL, + GITBOOK_EXCHANGE_TOKEN_URL, GITBOOK_ASSETS_URL, GITBOOK_FONTS_URL, GITBOOK_ICONS_URL, diff --git a/packages/gitbook/src/lib/adaptive.ts b/packages/gitbook/src/lib/adaptive.ts index 119bd7239..5152d58a7 100644 --- a/packages/gitbook/src/lib/adaptive.ts +++ b/packages/gitbook/src/lib/adaptive.ts @@ -9,12 +9,6 @@ import type { SiteURLData } from '@/lib/context'; */ export type VisitorAuthClaims = Record; -type PPRSiteAPIToken = SiteAPIToken & { - siteStructureClaims?: VisitorAuthClaims; - revisionClaims?: VisitorAuthClaims; - pageClaims?: VisitorAuthClaims; -}; - /** * Get the visitor auth claims from the API response obtained from `resolvePublishedContentByUrl`. */ @@ -30,10 +24,3 @@ export function getVisitorAuthClaims(siteData: SiteURLData): VisitorAuthClaims { export function getVisitorAuthClaimsFromToken(token: SiteAPIToken): VisitorAuthClaims { return token.claims ?? {}; } - -/** - * PPR shares its client contexts with the site structure, so it exposes the structure claims. - */ -export function getPPRVisitorAuthClaimsFromToken(token: PPRSiteAPIToken): VisitorAuthClaims { - return token.siteStructureClaims ?? {}; -} diff --git a/packages/gitbook/src/lib/env/globals.ts b/packages/gitbook/src/lib/env/globals.ts index b7d97778b..cb19c6ddd 100644 --- a/packages/gitbook/src/lib/env/globals.ts +++ b/packages/gitbook/src/lib/env/globals.ts @@ -132,6 +132,13 @@ export const GITBOOK_ICONS_TOKEN = process.env.GITBOOK_ICONS_TOKEN; */ export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null; +/** + * Endpoint exchanging a PPR revalidation token for a content API token scoped to a single claims + * bucket. Signing one requires the API token secret, which GitBook Open does not have. + */ +export const GITBOOK_EXCHANGE_TOKEN_URL = + process.env.GITBOOK_EXCHANGE_TOKEN_URL || 'https://sites.gitbook.com/token'; + /** * Shared secret used to sign server-to-server requests to the sites OAuth server consent endpoints. * This must match the sites OAuth provider signing secret (`functionsConfig.sitesOAuth.signingSecret` diff --git a/packages/gitbook/src/lib/ppr-token.test.ts b/packages/gitbook/src/lib/ppr-token.test.ts new file mode 100644 index 000000000..4875f2602 --- /dev/null +++ b/packages/gitbook/src/lib/ppr-token.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test'; + +import { PPR_TOKEN_SCOPE, exchangePPRToken } from './ppr-token'; +import { DataFetcherError } from '@/lib/data/errors'; +import { GITBOOK_EXCHANGE_TOKEN_URL } from '@/lib/env'; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function mockFetch(handler: (input: RequestInfo | URL, init?: RequestInit) => Response) { + const calls: { url: string; body: unknown }[] = []; + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), body: JSON.parse(String(init?.body)) }); + return handler(input, init); + }) as unknown as typeof fetch; + return calls; +} + +describe('PPR_TOKEN_SCOPE', () => { + it('maps every PPR cache scope to the claims bucket it resolves', () => { + expect(PPR_TOKEN_SCOPE).toEqual({ header: 'site', toc: 'revision', body: 'page' }); + }); +}); + +describe('exchangePPRToken', () => { + it('posts the token and scope, and returns the exchanged token', async () => { + for (const scope of ['site', 'revision', 'page', 'full'] as const) { + const calls = mockFetch(() => Response.json({ token: `exchanged-${scope}` })); + + expect(await exchangePPRToken(`revalidation-token-${scope}`, scope)).toBe( + `exchanged-${scope}` + ); + expect(calls).toEqual([ + { + url: GITBOOK_EXCHANGE_TOKEN_URL, + body: { token: `revalidation-token-${scope}`, scope }, + }, + ]); + } + }); + + it('fails with a 502 when the endpoint rejects the token', async () => { + mockFetch(() => Response.json({ error: 'Invalid or expired token' }, { status: 401 })); + + const error = await exchangePPRToken('rejected-token', 'site').catch((e) => e); + expect(error).toBeInstanceOf(DataFetcherError); + expect((error as DataFetcherError).code).toBe(502); + }); + + it('fails with a 502 when the endpoint returns no token', async () => { + mockFetch(() => Response.json({})); + + const error = await exchangePPRToken('tokenless-response', 'revision').catch((e) => e); + expect(error).toBeInstanceOf(DataFetcherError); + expect((error as DataFetcherError).code).toBe(502); + }); + + it('fails with a 502 when the endpoint is unreachable', async () => { + globalThis.fetch = mock(async () => { + throw new TypeError('fetch failed'); + }) as unknown as typeof fetch; + + const error = await exchangePPRToken('unreachable', 'page').catch((e) => e); + expect(error).toBeInstanceOf(DataFetcherError); + expect((error as DataFetcherError).code).toBe(502); + }); + + // Request-level memoization is `React.cache`, which is inert outside a render scope and so + // cannot be exercised here. What is asserted instead: each scope is a distinct exchange. + it('exchanges each scope separately', async () => { + const calls = mockFetch(() => Response.json({ token: 'exchanged' })); + + await Promise.all([ + exchangePPRToken('shared-token', 'site'), + exchangePPRToken('shared-token', 'revision'), + ]); + + expect(calls.map((call) => call.body)).toEqual([ + { token: 'shared-token', scope: 'site' }, + { token: 'shared-token', scope: 'revision' }, + ]); + }); +}); diff --git a/packages/gitbook/src/lib/ppr-token.ts b/packages/gitbook/src/lib/ppr-token.ts new file mode 100644 index 000000000..854ad134c --- /dev/null +++ b/packages/gitbook/src/lib/ppr-token.ts @@ -0,0 +1,68 @@ +import 'server-only'; +import { cache } from '@/lib/cache'; +import type { PPRCacheScope } from '@/lib/cache-tags'; +import { DataFetcherError } from '@/lib/data/errors'; +import { GITBOOK_EXCHANGE_TOKEN_URL } from '@/lib/env'; +import { trace } from '@/lib/tracing'; + +/** + * Scope of the claims to keep in the exchanged token. `full` merges every scope into one object, + * for callers that resolve them all at once and cannot present a different token per scope. + */ +export type PPRTokenScope = 'site' | 'revision' | 'page' | 'full'; + +/** Each PPR cache scope resolves exactly one claims bucket, so it maps to one exchange scope. */ +export const PPR_TOKEN_SCOPE: Record = { + header: 'site', + toc: 'revision', + body: 'page', +}; + +/** + * Exchange the revalidation token carried by a PPR request for a content API token whose claims are + * narrowed to `scope`. The API only understands the latter, and narrowing is what lets components + * sharing a scope share a cache entry: the token is part of their cache key. + * + * Memoized per request, but never persisted — an exchanged token is a credential. + */ +export const exchangePPRToken = cache( + async (token: string, scope: PPRTokenScope): Promise => { + return trace(`exchangePPRToken(${scope})`, async () => { + const response = await fetchExchangedToken(token, scope); + + if (!response.ok) { + throw new DataFetcherError( + `Token exchange for scope "${scope}" responded with ${response.status}`, + 502 + ); + } + + const { token: exchanged } = (await response.json()) as { token?: unknown }; + if (typeof exchanged !== 'string' || !exchanged) { + throw new DataFetcherError( + `Token exchange for scope "${scope}" returned no token`, + 502 + ); + } + + return exchanged; + }); + } +); + +async function fetchExchangedToken(token: string, scope: PPRTokenScope): Promise { + try { + return await fetch(GITBOOK_EXCHANGE_TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token, scope }), + cache: 'no-store', + }); + } catch (error) { + // Surface a transport failure the same way as a rejection, so callers only handle one type. + throw new DataFetcherError( + `Token exchange for scope "${scope}" failed: ${error instanceof Error ? error.message : String(error)}`, + 502 + ); + } +}