From 1db09c92bfd881c469ea193015797c738d3f6f8b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 19:06:25 +0000 Subject: [PATCH 1/6] feat(docs): add /api/fern-docs/get-jwt endpoint for LLM authentication Co-Authored-By: kenny@buildwithfern.com --- .../[domain]/api/fern-docs/get-jwt/route.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/get-jwt/route.ts diff --git a/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/get-jwt/route.ts b/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/get-jwt/route.ts new file mode 100644 index 0000000000..fb8977088f --- /dev/null +++ b/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/get-jwt/route.ts @@ -0,0 +1,87 @@ +import { signFernJWT } from "@fern-api/docs-server/auth/FernJWT"; +import { getDocsUrlMetadata } from "@fern-api/docs-server/getDocsUrlMetadata"; +import { isLocal } from "@fern-api/docs-server/isLocal"; +import { isSelfHosted } from "@fern-api/docs-server/isSelfHosted"; +import { validateApiKeyBelongsToOrg } from "@fern-api/docs-server/venus/validateApiKeyBelongsToOrg"; +import { getDocsDomainEdge } from "@fern-api/docs-server/xfernhost/edge"; +import { getAuthEdgeConfig } from "@fern-docs/edge-config"; +import { type NextRequest, NextResponse } from "next/server"; + +export const maxDuration = 10; + +export async function GET(req: NextRequest): Promise { + if (isLocal()) { + return NextResponse.json({ error: "JWT generation is not accessible in local preview mode" }, { status: 400 }); + } + + if (isSelfHosted()) { + return NextResponse.json( + { error: "JWT generation is not supported in self-hosted environments" }, + { status: 400 } + ); + } + + const domain = getDocsDomainEdge(req); + + const fernApiKey = req.headers.get("FERN_API_KEY"); + if (!fernApiKey) { + return NextResponse.json({ error: "Missing FERN_API_KEY header" }, { status: 401 }); + } + + const metadata = await getDocsUrlMetadata(domain); + const validation = await validateApiKeyBelongsToOrg(fernApiKey, metadata.org); + + if (!validation.valid) { + const status = validation.error?.includes("does not belong") ? 403 : 401; + return NextResponse.json({ error: `Unauthorized: ${validation.error}` }, { status }); + } + + const authConfig = await getAuthEdgeConfig(domain); + if (!authConfig) { + return NextResponse.json( + { error: "No authentication configuration found for this domain" }, + { status: 500 } + ); + } + + const rolesHeader = req.headers.get("ROLES"); + let roles: string[] = []; + if (rolesHeader) { + roles = rolesHeader + .split(",") + .map((role) => role.trim()) + .filter((role) => role.length > 0); + } + + try { + const fern_token = await signFernJWT( + { + roles, + api_key: fernApiKey + }, + { + secret: authConfig.type === "basic_token_verification" ? authConfig.secret : undefined, + issuer: authConfig.issuer + } + ); + + return NextResponse.json( + { + fern_token, + roles + }, + { + status: 200, + headers: { + "Cache-Control": "no-store" + } + } + ); + } catch (error) { + console.error("Error generating JWT:", error); + return NextResponse.json( + { error: "Failed to generate JWT token" }, + { status: 500 } + ); + } +} From 2bf02bd13dcb2592e1852ea25f001d8bb31d430a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 19:12:56 +0000 Subject: [PATCH 2/6] fix(docs): handle oauth2 and sso auth types in get-jwt endpoint, fix formatting Co-Authored-By: kenny@buildwithfern.com --- .../[domain]/api/fern-docs/get-jwt/route.ts | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/get-jwt/route.ts b/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/get-jwt/route.ts index fb8977088f..60d98f64d1 100644 --- a/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/get-jwt/route.ts +++ b/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/get-jwt/route.ts @@ -38,8 +38,28 @@ export async function GET(req: NextRequest): Promise { const authConfig = await getAuthEdgeConfig(domain); if (!authConfig) { + return NextResponse.json({ error: "No authentication configuration found for this domain" }, { status: 500 }); + } + + if (authConfig.type === "sso" && authConfig.partner === "workos") { return NextResponse.json( - { error: "No authentication configuration found for this domain" }, + { + error: "SSO/WorkOS authentication is not supported by this endpoint. This endpoint is for API key-based authentication only." + }, + { status: 400 } + ); + } + + const secret = + authConfig.type === "basic_token_verification" + ? authConfig.secret + : authConfig.type === "oauth2" + ? process.env.OAUTH_JWT_SECRET + : undefined; + + if (authConfig.type === "oauth2" && !secret) { + return NextResponse.json( + { error: "Missing OAUTH_JWT_SECRET configuration for oauth2 authentication" }, { status: 500 } ); } @@ -60,7 +80,7 @@ export async function GET(req: NextRequest): Promise { api_key: fernApiKey }, { - secret: authConfig.type === "basic_token_verification" ? authConfig.secret : undefined, + secret, issuer: authConfig.issuer } ); @@ -79,9 +99,6 @@ export async function GET(req: NextRequest): Promise { ); } catch (error) { console.error("Error generating JWT:", error); - return NextResponse.json( - { error: "Failed to generate JWT token" }, - { status: 500 } - ); + return NextResponse.json({ error: "Failed to generate JWT token" }, { status: 500 }); } } From 3befc2fcba2eeda3c4bb480af4cdd0bb098dfe53 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 19:24:51 +0000 Subject: [PATCH 3/6] fix(docs): safely access issuer property based on auth config type Co-Authored-By: kenny@buildwithfern.com --- .../app/[host]/[domain]/api/fern-docs/get-jwt/route.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/get-jwt/route.ts b/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/get-jwt/route.ts index 60d98f64d1..fe5ddd2fd8 100644 --- a/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/get-jwt/route.ts +++ b/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/get-jwt/route.ts @@ -73,6 +73,13 @@ export async function GET(req: NextRequest): Promise { .filter((role) => role.length > 0); } + let issuer: string | undefined; + if (authConfig.type === "basic_token_verification") { + issuer = authConfig.issuer; + } else if (authConfig.type === "oauth2" && "issuer" in authConfig) { + issuer = authConfig.issuer; + } + try { const fern_token = await signFernJWT( { @@ -81,7 +88,7 @@ export async function GET(req: NextRequest): Promise { }, { secret, - issuer: authConfig.issuer + issuer } ); From 9a8ad237c9fb4182da2ec3dfac2db1e1881b9aac Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:17:09 +0000 Subject: [PATCH 4/6] feat(docs): add FERN_TOKEN header support to llms.txt, llms-full.txt, and markdown routes Co-Authored-By: kenny@buildwithfern.com --- .../app/[host]/[domain]/api/fern-docs/llms-full.txt/route.ts | 2 +- .../src/app/[host]/[domain]/api/fern-docs/llms.txt/route.ts | 2 +- .../src/app/[host]/[domain]/api/fern-docs/markdown/route.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/llms-full.txt/route.ts b/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/llms-full.txt/route.ts index 22069b9fc1..e66ff098d9 100644 --- a/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/llms-full.txt/route.ts +++ b/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/llms-full.txt/route.ts @@ -20,7 +20,7 @@ export async function GET( const path = slugToHref(req.nextUrl.searchParams.get("slug") ?? ""); - const fernToken = (await cookies()).get(COOKIE_FERN_TOKEN)?.value; + const fernToken = req.headers.get("FERN_TOKEN") ?? (await cookies()).get(COOKIE_FERN_TOKEN)?.value; const userAgent = req.headers.get("user-agent"); const acceptHeader = req.headers.get("accept"); diff --git a/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/llms.txt/route.ts b/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/llms.txt/route.ts index 877375868b..2581660875 100644 --- a/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/llms.txt/route.ts +++ b/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/llms.txt/route.ts @@ -48,7 +48,7 @@ export async function GET( }); } - const fernToken = (await cookies()).get(COOKIE_FERN_TOKEN)?.value; + const fernToken = req.headers.get("FERN_TOKEN") ?? (await cookies()).get(COOKIE_FERN_TOKEN)?.value; const path = slugToHref(req.nextUrl.searchParams.get("slug") ?? ""); diff --git a/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/markdown/route.ts b/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/markdown/route.ts index 3d050bcb42..fed7639d95 100644 --- a/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/markdown/route.ts +++ b/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/markdown/route.ts @@ -27,7 +27,7 @@ export async function GET( const { host, domain } = await props.params; - const fernToken = (await cookies()).get(COOKIE_FERN_TOKEN)?.value; + const fernToken = req.headers.get("FERN_TOKEN") ?? (await cookies()).get(COOKIE_FERN_TOKEN)?.value; const path = req.nextUrl.pathname; const slug = path.replace(MARKDOWN_PATTERN, ""); From 53837f4e268ad4e4303a8319a46bf0dd30c7c383 Mon Sep 17 00:00:00 2001 From: Kenny Derek Date: Tue, 4 Nov 2025 11:21:17 -0500 Subject: [PATCH 5/6] reroute serving llms.txt --- packages/fern-docs/bundle/src/middleware.ts | 27 ++++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/fern-docs/bundle/src/middleware.ts b/packages/fern-docs/bundle/src/middleware.ts index 13c63dd6f2..16774805d2 100644 --- a/packages/fern-docs/bundle/src/middleware.ts +++ b/packages/fern-docs/bundle/src/middleware.ts @@ -161,10 +161,23 @@ export const middleware: NextMiddleware = async (request) => { return rewrite(withDomain(withoutBasepath("/api/fern-docs/"))); } + /** + * If Accept header contains text/plain or text/markdown, + * serve the llms.txt version instead + */ + const acceptHeader = request.headers.get("accept"); + const shouldServeLlmsTxt = + acceptHeader && (acceptHeader.includes("text/plain") || acceptHeader.includes("text/markdown")); + /** * Rewrite llms.txt */ - if (pathname.endsWith("/llms.txt") || pathname.endsWith("/llms-full.txt") || pathname.match(MARKDOWN_PATTERN)) { + if ( + shouldServeLlmsTxt || + pathname.endsWith("/llms.txt") || + pathname.endsWith("/llms-full.txt") || + pathname.match(MARKDOWN_PATTERN) + ) { const { getAuthState } = await createGetAuthStateEdge(request, (token) => { newToken = token; }); @@ -174,7 +187,7 @@ export const middleware: NextMiddleware = async (request) => { ? `authed:${[...(authState.user.roles ?? ["no_role"])].sort().join(",")}` : "unauthed:everyone"; - if (pathname.endsWith("/llms.txt")) { + if (shouldServeLlmsTxt || pathname.endsWith("/llms.txt")) { const slug = removeLeadingSlash(withoutEnding(/\/llms\.txt$/)); return rewrite(withDomain("/api/fern-docs/llms.txt"), { slug, @@ -226,16 +239,6 @@ export const middleware: NextMiddleware = async (request) => { return rewrite(withDomain("/api/fern-docs/changelog"), { format, slug }); } - /** - * Content negotiation: If Accept header contains text/plain or text/markdown, - * serve the llms.txt version instead - */ - const acceptHeader = request.headers.get("accept"); - if (acceptHeader && (acceptHeader.includes("text/plain") || acceptHeader.includes("text/markdown"))) { - const slug = removeLeadingSlash(pathname); - return rewrite(withDomain("/api/fern-docs/llms.txt"), { slug }); - } - /** * At this point, conform the trailing slash setting or else redirect */ From 209c5ab247e623041d27af5778af8cf15cb436b2 Mon Sep 17 00:00:00 2001 From: Kenny Derek Date: Tue, 4 Nov 2025 11:34:45 -0500 Subject: [PATCH 6/6] better --- packages/fern-docs/bundle/src/middleware.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/fern-docs/bundle/src/middleware.ts b/packages/fern-docs/bundle/src/middleware.ts index 16774805d2..a0279173ea 100644 --- a/packages/fern-docs/bundle/src/middleware.ts +++ b/packages/fern-docs/bundle/src/middleware.ts @@ -167,7 +167,11 @@ export const middleware: NextMiddleware = async (request) => { */ const acceptHeader = request.headers.get("accept"); const shouldServeLlmsTxt = - acceptHeader && (acceptHeader.includes("text/plain") || acceptHeader.includes("text/markdown")); + acceptHeader && + (acceptHeader.includes("text/plain") || acceptHeader.includes("text/markdown")) && + !pathname.endsWith("/llms.txt") && + !pathname.endsWith("/llms-full.txt") && + !pathname.match(MARKDOWN_PATTERN); /** * Rewrite llms.txt @@ -187,7 +191,13 @@ export const middleware: NextMiddleware = async (request) => { ? `authed:${[...(authState.user.roles ?? ["no_role"])].sort().join(",")}` : "unauthed:everyone"; - if (shouldServeLlmsTxt || pathname.endsWith("/llms.txt")) { + if (shouldServeLlmsTxt) { + const slug = removeLeadingSlash(pathname); + return rewrite(withDomain("/api/fern-docs/llms.txt"), { + slug, + authed: rolesValue + }); + } else if (pathname.endsWith("/llms.txt")) { const slug = removeLeadingSlash(withoutEnding(/\/llms\.txt$/)); return rewrite(withDomain("/api/fern-docs/llms.txt"), { slug,