mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 18:43:29 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4
-4
@@ -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));
|
||||
}
|
||||
|
||||
+18
-7
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user