Add PPR token exchange functionality: implement endpoint for exchanging revalidation tokens for scoped content API tokens, enhancing caching and claims management across components.

This commit is contained in:
Nicolas Dorseuil
2026-09-01 18:17:39 +02:00
parent e05abafddb
commit a2170a352a
15 changed files with 428 additions and 79 deletions
+5
View File
@@ -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.
@@ -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
@@ -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
+15 -1
View File
@@ -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=<any value>
GITBOOK_EXCHANGE_TOKEN_URL=http://localhost:8788/token
PPR_DEV_API_TOKEN_SECRET=<local gitbook-x functionsConfig.api.tokenSecret>
```
```
http://localhost:3001/url/<published-gitbook-url>
+1
View File
@@ -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,
+73 -1
View File
@@ -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<string, { value: unknown; expiresAt: number }>();
const inflight = new Map<string, Promise<unknown>>();
@@ -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<string> {
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<string, unknown> {
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<string, unknown>, secret: string): Promise<string> {
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.
@@ -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 <PPRPageBody params={getPPRRouteParams(params)} pathname={pathname} />;
return <PPRPageBody params={await getPPRPageRouteParams(params)} pathname={pathname} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
const params = await props.params;
return cachedGenerateSitePageViewport(getPPRRouteParams(params));
return cachedGenerateSitePageViewport(await getPPRPageRouteParams(params));
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
return cachedGenerateSitePageMetadata(getPPRRouteParams(params));
return cachedGenerateSitePageMetadata(await getPPRPageRouteParams(params));
}
@@ -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<SitePPRLayoutProps>) {
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);
}
+72 -24
View File
@@ -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' });
});
});
+75 -29
View File
@@ -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<RouteParams>;
export function getPPRPageRouteParams(params: PPRRouteLayoutParams): Promise<RouteLayoutParams>;
export function getPPRPageRouteParams(params: PPRRouteLayoutParams): Promise<RouteLayoutParams> {
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<RouteLayoutParams> {
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, unknown>): 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<RouteLayoutParams> {
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<T extends RouteLayoutParams>(
params: T,
scope: PPRTokenScope
): Promise<T> {
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<VisitorAuthClaims> {
const { apiToken } = getSiteURLDataFromParams(params);
const fullToken = await exchangePPRToken(apiToken, 'full');
return getVisitorAuthClaimsFromToken(jwtDecode<SiteAPIToken>(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 {
+2
View File
@@ -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,
-13
View File
@@ -9,12 +9,6 @@ import type { SiteURLData } from '@/lib/context';
*/
export type VisitorAuthClaims = Record<string, any>;
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 ?? {};
}
+7
View File
@@ -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`
@@ -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' },
]);
});
});
+68
View File
@@ -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<PPRCacheScope, PPRTokenScope> = {
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<string> => {
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<Response> {
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
);
}
}