From 876e66c261491f1aa4f7a55c3c64738426ba6bba Mon Sep 17 00:00:00 2001 From: Mehnoor Siddiqui <57288492+mehnoorsiddiqui@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:02:12 +0500 Subject: [PATCH 1/5] feat(portal): one OAuth redirect URI for every endpoint page (#424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API playground's OAuth sign-in (authorization code and implicit flows) now redirects to `/oauth/callback` from every endpoint page, so portal owners register one redirect URI with their provider (Slack, for example) instead of every endpoint page's URL. Refs apimatic/apimatic-io#2255. fumadocs-openapi 12.1 added `oauthRedirectUrl` for this ([fuma-nama/fumadocs#3611](https://github.com/fuma-nama/fumadocs/issues/3611)), with a `createOAuthHandler()` route that needs a server. Published portals are static files, so a prerendered page does the same in the browser: it sends the provider's answer back to the page that started the sign-in, or stays to show the provider's error. It refuses a starting page on another site, and forgets the starting page once read. When it can't tell where the sign-in started, it links to the portal's home page. The CLI now refuses a content page at `/oauth/callback`, which the template's route would hide, and its reserved-address error now says such a page would be served "where the portal puts pages of its own". Not done here: - Sign-in has been tried against a mock provider, not a real one yet. - The callback URL and its home-page link ignore a base path for now; both need one once portals can be hosted under a path (apimatic-io#2275). - Portal-owner docs for the single redirect URI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Muhammad Rafay Nadeem --- portal-template/prerender-pages.ts | 3 + portal-template/src/components/api-page.tsx | 4 +- .../src/components/authorization-failure.tsx | 63 ++++++++++ portal-template/src/lib/oauth-callback.ts | 63 ++++++++++ portal-template/src/routes/oauth.callback.tsx | 54 +++++++++ src/prompts/portal/source.ts | 11 +- src/types/portal/content-tree.ts | 33 +++++- src/types/portal/portal-source.ts | 13 +- test/e2e/portal-build.test.ts | 7 ++ .../authorization-failure.test.ts | 112 ++++++++++++++++++ test/portal-template/oauth-callback.test.ts | 101 ++++++++++++++++ test/portal-template/prerender-pages.test.ts | 9 ++ test/prompts/portal/source.test.ts | 28 ++++- test/types/portal-source-context.test.ts | 71 +++++++---- 14 files changed, 527 insertions(+), 45 deletions(-) create mode 100644 portal-template/src/components/authorization-failure.tsx create mode 100644 portal-template/src/lib/oauth-callback.ts create mode 100644 portal-template/src/routes/oauth.callback.tsx create mode 100644 test/portal-template/authorization-failure.test.ts create mode 100644 test/portal-template/oauth-callback.test.ts diff --git a/portal-template/prerender-pages.ts b/portal-template/prerender-pages.ts index 395a4b3ff..f5a5b9f13 100644 --- a/portal-template/prerender-pages.ts +++ b/portal-template/prerender-pages.ts @@ -2,6 +2,7 @@ import { readdir } from 'node:fs/promises'; import path from 'node:path'; import { getSlugs, loader } from 'fumadocs-core/source'; import type { BuildPaths } from './portal-config.ts'; +import { oauthCallbackPath } from './src/lib/oauth-callback'; import { openApiSection } from './src/lib/openapi-section.server'; const CONTENT_EXTENSIONS = new Set(['.md', '.mdx']); @@ -35,6 +36,8 @@ export async function prerenderPages(config: BuildPaths, siteUrl: string | null) if (url === '/') urls.add('/index.md'); else if (!/\.(txt|xml|json)$/.test(url)) urls.add(`${url}.md`); } + // After the twins, since it is no page of the portal's and has none. + urls.add(oauthCallbackPath); return [...urls].map((url) => ({ path: url })); } diff --git a/portal-template/src/components/api-page.tsx b/portal-template/src/components/api-page.tsx index 0d9881936..6f061246e 100644 --- a/portal-template/src/components/api-page.tsx +++ b/portal-template/src/components/api-page.tsx @@ -1,5 +1,6 @@ import { createOpenAPIPage } from 'fumadocs-openapi/ui'; import { mediaAdapters } from '@/lib/media-adapters'; +import { oauthCallbackPath } from '@/lib/oauth-callback'; import { renderExampleLayout } from './example-layout'; import { codeUsages, renderUsageTabs } from './usage-tabs'; @@ -7,5 +8,6 @@ export const OpenAPIPage = createOpenAPIPage({ codeUsages, generateTypeScriptDefinitions: false, content: { renderAPIExampleLayout: renderExampleLayout, renderAPIExampleUsageTabs: renderUsageTabs }, - mediaAdapters + mediaAdapters, + oauthRedirectUrl: oauthCallbackPath }); diff --git a/portal-template/src/components/authorization-failure.tsx b/portal-template/src/components/authorization-failure.tsx new file mode 100644 index 000000000..328867f15 --- /dev/null +++ b/portal-template/src/components/authorization-failure.tsx @@ -0,0 +1,63 @@ +import { buttonVariants } from 'fumadocs-ui/components/ui/button'; +import type { ReactNode } from 'react'; +import type { OAuthCallbackFailure } from '@/lib/oauth-callback'; + +/** What the OAuth callback says when it stays, instead of sending the browser back to the endpoint. */ +export function AuthorizationFailure({ failure }: { failure: OAuthCallbackFailure }) { + switch (failure.kind) { + case 'providerError': + // Ended, not refused: `server_error` and `temporarily_unavailable` arrive here too. + return ( + <> + + The provider ended the authorization with {failure.error} + {failure.description === null ? '.' : `: ${failure.description}`} + + Back to the endpoint + + ); + case 'notStarted': + return ( + <> + + This page completes an authorization started from an endpoint's playground, and none was started in this + browser. Open the endpoint's page and authorize from there. + + + + ); + case 'unknownPage': + return ( + <> + + The authorization was not started from a page of this site, so it cannot be completed here. Open the + endpoint's page and authorize from there. + + + + ); + } +} + +function Explanation({ children }: { children: ReactNode }) { + return ( + <> +

Authorization could not be completed

+

{children}

+ + ); +} + +// The page that started the authorization is unknown here, so the home page is the one way back. +// It ignores a base path, as the callback's own address does (apimatic-io#2275). +function HomeAction() { + return Go to the home page; +} + +function Action({ href, children }: { href: string; children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/portal-template/src/lib/oauth-callback.ts b/portal-template/src/lib/oauth-callback.ts new file mode 100644 index 000000000..a592192bb --- /dev/null +++ b/portal-template/src/lib/oauth-callback.ts @@ -0,0 +1,63 @@ +/** + * The one redirect URI a portal owner registers with their OAuth provider, instead of the + * address of every endpoint page that can start an authorization. The CLI keeps the user's + * pages off it, in `src/types/portal/content-tree.ts`. + */ +export const oauthCallbackPath = '/oauth/callback'; + +/** The cookie the playground writes before it leaves, naming the page that started the flow. */ +const PAGE_COOKIE_NAME = 'fumadocs-openapi-oauth'; +const PAGE_COOKIE = new RegExp(`(?:^|;\\s*)${PAGE_COOKIE_NAME}=([^;]+)`); + +/** What the callback does with the browser that lands on it. */ +export type OAuthCallback = + /** Sends it back to the page that started the flow, with the provider's answer still attached. */ + | { kind: 'return'; url: string } + /** Stays, to show the error the provider ended the authorization with, and a link back to that page. */ + | { kind: 'providerError'; error: string; description: string | null; page: string } + /** Stays, since the cookie names no page: no authorization was started in this browser. */ + | { kind: 'notStarted' } + /** Stays, since the cookie names a page on another site, or one that cannot be read. */ + | { kind: 'unknownPage' }; + +/** Every outcome but sending the browser on, which leaves the callback before it renders again. */ +export type OAuthCallbackFailure = Exclude; + +/** + * The provider answers in the query for the authorization code flow and in the fragment for the + * implicit one, an error included. Only an authorization this browser started is answered with + * the provider's error: anyone can link here with an `error_description` of their own. + */ +export function oauthCallback(cookie: string, location: Pick): OAuthCallback { + const value = PAGE_COOKIE.exec(cookie)?.[1]; + if (value === undefined) return { kind: 'notStarted' }; + + let page: URL; + try { + page = new URL(decodeURIComponent(value), location.origin); + } catch { + return { kind: 'unknownPage' }; + } + // Anything else is an open redirect, carrying the authorization code to another site. + if (page.origin !== location.origin) return { kind: 'unknownPage' }; + + const query = new URLSearchParams(location.search); + const answer = query.has('error') ? query : new URLSearchParams(location.hash.slice(1)); + const error = answer.get('error'); + if (error !== null) { + return { kind: 'providerError', error, description: answer.get('error_description'), page: page.href }; + } + + // Whole, not as a path: a path of `//evil.test/x` would be read as another site's address. + page.search = location.search; + page.hash = location.hash; + return { kind: 'return', url: page.href }; +} + +/** + * The playground leaves the cookie for the session, so a later visit would be sent back to the + * last page that started an authorization. Forgotten once read, it names only the flow just started. + */ +export function forgetStartingPage(): void { + document.cookie = `${PAGE_COOKIE_NAME}=; path=/; max-age=0`; +} diff --git a/portal-template/src/routes/oauth.callback.tsx b/portal-template/src/routes/oauth.callback.tsx new file mode 100644 index 000000000..4ced996a4 --- /dev/null +++ b/portal-template/src/routes/oauth.callback.tsx @@ -0,0 +1,54 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { HomeLayout } from 'fumadocs-ui/layouts/home'; +import { useEffect, useState } from 'react'; +import { AuthorizationFailure } from '@/components/authorization-failure'; +import { baseOptions } from '@/lib/layout.shared'; +import { forgetStartingPage, oauthCallback, type OAuthCallback, type OAuthCallbackFailure } from '@/lib/oauth-callback'; +import { portal } from '@/lib/portal'; + +// Published portals are static, so this page does in the browser what Fumadocs' +// `createOAuthHandler()` route does on a server. +export const Route = createFileRoute('/oauth/callback')({ + head: () => ({ meta: [{ title: `Authorization | ${portal.name}` }, { name: 'robots', content: 'noindex' }] }), + component: OAuthCallbackPage +}); + +let completion: OAuthCallback | undefined; + +/** + * Reading the cookie and sending the browser on can each happen only once per page load, not + * once per mount: the StrictMode `portal serve` runs under mounts the page twice, and the second + * mount would find the cookie gone. The answer is kept, so a later mount in the same load, such + * as Back after leaving through the header, shows the same outcome. Every arrival from a provider + * is a new load. + */ +function completeAuthorization(): OAuthCallback { + if (completion === undefined) { + completion = oauthCallback(document.cookie, window.location); + forgetStartingPage(); + if (completion.kind === 'return') window.location.replace(completion.url); + } + return completion; +} + +function OAuthCallbackPage() { + // Null until the effect has read the cookie, which the prerendered page cannot. + const [failure, setFailure] = useState(null); + + useEffect(() => { + const callback = completeAuthorization(); + if (callback.kind !== 'return') setFailure(callback); + }, []); + + return ( + +
+ {failure === null ? ( +

Completing authorization…

+ ) : ( + + )} +
+
+ ); +} diff --git a/src/prompts/portal/source.ts b/src/prompts/portal/source.ts index 35f68edee..2e2242538 100644 --- a/src/prompts/portal/source.ts +++ b/src/prompts/portal/source.ts @@ -225,17 +225,16 @@ function reportMissingImages(images: MissingImage[], sourceDirectory: DirectoryP function reportReservedAddresses(pages: ReservedAddressPage[], sourceDirectory: DirectoryPath): void { const one = pages.length === 1; - const lines = pages.map(({ file, address, section }) => { - const kept = `/${section.folder}`; - const within = address === kept ? '' : `, under ${f.var(kept)}`; + const lines = pages.map(({ file, address, keptFor }) => { + const within = address === keptFor.address ? '' : `, under ${f.var(keptFor.address)}`; return ` • ${relative(file, sourceDirectory)}, at ${f.var(address)}${within}, which is kept for ${ - section.description + keptFor.description }`; }); log.error( one - ? `A page in ${f.path(sourceDirectory)} would be served where the portal puts the pages it generates:` - : `Pages in ${f.path(sourceDirectory)} would be served where the portal puts the pages it generates:` + ? `A page in ${f.path(sourceDirectory)} would be served where the portal puts pages of its own:` + : `Pages in ${f.path(sourceDirectory)} would be served where the portal puts pages of its own:` ); log.message(lines.join('\n')); log.message(one ? 'Rename or move the page.' : 'Rename or move each page.'); diff --git a/src/types/portal/content-tree.ts b/src/types/portal/content-tree.ts index 419bc1bcc..0b545b549 100644 --- a/src/types/portal/content-tree.ts +++ b/src/types/portal/content-tree.ts @@ -18,11 +18,30 @@ import { PortalNavigation, TabEntry } from './portal-navigation.js'; -import { ContentProblem, MissingImage, PortalSpec, ReservedAddressPage, SharedAddress } from './portal-source.js'; +import { + ContentProblem, + MissingImage, + PortalSpec, + ReservedAddress, + ReservedAddressPage, + SharedAddress +} from './portal-source.js'; import { PortalTab, sharedTabNames, TabOwner, untitledTabName } from './portal-tabs.js'; const NAVIGATION_FILE = new FileName(NAVIGATION_FILE_NAME); +/** + * The page the API playground's OAuth authorization returns through, which the template serves + * from a route of its own (`portal-template/src/lib/oauth-callback.ts`). That route wins over a + * content page at the address, so such a page would build and never be shown. + */ +export const OAUTH_CALLBACK_ADDRESS = '/oauth/callback'; + +export const OAUTH_CALLBACK: ReservedAddress = { + address: OAUTH_CALLBACK_ADDRESS, + description: "the API playground's OAuth authorization" +}; + /** Extensions the docs collection compiles, and so the ones an entry can address. */ const PAGE_EXTENSIONS = ['.md', '.mdx']; @@ -302,15 +321,19 @@ export class ContentTree { /** * Pages served at a generated section's address or below it, whether or not `apimatic.json` - * calls for the section. Judged by the address, as the content source computes it, rather - * than by the directories as written: a page in a `(group)` folder is served as if the folder - * were not there, and a folder's index page at the folder's own address. + * calls for the section, and at the OAuth callback's. Judged by the address, as the content + * source computes it, rather than by the directories as written: a page in a `(group)` folder + * is served as if the folder were not there, and a folder's index page at the folder's own address. */ private static reservedAddressPages(pages: ContentPage[]): ReservedAddressPage[] { return pages.flatMap(({ file, segments }) => { const slugs = getSlugs(segments.join('/')); + const address = `/${slugs.join('/')}`; + if (address === OAUTH_CALLBACK.address) return [{ file, address, keptFor: OAUTH_CALLBACK }]; const section = GENERATED_SECTIONS.find((candidate) => candidate.folder === slugs[0]); - return section === undefined ? [] : [{ file, address: `/${slugs.join('/')}`, section }]; + return section === undefined + ? [] + : [{ file, address, keptFor: { address: `/${section.folder}`, description: section.description } }]; }); } diff --git a/src/types/portal/portal-source.ts b/src/types/portal/portal-source.ts index 7929e5cc8..94813dd27 100644 --- a/src/types/portal/portal-source.ts +++ b/src/types/portal/portal-source.ts @@ -4,7 +4,7 @@ import { FilePath } from '../file/filePath.js'; import { Endpoint } from './endpoint.js'; import { SuggestedSite } from './config/site-config.js'; import { ContentNotices } from './content-notices.js'; -import { GeneratedPages, GeneratedSection } from './generated-pages.js'; +import { GeneratedPages } from './generated-pages.js'; import { PortalConfig } from './portal-config.js'; /** An OpenAPI document found in `src/spec/`, with the slug its section is mounted at. */ @@ -57,12 +57,19 @@ export interface MissingStaticFile extends MissingFile { setting: string; } -/** A page of the user's served at an address the CLI keeps for the pages it generates. */ +/** An address the portal serves a page of its own at: a generated section's, and every one below it, or a single page's. */ +export interface ReservedAddress { + address: string; + /** What a message says the address is kept for. */ + description: string; +} + +/** A page of the user's served at an address the portal keeps for a page of its own. */ export interface ReservedAddressPage { file: FilePath; /** Where the page would be served, which a `(group)` folder makes differ from its path. */ address: string; - section: GeneratedSection; + keptFor: ReservedAddress; } /** Pages of the user's that would be served at one address, which only one of them can have. */ diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index 4a41cda1b..7c505d679 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -228,6 +228,13 @@ const stylesheetOf = (output: DirectoryPath) => { expect(read('index.md')).to.contain('Hello from the fixture.'); }); + it('writes the one OAuth redirect URI every endpoint page authorizes through, kept out of search', () => { + expect(read('oauth/callback/index.html')).to.contain(' { expect(exists('api/search.json')).to.be.true; expect(read('llms.txt')).to.contain('Welcome'); diff --git a/test/portal-template/authorization-failure.test.ts b/test/portal-template/authorization-failure.test.ts new file mode 100644 index 000000000..6ae8703d0 --- /dev/null +++ b/test/portal-template/authorization-failure.test.ts @@ -0,0 +1,112 @@ +import { expect } from 'chai'; +import { createServer, type Plugin, type ViteDevServer } from 'vite'; +import type { OAuthCallbackFailure } from '../../portal-template/src/lib/oauth-callback'; + +const ENTRY = 'virtual:authorization-failure-fixture'; + +const fixture = ` +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { AuthorizationFailure } from '/portal-template/src/components/authorization-failure.tsx'; + +export function render(failure) { + return renderToStaticMarkup(createElement(AuthorizationFailure, { failure })); +} +`; + +function authorizationFailureFixture(): Plugin { + return { + name: 'authorization-failure-fixture', + enforce: 'pre', + resolveId: (id) => (id === ENTRY ? `\0${ENTRY}` : null), + load: (id) => (id === `\0${ENTRY}` ? fixture : null) + }; +} + +/** What the reader is told, as text, and where its one link goes. */ +interface Shown { + text: string; + link: { label: string; href: string }; +} + +describe('the OAuth callback when it cannot complete the authorization', function () { + this.timeout(60 * 1000); + + let server: ViteDevServer; + let render: (failure: OAuthCallbackFailure) => string; + + before(async () => { + server = await createServer({ + configFile: false, + root: process.cwd(), + logLevel: 'silent', + appType: 'custom', + server: { middlewareMode: true, hmr: false, ws: false, watch: null }, + optimizeDeps: { noDiscovery: true, include: [] }, + plugins: [authorizationFailureFixture()] + }); + ({ render } = (await server.ssrLoadModule(ENTRY)) as { render: typeof render }); + }); + + after(() => server.close()); + + const shown = (failure: OAuthCallbackFailure): Shown => { + const markup = render(failure); + const link = /]*href="([^"]*)"[^>]*>(.*?)<\/a>/s.exec(markup); + expect(link, 'the page offers a way on').to.not.be.null; + return { text: readable(markup), link: { label: readable(link![2]), href: link![1] } }; + }; + + it("names the provider's error and its description, with a link back to the starting page", () => { + const page = 'https://docs.test/api/pets/pets/listPets'; + + expect( + shown({ kind: 'providerError', error: 'access_denied', description: 'The user said no.', page }) + ).to.deep.equal({ + text: 'Authorization could not be completed The provider ended the authorization with access_denied: The user said no. Back to the endpoint', + link: { label: 'Back to the endpoint', href: page } + }); + }); + + // `server_error` is no refusal, so the wording fits every code the provider can send. + it("names the provider's error alone when the provider gives no description", () => { + const { text } = shown({ + kind: 'providerError', + error: 'server_error', + description: null, + page: 'https://docs.test/' + }); + + expect(text).to.contain('The provider ended the authorization with server_error.'); + }); + + it('says no authorization was started in this browser, and leads to the home page', () => { + expect(shown({ kind: 'notStarted' })).to.deep.equal({ + text: + "Authorization could not be completed This page completes an authorization started from an endpoint's playground, and none " + + "was started in this browser. Open the endpoint's page and authorize from there. Go to the home page", + link: { label: 'Go to the home page', href: '/' } + }); + }); + + it('says the authorization was not started from a page of this site, and leads to the home page', () => { + expect(shown({ kind: 'unknownPage' })).to.deep.equal({ + text: + 'Authorization could not be completed The authorization was not started from a page of this site, so it cannot be ' + + "completed here. Open the endpoint's page and authorize from there. Go to the home page", + link: { label: 'Go to the home page', href: '/' } + }); + }); +}); + +/** Markup as the reader sees it: tags dropped, each block apart, entities spelled out. */ +function readable(markup: string): string { + return markup + .replace(/<\/(h1|p)>/g, ' ') + .replace(/<[^>]+>/g, '') + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/test/portal-template/oauth-callback.test.ts b/test/portal-template/oauth-callback.test.ts new file mode 100644 index 000000000..35ff8ff12 --- /dev/null +++ b/test/portal-template/oauth-callback.test.ts @@ -0,0 +1,101 @@ +import { expect } from 'chai'; +import { oauthCallback, oauthCallbackPath } from '../../portal-template/src/lib/oauth-callback'; +import { OAUTH_CALLBACK_ADDRESS } from '../../src/types/portal/content-tree'; + +// The CLI refuses a page of the user's at the address the template serves the callback at. +describe('oauthCallbackPath', () => { + it('is the address the CLI keeps the content pages off', () => { + expect(oauthCallbackPath).to.equal(OAUTH_CALLBACK_ADDRESS); + }); +}); + +describe('oauthCallback', () => { + const origin = 'https://docs.test'; + const pageCookie = (path: string) => `theme=dark; fumadocs-openapi-oauth=${encodeURIComponent(path)}`; + const answered = (search: string, hash = '') => ({ origin, search, hash }); + + it("sends the authorization code back to the page that started the flow, in the provider's query", () => { + const callback = oauthCallback(pageCookie('/api/pets/pets/listPets'), answered('?code=abc&state=123')); + + expect(callback).to.deep.equal({ + kind: 'return', + url: 'https://docs.test/api/pets/pets/listPets?code=abc&state=123' + }); + }); + + // The implicit flow answers in the fragment, which a browser never sends to a server either. + it("sends the implicit flow's token back in the provider's fragment", () => { + const callback = oauthCallback(pageCookie('/api/pets/pets/listPets'), answered('', '#access_token=xyz&state=123')); + + expect(callback).to.deep.equal({ + kind: 'return', + url: 'https://docs.test/api/pets/pets/listPets#access_token=xyz&state=123' + }); + }); + + it('keeps an encoded page address as the page wrote it', () => { + const callback = oauthCallback(pageCookie('/api/pets/Get%20Pets'), answered('?code=abc')); + + expect(callback).to.deep.equal({ kind: 'return', url: 'https://docs.test/api/pets/Get%20Pets?code=abc' }); + }); + + // As a bare path, `//evil.test/x?code=abc` would send the code to evil.test. + it('keeps the code on this site for a page whose path reads as another site', () => { + const callback = oauthCallback( + pageCookie('https://docs.test//evil.test/x'), + answered('?code=abc', '#access_token=xyz') + ); + + expect(callback).to.deep.equal({ kind: 'return', url: 'https://docs.test//evil.test/x?code=abc#access_token=xyz' }); + }); + + // The playground's own return handling finds no code or token in it, and says nothing. + it("stays to show the provider's error, in the query, with a way back to the page", () => { + const callback = oauthCallback( + pageCookie('/api/pets/listPets'), + answered('?error=access_denied&error_description=The+user+said+no&state=123') + ); + + expect(callback).to.deep.equal({ + kind: 'providerError', + error: 'access_denied', + description: 'The user said no', + page: 'https://docs.test/api/pets/listPets' + }); + }); + + it("stays to show the implicit flow's error, in the fragment, without a description it was not given", () => { + const callback = oauthCallback(pageCookie('/api/pets/listPets'), answered('', '#error=access_denied&state=123')); + + expect(callback).to.deep.equal({ + kind: 'providerError', + error: 'access_denied', + description: null, + page: 'https://docs.test/api/pets/listPets' + }); + }); + + it('says no authorization was started without the cookie', () => { + expect(oauthCallback('theme=dark', answered('?code=abc'))).to.deep.equal({ kind: 'notStarted' }); + expect(oauthCallback('', answered('?code=abc'))).to.deep.equal({ kind: 'notStarted' }); + }); + + // Anyone can link here, so an error and its description are only shown for an authorization this browser started. + it("shows no provider's error for an authorization this browser did not start", () => { + const callback = oauthCallback('', answered('?error=access_denied&error_description=Call+this+number')); + + expect(callback).to.deep.equal({ kind: 'notStarted' }); + }); + + it('refuses to send the code to another site', () => { + for (const page of ['https://evil.test/steal', '//evil.test/steal']) { + expect(oauthCallback(pageCookie(page), answered('?code=abc')), page).to.deep.equal({ kind: 'unknownPage' }); + } + }); + + it('refuses a cookie that is not encoded the way the playground writes it', () => { + const callback = oauthCallback('fumadocs-openapi-oauth=%E0%A4%A', answered('?code=abc')); + + expect(callback).to.deep.equal({ kind: 'unknownPage' }); + }); +}); diff --git a/test/portal-template/prerender-pages.test.ts b/test/portal-template/prerender-pages.test.ts index f8b3ab40d..cc9eb26ef 100644 --- a/test/portal-template/prerender-pages.test.ts +++ b/test/portal-template/prerender-pages.test.ts @@ -106,6 +106,15 @@ describe('prerenderPages', () => { expect(await urlsFor('https://docs.test')).to.include('/robots.txt'); }); + it('lists the OAuth callback, without a Markdown twin', async () => { + write('index.md'); + + const urls = await urlsFor(); + + expect(urls).to.include('/oauth/callback'); + expect(urls).to.not.include('/oauth/callback.md'); + }); + it('does not ask for a Markdown twin of the generated files', async () => { write('index.md'); diff --git a/test/prompts/portal/source.test.ts b/test/prompts/portal/source.test.ts index 5ce4cbbd3..7692ab855 100644 --- a/test/prompts/portal/source.test.ts +++ b/test/prompts/portal/source.test.ts @@ -15,6 +15,7 @@ import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { FileName } from '../../../src/types/file/fileName.js'; import { FilePath } from '../../../src/types/file/filePath.js'; import { PLUGIN_SECTION, SDK_SECTION } from '../../../src/types/portal/generated-pages.js'; +import { OAUTH_CALLBACK } from '../../../src/types/portal/content-tree.js'; describe('reportSourceProblem', () => { const source = new DirectoryPath('project').join('src'); @@ -191,7 +192,7 @@ describe('reportSourceProblem', () => { }); }); - describe('a page at an address kept for the generated pages', () => { + describe("a page at an address kept for the portal's own pages", () => { const content = source.join('content'); it('names the page, where it would be served, and what the address is kept for', () => { @@ -201,14 +202,14 @@ describe('reportSourceProblem', () => { { file: new FilePath(content.join('(intro)'), new FileName('sdks.md')), address: '/sdks', - section: SDK_SECTION + keptFor: { address: '/sdks', description: SDK_SECTION.description } } ] }); const [heading, ...rest] = printed().split('\n'); - expect(heading).to.match(/^A page in .+ would be served where the portal puts the pages it generates:$/); + expect(heading).to.match(/^A page in .+ would be served where the portal puts pages of its own:$/); expect(rest).to.deep.equal([ " • 'content/(intro)/sdks.md', at '/sdks', which is kept for the SDK pages", 'Rename or move the page.' @@ -222,12 +223,12 @@ describe('reportSourceProblem', () => { { file: new FilePath(content.join('sdks'), new FileName('setup.md')), address: '/sdks/setup', - section: SDK_SECTION + keptFor: { address: '/sdks', description: SDK_SECTION.description } }, { file: new FilePath(content, new FileName('context-plugin.mdx')), address: '/context-plugin', - section: PLUGIN_SECTION + keptFor: { address: '/context-plugin', description: PLUGIN_SECTION.description } } ] }); @@ -241,6 +242,23 @@ describe('reportSourceProblem', () => { ); expect(printed()).to.contain('Rename or move each page.'); }); + + it("says a page at /oauth/callback is kept for the API playground's OAuth authorization", () => { + reportContent({ + kind: 'reservedAddresses', + pages: [ + { + file: new FilePath(content.join('oauth'), new FileName('callback.md')), + address: '/oauth/callback', + keptFor: OAUTH_CALLBACK + } + ] + }); + + expect(printed()).to.contain( + " • 'content/oauth/callback.md', at '/oauth/callback', which is kept for the API playground's OAuth authorization" + ); + }); }); it('lists each page whose front matter the build would refuse, and shows front matter that works', () => { diff --git a/test/types/portal-source-context.test.ts b/test/types/portal-source-context.test.ts index f30c1c288..cb08adfaf 100644 --- a/test/types/portal-source-context.test.ts +++ b/test/types/portal-source-context.test.ts @@ -777,34 +777,55 @@ describe('PortalSourceContext', () => { throw new Error(`expected a 'reservedAddresses' problem, got '${problem.kind}'`); } return problem.pages - .map(({ file, address, section }) => `${file.relativeTo(new DirectoryPath(root))} ${address} ${section.folder}`) + .map( + ({ file, address, keptFor }) => `${file.relativeTo(new DirectoryPath(root))} ${address} ${keptFor.address}` + ) .sort(); }; - // Three of them share /sdks as well, which is not said again. - it('refuses every page the SDK pages would share an address with, naming each', async () => { - write('content/sdks.md', page('Mine')); - write('content/sdks/setup.mdx', page('Setup')); - write('content/(intro)/sdks.md', page('Grouped')); - write('content/sdks/index.md', page('Index')); - - expect(reserved((await resolve())._unsafeUnwrapErr())).to.deep.equal([ - 'content/(intro)/sdks.md /sdks sdks', - 'content/sdks.md /sdks sdks', - 'content/sdks/index.md /sdks sdks', - 'content/sdks/setup.mdx /sdks/setup sdks' - ]); - }); - - // Reserved with or without a plugin block, so adding one never refuses a page that built. - it('refuses a page at the context plugin address although there is no plugin block', async () => { - write('content/context-plugin.md', page('Mine')); - write('content/context-plugin/faq.md', page('FAQ')); - - expect(reserved((await resolve())._unsafeUnwrapErr())).to.deep.equal([ - 'content/context-plugin.md /context-plugin context-plugin', - 'content/context-plugin/faq.md /context-plugin/faq context-plugin' - ]); + ( + [ + [ + // Three of them share /sdks as well, which is not said again. + 'refuses every page the SDK pages would share an address with, naming each', + ['content/sdks.md', 'content/sdks/setup.mdx', 'content/(intro)/sdks.md', 'content/sdks/index.md'], + [ + 'content/(intro)/sdks.md /sdks /sdks', + 'content/sdks.md /sdks /sdks', + 'content/sdks/index.md /sdks /sdks', + 'content/sdks/setup.mdx /sdks/setup /sdks' + ] + ], + [ + // Reserved with or without a plugin block, so adding one never refuses a page that built. + 'refuses a page at the context plugin address although there is no plugin block', + ['content/context-plugin.md', 'content/context-plugin/faq.md'], + [ + 'content/context-plugin.md /context-plugin /context-plugin', + 'content/context-plugin/faq.md /context-plugin/faq /context-plugin' + ] + ], + [ + // The template's route wins over the page, which would build, sit in the sidebar and never be shown. + 'refuses a page at the OAuth callback address, and only there', + [ + 'content/oauth/callback.md', + 'content/(auth)/oauth/callback/index.mdx', + 'content/oauth/index.md', + 'content/oauth/callback/setup.md' + ], + [ + 'content/(auth)/oauth/callback/index.mdx /oauth/callback /oauth/callback', + 'content/oauth/callback.md /oauth/callback /oauth/callback' + ] + ] + ] as const + ).forEach(([name, files, refused]) => { + it(name, async () => { + files.forEach((file) => write(file, page('Page'))); + + expect(reserved((await resolve())._unsafeUnwrapErr())).to.deep.equal(refused); + }); }); // A group folder's name is not part of the address; only the folder it groups is. From a34620977c63be456f9b06818e76d196757c3142 Mon Sep 17 00:00:00 2001 From: MuHamza30 Date: Thu, 8 Oct 2026 11:19:45 +0500 Subject: [PATCH 2/5] fix(portal): follow up the review of the preview allow list (#426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #418, which was approved on the condition that its review comments are addressed in a separate PR. Every comment is addressed here. ## Changes - **Real paths for the dependencies and the Vite binary** (finding 3). The package lookup answers canonical paths, so a Windows 8.3 short name or a symlinked install can no longer leave the entry's `/@fs/` import refused in every browser. The new unit test fails without the change on a pnpm checkout. - **The narrowest directory holding the dependencies** (finding 1). The allow list took the outermost `node_modules` above each linked package: every global package under an npm global install, and only each package's own directory under a pnpm global install, where 69 of the page's 120 `/@fs/` files fell outside it. It now takes the directory all the linked installations share: the CLI's own `node_modules` under npm, the `.pnpm` or `links` store under pnpm. Where the installations share nothing short of the disk's root, as with an `npm link`ed dependency on another drive, each is listed on its own rather than widening to the root. Unit tests cover the four layouts in the review's table and the divergent case. - **Vite's default workspace root is kept** (finding 5), and the project directory is `portalProjectDirectory` after the glossary (inline comment). - **Nothing is skipped** (finding 9, tightened in review). A missing `node_modules` or a link leading nowhere stops the config load with its own error, naming the path, rather than surfacing later as a blank page. - **Serve tests check what came back** (finding 7): a JavaScript content type for the dependency file and `image/png` for the static image, since a missing file answers 200 with the fallback page. Cleanup only removes what set-up made (finding 10). - **One-line comments**, as the review's table asks. ## Recommendation 5: `Cache-Control: no-store`, tested in Safari Tested on macOS, Safari, from the `dev` commit before #418 so the bug could show. Control: the first preview loaded and the next preview on the same port went blank. With `no-store` on the page: every run blank. With `no-store` on every module too: first run loaded, every later run blank, Safari restarted between. In Web Inspector the entry module, `virtual:tanstack-start-dev-client-entry`, showed `(memory)` as its transfer size in every case, on reload too. Safari serves the module from its memory cache whatever the header says, which confirms finding 6's premise and rules out the header as a fix. The allow list stays. ## Verification - Full suite, both end-to-end suites, lint and type check pass. - Replaying the review's measurement: the page's 120 `/@fs/` files, asked of a fresh server before anything else, are all served from a real `npm install -g` and a real `pnpm add -g` (pnpm 11, `links` store). The old boundary refused 69 under pnpm. - A build of this branch at 7754fb5 on macOS, Safari, with the stale cache from earlier previews in place: quickstart, two repeat previews, a page with a static image over two previews, and a second quickstart all loaded. The commits since restructure the fold, add the root guard and let the lookup throw; the unit and serve tests cover them, and on a project linked like the CLI links it the old and new lookup answer the same allow list. ## Note on Windows CI As finding 8 says, on `windows-latest` the test project lands inside the repository, which Vite's default already trusts, so the serve tests pass there with or without the allow list. They guard it on macOS and Linux. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 --- portal-template/dependency-directories.ts | 71 +++++++-------- portal-template/vite.config.ts | 10 +-- src/infrastructure/portal-project-service.ts | 4 +- test/e2e/portal-serve.test.ts | 29 +++--- .../portal-project-service.test.ts | 13 +++ .../dependency-directories.test.ts | 89 +++++++++++++------ 6 files changed, 125 insertions(+), 91 deletions(-) diff --git a/portal-template/dependency-directories.ts b/portal-template/dependency-directories.ts index a86245be3..2c05a482e 100644 --- a/portal-template/dependency-directories.ts +++ b/portal-template/dependency-directories.ts @@ -1,54 +1,45 @@ import fs from 'node:fs'; import path from 'node:path'; -/** - * The installations the project's dependencies resolve to. Vite serves a file from outside - * its allow list only once the module importing it has been transformed, an order a browser - * cache skips: Safari, holding the importer from an earlier preview on the same port, asked - * for the TanStack dev entry first and was refused, leaving a blank page. Naming where the - * links really lead keeps every dependency served whatever the browser asks for first. - */ -export function dependencyDirectories(projectDirectory: string): string[] { - const directories = new Set(); - for (const packagePath of packagePaths(path.join(projectDirectory, 'node_modules'))) { - const installed = realPath(packagePath); - if (installed !== undefined) { - directories.add(outermostNodeModules(installed)); +// Safari runs a cached entry before its importer is transformed (apimatic-io#2287). +export function dependencyDirectories(portalProjectDirectory: string): string[] { + const modules = path.join(portalProjectDirectory, 'node_modules'); + return narrowest( + packageNames(modules) + .filter((name) => fs.lstatSync(path.join(modules, name)).isSymbolicLink()) + .map((name) => holdingDirectory(fs.realpathSync.native(path.join(modules, name)), name)) + ); +} + +/** The one directory holding them all: the CLI's `node_modules` under npm, the store under pnpm. Each alone when only the disk's root would. */ +function narrowest(directories: string[]): string[] { + let common: string | undefined = directories[0]; + for (const directory of directories.slice(1)) { + while (common !== undefined && !isWithin(common, directory)) { + common = isRoot(path.dirname(common)) ? undefined : path.dirname(common); } } - return [...directories]; + return common === undefined ? directories : [common]; } -function packagePaths(modules: string): string[] { - return entries(modules).flatMap((name) => { - const packagePath = path.join(modules, name); - return name.startsWith('@') ? entries(packagePath).map((scoped) => path.join(packagePath, scoped)) : [packagePath]; - }); +function packageNames(modules: string): string[] { + return fs + .readdirSync(modules) + .flatMap((name) => + name.startsWith('@') ? fs.readdirSync(path.join(modules, name)).map((scoped) => `${name}/${scoped}`) : [name] + ); } -function entries(directory: string): string[] { - try { - return fs.readdirSync(directory).filter((name) => !name.startsWith('.')); - } catch { - return []; - } +/** Where a package and the dependencies installed beside it sit, its scope included. */ +function holdingDirectory(packageDirectory: string, name: string): string { + return path.resolve(packageDirectory, ...name.split('/').map(() => '..')); } -function realPath(target: string): string | undefined { - try { - return fs.realpathSync(target); - } catch { - return undefined; - } +function isRoot(directory: string): boolean { + return path.dirname(directory) === directory; } -/** The widest installation around a package: under pnpm the whole store sits inside one `node_modules` too. */ -function outermostNodeModules(packageDirectory: string): string { - let found = packageDirectory; - for (let parent = path.dirname(packageDirectory); parent !== path.dirname(parent); parent = path.dirname(parent)) { - if (path.basename(parent) === 'node_modules') { - found = parent; - } - } - return found; +function isWithin(directory: string, target: string): boolean { + const relative = path.relative(directory, target); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } diff --git a/portal-template/vite.config.ts b/portal-template/vite.config.ts index 38b846862..b97fdc2b4 100644 --- a/portal-template/vite.config.ts +++ b/portal-template/vite.config.ts @@ -1,7 +1,7 @@ import { fileURLToPath } from 'node:url'; import react from '@vitejs/plugin-react'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; -import { defineConfig } from 'vite'; +import { defineConfig, searchForWorkspaceRoot } from 'vite'; import tailwindcss from '@tailwindcss/vite'; import { fumadocsMdx } from 'fumadocs-mdx/vite'; import { dependencyDirectories } from './dependency-directories.ts'; @@ -15,7 +15,7 @@ export default defineConfig(async () => { const [paths, identity] = await Promise.all([readBuildPaths(), readPortalIdentity()]); const pages = await prerenderPages(paths, identity.siteUrl); const publicDir: string | false = paths.staticDir ?? false; - const projectDirectory = fileURLToPath(new URL('.', import.meta.url)); + const portalProjectDirectory = fileURLToPath(new URL('.', import.meta.url)); return { publicDir, @@ -57,13 +57,11 @@ export default defineConfig(async () => { // listens on `::1` alone, a connect that stalls under load falls back to 127.0.0.1 and is // refused, failing the build; binding IPv4 leaves the fetch a single address to reach. preview: { host: '127.0.0.1' }, - // Spelled out because the default, the project alone, misses the CLI installation the - // dependencies are linked from, and the static directory pages import images from. server: { fs: { allow: [ - projectDirectory, - ...dependencyDirectories(projectDirectory), + searchForWorkspaceRoot(portalProjectDirectory), + ...dependencyDirectories(portalProjectDirectory), ...(paths.staticDir === null ? [] : [paths.staticDir]) ] } diff --git a/src/infrastructure/portal-project-service.ts b/src/infrastructure/portal-project-service.ts index cb8698809..0cb66b950 100644 --- a/src/infrastructure/portal-project-service.ts +++ b/src/infrastructure/portal-project-service.ts @@ -18,6 +18,7 @@ import { errorMessage } from '../utils/error-utils.js'; import { envInfo } from './env-info.js'; import { FileService } from './file-service.js'; import { PortalPagesService } from './portal-pages-service.js'; +import { canonical } from './tmp-extensions.js'; // Copied, not linked: Tailwind rebases their `url()`s onto the project, and no relative path crosses drives. export const COPIED_DEPENDENCIES = ['@fontsource-variable/geist', '@fontsource-variable/geist-mono']; @@ -393,8 +394,9 @@ export class PortalProjectService { private packageDirectory(name: string): DirectoryPath | undefined { for (const candidate of this.require.resolve.paths(name) ?? []) { const manifest = path.join(candidate, name, 'package.json'); + // Vite refuses a Windows 8.3 short name, and resolves symlinks before comparing against its allow list. if (fsExtra.existsSync(manifest)) { - return new DirectoryPath(path.dirname(manifest)); + return new DirectoryPath(canonical(path.dirname(manifest))); } } return undefined; diff --git a/test/e2e/portal-serve.test.ts b/test/e2e/portal-serve.test.ts index 565355a5c..b02122dde 100644 --- a/test/e2e/portal-serve.test.ts +++ b/test/e2e/portal-serve.test.ts @@ -14,16 +14,15 @@ import { removePortalProjectDirectoryBase } from '../../src/infrastructure/tmp-extensions'; -// A real Vite dev server takes a minute to compile the portal and needs every runtime -// dependency installed, so it stays out of the default run, as the build test does. +// Opt-in: a real dev server needs a minute and every runtime dependency. const enabled = process.env.APIMATIC_E2E === '1'; (enabled ? describe : describe.skip)('portal serve (end to end)', function () { this.timeout(10 * 60 * 1000); const fixture = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/default/src'); - let base: string; - let root: string; + let base: string | undefined; + let root: string | undefined; let server: PortalDevServer | undefined; before(async () => { @@ -55,27 +54,22 @@ const enabled = process.env.APIMATIC_E2E === '1'; after(async () => { await server?.stop(); - fs.rmSync(root, { recursive: true, force: true }); - await removePortalProjectDirectoryBase(base); + if (root !== undefined) fs.rmSync(root, { recursive: true, force: true }); + if (base !== undefined) await removePortalProjectDirectoryBase(base); }); - /** - * Safari keeps the entry module cached across previews on the fixed port and executes it - * without asking the fresh server, so its first request is the file the entry imports from - * the CLI's installation. Served only through its importer's transform, that first request - * is refused as outside the allow list, and the page stays blank (apimatic-io#2287). - */ - it('serves the TanStack dev entry to a browser that asks for it before anything else', async () => { - const installed = fs.realpathSync(path.join(root, 'build', 'node_modules', '@tanstack', 'react-start')); + // A missing file answers 200 too, with the fallback page, so the type says what came back. + it('serves a dependency file asked for before its importer (apimatic-io#2287)', async () => { + const installed = fs.realpathSync(path.join(root!, 'build', 'node_modules', '@tanstack', 'react-start')); const entry = path.join(installed, 'dist', 'plugin', 'default-entry', 'client.tsx').split(path.sep).join('/'); const response = await globalThis.fetch(`${server?.url.toString()}/@fs/${entry}`); - expect(response.status, await response.text()).to.equal(200); + expect(response.status, await response.clone().text()).to.equal(200); + expect(response.headers.get('content-type')).to.contain('javascript'); }); - // A page imports its images from the static directory, outside the project, by the same kind of address. - it('serves an image from the static directory to a browser that asks for it before the page', async () => { + it('serves an image from the static directory asked for before the page importing it', async () => { const image = fs .realpathSync(path.join(fixture.toString(), 'static', 'images', 'logo.png')) .split(path.sep) @@ -84,5 +78,6 @@ const enabled = process.env.APIMATIC_E2E === '1'; const response = await globalThis.fetch(`${server?.url.toString()}/@fs/${image}`); expect(response.status).to.equal(200); + expect(response.headers.get('content-type')).to.equal('image/png'); }); }); diff --git a/test/infrastructure/portal-project-service.test.ts b/test/infrastructure/portal-project-service.test.ts index a1972d4c8..e16420754 100644 --- a/test/infrastructure/portal-project-service.test.ts +++ b/test/infrastructure/portal-project-service.test.ts @@ -7,6 +7,7 @@ import sinon from 'sinon'; import { FileService } from '../../src/infrastructure/file-service'; import { COPIED_DEPENDENCIES, + LINKED_DEPENDENCIES, PortalProjectService, TEMPLATE_DEPENDENCIES } from '../../src/infrastructure/portal-project-service'; @@ -129,6 +130,18 @@ describe('PortalProjectService', () => { } }); + // Vite refuses an 8.3 short name outright, and compares the real path against its allow list. + it('links each dependency, and names the Vite binary, by its real path', async () => { + const prepared = (await service.prepare(project, sourceFor(), NO_ARTIFACTS))._unsafeUnwrap(); + + for (const name of LINKED_DEPENDENCIES) { + const link = path.join(project.toString(), 'node_modules', name); + expect(path.resolve(fs.readlinkSync(link)), name).to.equal(fs.realpathSync.native(link)); + } + const binary = prepared.viteBinary.toString(); + expect(binary).to.equal(fs.realpathSync.native(binary)); + }); + // Linked, their `url()`s would resolve from the CLI's install, which may be on another drive. it('copies the font packages into the project instead of linking them', async () => { (await service.prepare(project, sourceFor(), NO_ARTIFACTS))._unsafeUnwrap(); diff --git a/test/portal-template/dependency-directories.test.ts b/test/portal-template/dependency-directories.test.ts index 13ee77ea7..1bdd24ba1 100644 --- a/test/portal-template/dependency-directories.test.ts +++ b/test/portal-template/dependency-directories.test.ts @@ -2,29 +2,27 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { expect } from 'chai'; +import sinon from 'sinon'; import { dependencyDirectories } from '../../portal-template/dependency-directories'; -/** - * The directories `server.fs.allow` names so the dev server serves a linked dependency even - * when the browser, from its cache, asks for it before the module importing it. - */ describe('dependencyDirectories', () => { let root: string; - const install = (relative: string): string => { - const directory = path.join(root, relative); + const install = (...segments: string[]): string => { + const directory = path.join(root, ...segments); fs.mkdirSync(directory, { recursive: true }); return directory; }; const link = (name: string, target: string) => { - const linkPath = path.join(root, 'project', 'node_modules', name); + const linkPath = path.join(root, 'project', 'node_modules', ...name.split('/')); fs.mkdirSync(path.dirname(linkPath), { recursive: true }); // A junction is the only link type Windows grants without elevation. fs.symlinkSync(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir'); }; const project = () => path.join(root, 'project'); + const real = (...segments: string[]) => fs.realpathSync.native(path.join(root, ...segments)); beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'dependency-directories-')); @@ -32,44 +30,81 @@ describe('dependencyDirectories', () => { }); afterEach(() => { + sinon.restore(); fs.rmSync(root, { recursive: true, force: true }); }); - it('answers the installation a linked dependency resolves to', () => { - link('vite', install(path.join('cli', 'node_modules', 'vite'))); + it('answers the node_modules the linked dependencies are installed in, scoped ones included', () => { + link('vite', install('cli', 'node_modules', 'vite')); + link('@tanstack/react-start', install('cli', 'node_modules', '@tanstack', 'react-start')); - expect(dependencyDirectories(project())).to.deep.equal([fs.realpathSync(path.join(root, 'cli', 'node_modules'))]); + expect(dependencyDirectories(project())).to.deep.equal([real('cli', 'node_modules')]); }); - it('answers one directory for dependencies installed side by side, scoped ones included', () => { - link('vite', install(path.join('cli', 'node_modules', 'vite'))); - link(path.join('@tanstack', 'react-start'), install(path.join('cli', 'node_modules', '@tanstack', 'react-start'))); + it("answers the CLI's own node_modules in an npm global install, not every global package around it", () => { + const cli = ['lib', 'node_modules', '@apimatic', 'cli', 'node_modules']; + install('lib', 'node_modules', 'some-other-global-package'); + link('vite', install(...cli, 'vite')); + link('@tanstack/react-start', install(...cli, '@tanstack', 'react-start')); - expect(dependencyDirectories(project())).to.deep.equal([fs.realpathSync(path.join(root, 'cli', 'node_modules'))]); + expect(dependencyDirectories(project())).to.deep.equal([real(...cli)]); }); - it('answers the store around a pnpm installation, not the one package inside it', () => { - link('vite', install(path.join('cli', 'node_modules', '.pnpm', 'vite@8.2.2', 'node_modules', 'vite'))); + it('answers the whole pnpm store, where each dependency sits in a directory of its own', () => { + const store = ['global', 'node_modules', '.pnpm']; + link('vite', install(...store, 'vite@8.2.2', 'node_modules', 'vite')); + link( + '@tanstack/react-start', + install(...store, '@tanstack+react-start@1.168.50', 'node_modules', '@tanstack', 'react-start') + ); - expect(dependencyDirectories(project())).to.deep.equal([fs.realpathSync(path.join(root, 'cli', 'node_modules'))]); + expect(dependencyDirectories(project())).to.deep.equal([real(...store)]); }); - it('keeps a copied dependency where it is', () => { - install(path.join('project', 'node_modules', '@fontsource-variable', 'geist')); + it("answers the pnpm 11 global store's links directory, where each dependency sits under a hash of its own", () => { + const links = ['store', 'links']; + link('vite', install(...links, 'vite', '8.2.2', 'a1b2', 'node_modules', 'vite')); + link( + '@tanstack/react-start', + install(...links, '@tanstack', 'react-start', '1.168.50', 'c3d4', 'node_modules', '@tanstack', 'react-start') + ); - expect(dependencyDirectories(project())).to.deep.equal([ - fs.realpathSync(path.join(root, 'project', 'node_modules')) - ]); + expect(dependencyDirectories(project())).to.deep.equal([real(...links)]); }); - it('leaves out a link leading nowhere rather than failing the server over it', () => { - link('vite', install(path.join('cli', 'node_modules', 'vite'))); + it('leaves a copied dependency to the workspace root, which already holds it', () => { + install('project', 'node_modules', '@fontsource-variable', 'geist'); + link('vite', install('cli', 'node_modules', 'vite')); + + expect(dependencyDirectories(project())).to.deep.equal([real('cli', 'node_modules')]); + }); + + it('reports a link leading nowhere rather than leaving it out', () => { + link('vite', install('cli', 'node_modules', 'vite')); fs.rmdirSync(path.join(root, 'cli', 'node_modules', 'vite')); - expect(dependencyDirectories(project())).to.deep.equal([]); + expect(() => dependencyDirectories(project())).to.throw(/ENOENT/); }); - it('answers nothing for a project without dependencies', () => { - expect(dependencyDirectories(path.join(root, 'nowhere'))).to.deep.equal([]); + it('lists installations one by one rather than the whole disk, when only its root holds them all', () => { + const disk = path.parse(root).root; + const installations: Record = { + vite: path.join(disk, 'usr', 'lib', 'node_modules', '@apimatic', 'cli', 'node_modules'), + react: path.join(disk, 'home', 'me', 'src', 'node_modules') + }; + link('vite', install('cli', 'node_modules', 'vite')); + link('react', install('elsewhere', 'node_modules', 'react')); + const installed = (target: string) => path.join(installations[path.basename(target)], path.basename(target)); + // Only the disk's root holds both, and a test cannot install there. + sinon.stub(fs.realpathSync, 'native').callsFake(installed as typeof fs.realpathSync.native); + + expect(dependencyDirectories(project())).to.have.members(Object.values(installations)); + }); + + it('reports a node_modules it cannot read, rather than serving less than the page needs', () => { + fs.rmdirSync(path.join(root, 'project', 'node_modules')); + fs.writeFileSync(path.join(root, 'project', 'node_modules'), ''); + + expect(() => dependencyDirectories(project())).to.throw(/ENOTDIR/); }); }); From 767671be3f41280623b68f7de6bfa39c7ab057af Mon Sep 17 00:00:00 2001 From: Muhammad Saeed Date: Thu, 8 Oct 2026 15:33:46 +0500 Subject: [PATCH 3/5] feat(portal): host a portal under the path of site.url (#411) --- .ai/plans/portal-subpath-hosting.md | 1071 +++++++++++++++++ README.md | 13 +- apimatic.schema.json | 4 +- portal-template/downloads.ts | 9 +- portal-template/portal-config.ts | 2 + portal-template/prerender-pages.ts | 7 +- .../src/components/api-markdown.tsx | 32 + portal-template/src/components/api-page.tsx | 7 +- .../src/components/authorization-failure.tsx | 4 +- .../src/components/markdown-copy-button.tsx | 4 +- .../src/components/plugin-install.tsx | 11 +- .../src/components/sdk-actions.tsx | 5 +- portal-template/src/components/sdk-cards.tsx | 3 +- portal-template/src/components/search.tsx | 11 +- portal-template/src/lib/base-path.ts | 14 + portal-template/src/lib/code-titles.ts | 8 + portal-template/src/lib/copy-markdown.ts | 6 +- portal-template/src/lib/install-command.ts | 10 +- portal-template/src/lib/layout.shared.tsx | 8 +- portal-template/src/lib/llms.server.ts | 34 +- portal-template/src/lib/portal-types.ts | 6 +- portal-template/src/lib/rehype-code-titles.ts | 8 +- .../src/lib/remark-image-references.ts | 35 + portal-template/src/lib/search-content.ts | 28 + portal-template/src/lib/search-options.ts | 7 + portal-template/src/lib/source.server.ts | 2 + portal-template/src/router.tsx | 4 +- portal-template/src/routes/$.tsx | 5 +- portal-template/src/routes/__root.tsx | 9 +- .../src/routes/api/search[.]json.ts | 25 +- portal-template/static-functions-base.ts | 41 + portal-template/vite.config.ts | 11 +- src/actions/portal/generate.ts | 7 +- src/commands/portal/generate.ts | 2 +- src/commands/portal/serve.ts | 2 +- .../portal-dev-server-service.ts | 3 +- src/infrastructure/portal-project-service.ts | 7 +- src/prompts/portal/generate.ts | 30 +- src/prompts/portal/serve.ts | 5 +- src/types/portal/config/site-address.ts | 72 ++ src/types/portal/config/site-config.ts | 41 +- src/types/portal/config/static-asset.ts | 4 +- src/types/portal/page.ts | 20 +- src/types/portal/portal-config.ts | 20 +- test/actions/portal/generate.test.ts | 9 + test/e2e/portal-build.test.ts | 160 +++ .../portal-dev-server-service.test.ts | 10 + .../portal-project-service.test.ts | 15 + test/portal-template.test.ts | 4 +- .../authorization-failure.test.ts | 7 +- test/portal-template/base-path.test.ts | 35 + test/portal-template/downloads.test.ts | 15 +- test/portal-template/install-command.test.ts | 15 +- test/portal-template/prerender-pages.test.ts | 46 +- .../remark-image-references.test.ts | 60 + test/portal-template/search-content.test.ts | 23 + .../portal-template/search-navigation.test.ts | 35 + test/portal-template/search-options.test.ts | 12 + .../static-functions-base.test.ts | 91 ++ test/prompts/portal/generate.test.ts | 67 ++ test/prompts/portal/serve.test.ts | 6 +- .../portal-inputs/subpath/src/apimatic.json | 16 + .../subpath/src/content/authentication.md | 10 + .../subpath/src/content/index.md | 8 + .../subpath/src/content/nav.json | 11 + .../subpath/src/spec/Apimatic-Calculator.json | 104 ++ .../subpath/src/static/images/diagram.png | Bin 0 -> 12420 bytes .../subpath/src/static/images/logo.png | Bin 0 -> 3912 bytes test/types/apimatic-config/schema.test.ts | 32 +- test/types/portal/config/site-address.test.ts | 117 ++ test/types/portal/page.test.ts | 15 +- test/types/portal/portal-config.test.ts | 42 +- 72 files changed, 2464 insertions(+), 158 deletions(-) create mode 100644 .ai/plans/portal-subpath-hosting.md create mode 100644 portal-template/src/components/api-markdown.tsx create mode 100644 portal-template/src/lib/base-path.ts create mode 100644 portal-template/src/lib/remark-image-references.ts create mode 100644 portal-template/src/lib/search-content.ts create mode 100644 portal-template/src/lib/search-options.ts create mode 100644 portal-template/static-functions-base.ts create mode 100644 src/types/portal/config/site-address.ts create mode 100644 test/portal-template/base-path.test.ts create mode 100644 test/portal-template/remark-image-references.test.ts create mode 100644 test/portal-template/search-content.test.ts create mode 100644 test/portal-template/search-navigation.test.ts create mode 100644 test/portal-template/search-options.test.ts create mode 100644 test/portal-template/static-functions-base.test.ts create mode 100644 test/prompts/portal/generate.test.ts create mode 100644 test/resources/portal-inputs/subpath/src/apimatic.json create mode 100644 test/resources/portal-inputs/subpath/src/content/authentication.md create mode 100644 test/resources/portal-inputs/subpath/src/content/index.md create mode 100644 test/resources/portal-inputs/subpath/src/content/nav.json create mode 100644 test/resources/portal-inputs/subpath/src/spec/Apimatic-Calculator.json create mode 100644 test/resources/portal-inputs/subpath/src/static/images/diagram.png create mode 100644 test/resources/portal-inputs/subpath/src/static/images/logo.png create mode 100644 test/types/portal/config/site-address.test.ts diff --git a/.ai/plans/portal-subpath-hosting.md b/.ai/plans/portal-subpath-hosting.md new file mode 100644 index 000000000..ffb672f74 --- /dev/null +++ b/.ai/plans/portal-subpath-hosting.md @@ -0,0 +1,1071 @@ +# Plan: hosting the portal under a sub-path + +Status: designed 2026-09-29 on `saeedjamshaid/portal-subpath-hosting`, cut from `origin/dev` at +`74ad5d2e`. Tracks apimatic-io#2275, whose design this follows except where section 14 says +otherwise. Reviewed four times on 2026-09-29; section 15 records what each round changed. + +Implemented in steps 1–4 on 2026-09-29 and 30, and opened as apimatic/apimatic-cli#411, which +supersedes #407. Step 5 (section 7) is the follow-up outside this repository. + +## 1. Goal and scope + +`portal.site.url` may carry a path, such as `https://acme.github.io/docs` for a GitHub Pages +project site or a docs prefix behind a reverse proxy. When it does, the portal works when it is +served at that path: its pages, assets, client navigation, search, downloads, SEO files and +`portal serve`. + +- **In scope:** the path comes from `site.url`, and only from there. +- **Out of scope** (decided 2026-09-29; section 12 says why): + - a path given without `site.url`, as a setting or a flag; + - one build that works at whatever path it is deployed to. +- **Invariant:** a portal whose `site.url` has no path, or that has no `site.url`, emits the same + pages, files and addresses as today. The content hashes in chunk names are the only difference, + apart from the image fixes of 5.10 and 5.12, which apply at the root too: reference-style images + are bundled and checked like inline ones, an image in a description no longer fails its page, + and search results show no image from a description. + +**Terms:** +- A **portal-relative path** is a page's path from the portal's own root (`/guides/intro`). +- The **base path** is where the portal is mounted (`/docs`, or `''` at a host's root). +- The **site address** is the origin plus the base path (`https://acme.github.io/docs`). +- The **served path** is the base path plus the portal-relative path. + +The comments that say "site-relative" today (`portal-config.ts:29,31`, `portal-types.ts`) are +changed to "portal-relative". `StaticAsset.siteUrl()`, which returns a portal-relative path, is +renamed `portalPath()` (4.3). + +## 2. Decisions (2026-09-29) + +1. **Where the path comes from.** It is the path of `site.url`. Without `site.url` the portal is + at the root of its host, as today. +2. **Vite's `base` is the one switch.** TanStack Start derives the rest from it: + - the router basepath (`deriveRouterBasepath`, start-plugin-core `planning.js:14`); + - every page's asset URLs (the Start manifest is built from `publicBase`); + - the prerender, which fetches `withBase(page)` and writes `withoutBase(file)` (`prerender.js:78,92`). + + Fumadocs' links are TanStack Router links (`fumadocs-core/dist/framework/tanstack.js`), which + already carry the basepath. So `docsRoute` stays `/`; a Fumadocs `baseUrl` would double every + link (`/docs/docs/guides`). TanStack's maintainer puts the split the same way on #4888: Vite + `base` owns assets, and the router basepath owns routes. +3. **One source for the path: the CLI's `SiteAddress`** (revised 2026-10-08, section 15). + - `siteUrl` in `portal.identity.json` becomes the site address. + - `portal.config.json` carries `base`, the address's `path()`, and `vite.config.ts` takes it as + it is. The CLI writes both files from one `SiteAddress` when it prepares the build. + - Not in `portal.identity.json`: the browser imports that file whole, and it already has the + base as `import.meta.env.BASE_URL` (decision 4). + - Until 2026-10-08 the template worked `base` out of `siteUrl` a second time, in `viteBase`, and + a contract test held that to `SiteAddress.path()`. +4. **Portal-relative strings are prefixed in the template.** The strings the router never sees + go through one helper, `withBasePath()`, which reads `import.meta.env.BASE_URL`. + - The template is the layer that already adds the base to the router's URLs. Prefixing in the + CLI would give the same rule a second home. + - `BASE_URL` is the value Vite actually applied. Under `portal serve`, an edit that moves the + path rewrites `siteUrl` while Vite keeps its startup base (decision 9), so reading `siteUrl` + there would disagree with the assets. + - The code that runs in Node passes the base explicitly: the prerender list, the dev + downloads middleware and the cache plugin. So the prefix rule has one home. +5. **`siteUrl` carries the whole site address.** `seo.ts` and `sitemap.server.ts` concatenate + `siteUrl + pageUrl`, so these follow with no code change: canonical, `og:url`, the sitemap, and + the plugin's Markdown install command. +6. **A base path that collides with page paths works; nothing is refused.** A collision is a + page whose portal-relative path starts with the base, such as `/api` against the API reference, + or `/docs` against a `docs/` folder. Two things break on one today, and each gets a fix in the + template: + - **The prerender.** ufo's `withBase` leaves a path that already starts with the base unchanged + (ufo 1.6.4), so `/docs/intro` under `/docs` is fetched as `/intro` and 404s. Fixed by giving + TanStack the served paths (5.7): `withBase` then leaves every one alone, and `withoutBase` + strips the base exactly once for the filename. + - **Search results.** They navigate through `router.navigate({ href })`, whose input rewrite + strips a leading base, case-insensitively (`router-core router.js:261-265`, `rewrite.js`). + Fixed by handing the dialog served URLs (5.2), so the rewrite strips exactly once. The dialog + is the only place in Fumadocs that navigates by `href`. + + Router links (`to`) and direct loads already strip once, correctly. All of this was measured + under base `/api`, in the worst case (section 3). +7. **No `robots.txt` under a path.** Crawlers read it only from the root of a host. The Next + Steps note says where the sitemap is instead. +8. **The static server-function cache URL is rewritten in the client build** by a Vite plugin in + the template, until TanStack/router#6152 ships. The rejected alternatives: + - a pnpm patch, which never reaches users who install the CLI with npm; + - a copy of the middleware, which needs `@tanstack/start-client-core` and `seroval` as direct + dependencies pinned to Start's versions; + - a wrapper round the global `fetch`, which changes a browser global for every caller. + + TanStack offers no option for this URL (`staticFunctionMiddleware.ts:50` on `main`), and the + latest release is 1.167.38. +9. **`portal serve` runs under the base**, as the build does. The 2026-09-29 dev probe found + routing, the `/docs/_serverFn/` page data, HMR and live edits all working there, and it surfaced + the same missing prefixes a build shows. + - It reports and waits on the address with its trailing slash. + - An edit that moves the path is applied, so canonical links follow, but the preview keeps + serving the old path until it restarts. The restart list in the Live preview note names it. + There is no notice of its own: the path is set once, and a notice would mean reworking the + static-directory one into a keyed prompt, which is not worth it for so rare an edit. +10. **Links in the Markdown copies.** + - **In scope:** the links we write into the SDK pages' Markdown copies get the base (5.2). + - **Deferred to a follow-up** (decided 2026-09-29): the links authors write keep their + spelling in the copies. The reasons: + - It is the one part whose mechanism is still open (section 9). + - It affects only the copies handed to AI assistants; the HTML already follows the base. + - No portal is hosted under a path yet. + - It would couple us to another Fumadocs internal. +11. **The quickstart note stays as #403 left it.** The change is carried by: + - the schema description; + - the validation messages; + - the Next Steps note of `portal generate`; + - the README. + +## 3. What was verified (2026-09-29) + +This covers the installed versions: Vite 8.2.2, @tanstack/react-start 1.168.50, start-plugin-core +1.171.40, router-core 1.171.28, start-static-server-functions 1.167.33, fumadocs-core/ui 16.15.15, +fumadocs-mdx 15.4.0. **Measured** means it was built or run; **read** means it was read in the +installed source. + +**Measured: the `default` fixture built with Vite `base: '/docs/'`, served at `/docs`, and driven +in headless Chrome.** In the fixes column, the `/__tsr/` fix was a hand-written fetch wrapper; the +planned plugin was verified afterwards (see the next list). + +| | `base` only | `base` + search, `/__tsr/` and logo fixes | +|---|---|---| +| First page load | works | works | +| Client navigation | the 404 page: `/__tsr/staticServerFnCache/*.json` 404s | works | +| Search | no results: `/api/search.json` 404s | works | +| Logo, favicon | 404 | work | +| Failed requests | 5 | 0 | + +**Measured, with the planned plugin (5.6) in place of the fetch wrapper:** +- Picking a search result navigates to `/docs/api/…/Calculate`, and its cache fetch answers 200. +- An unknown deep link served through `404.html` shows the not-found page, with `/docs/` links. +- Cache files are written to `dist/client/__tsr/`. + +**Measured: a colliding base.** +- `base: '/api/'` as things stand fails the build (`Failed to fetch /api/search.json: Not Found`). +- With served paths handed to the prerender and served URLs handed to the search dialog, it + builds, and the output layout is unchanged: `api/search.json` and + `api//…/Calculate/index.html` are at the output's root. +- In the browser, all of these work: + - a direct load of `/api/api/…/Calculate`; + - a sidebar click to it and back; + - clicking a search result; + - a deep link through `404.html`. + +**Measured with step 3 in place (2026-09-30):** the `subpath` fixture built and served at +`/api`, with `404.html` for missing paths, and driven in headless Chrome. All of these work: +- first load, with the logo, the Markdown image and the favicon; +- a click to a content page and to a colliding `/api/api/…` reference page, and back. Their + cached data comes from `/api/__tsr/…`, with status 200; +- the popover's "View as Markdown" (`http://…/api/authentication.md`, 200), the prompt's page + address, and the copy button's fetch; +- search, and a click on a result landing on the colliding page; +- the SDK download (200), the plugin page's install command with `/api`, a direct load of a + reference page, and a missing page shown as the not-found page with `/api/` links. + +The only console output was also there on the root-hosted `default` build, driven the same way: +- headless Chrome refusing the clipboard to an unfocused document; +- the not-found page's own failed data lookup. + +**Measured with step 4 in place (2026-09-30):** `portal serve` on the `subpath` fixture, run as +the action runs with only the account check and the artifacts stubbed, and driven in headless +Chrome. It printed `http://127.0.0.1:/api/`, and the restart list named the path. All of +these work, with no failed request and no console output: +- the printed link, with the logo, the Markdown image and the favicon; +- a click to a content page and to a colliding `/api/api/…` reference page, and back, their data + from `/api/_serverFn/…`; +- search, and a click on a result landing on the colliding page; +- the SDK download and the plugin's (200, both under `/api/__downloads/`), and the install + command's address; +- a missing page, shown as the not-found page. + +Moving the path to `/v2` in the running preview applied the edit (canonical links then carry +`/v2`), while `/api/` went on being served and `/v2/` answered 404, as the restart list says. +Stopping it the way CTRL+C does left no project directory behind. + +**Measured, other builds and runs:** +- `base: './'` writes script URLs as `/./assets/…` (the host's root), CSS links relative to the + page, and a router basepath of `.`. +- Vite dev with `base: '/docs/'`: + - `/docs/` answers 200, `/docs` answers 404 and `/` answers 302 to `/docs/`; + - routing, `/docs/_serverFn/…` page data, the HMR websocket (`ws://…/docs/`) and a live content + edit all work; + - `/docs/__downloads/plugin.zip` comes back as the SPA's HTML, while a bare `/__downloads/…` + answers with the zip, because our middleware runs ahead of Vite's base check. +- In the `/docs/` build: + - pages are written to `dist/client/guides/intro/index.html`, and the host serves that + directory at `/docs`; + - router links, `