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..fe5ddd2fd8 --- /dev/null +++ b/packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/get-jwt/route.ts @@ -0,0 +1,111 @@ +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 }); + } + + if (authConfig.type === "sso" && authConfig.partner === "workos") { + return NextResponse.json( + { + 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 } + ); + } + + const rolesHeader = req.headers.get("ROLES"); + let roles: string[] = []; + if (rolesHeader) { + roles = rolesHeader + .split(",") + .map((role) => role.trim()) + .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( + { + roles, + api_key: fernApiKey + }, + { + secret, + 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 }); + } +} 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 3e4865218e..8943ccb14f 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 8e2903f962..a60aecd859 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, ""); diff --git a/packages/fern-docs/bundle/src/middleware.ts b/packages/fern-docs/bundle/src/middleware.ts index 13c63dd6f2..a0279173ea 100644 --- a/packages/fern-docs/bundle/src/middleware.ts +++ b/packages/fern-docs/bundle/src/middleware.ts @@ -161,10 +161,27 @@ 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")) && + !pathname.endsWith("/llms.txt") && + !pathname.endsWith("/llms-full.txt") && + !pathname.match(MARKDOWN_PATTERN); + /** * 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 +191,13 @@ export const middleware: NextMiddleware = async (request) => { ? `authed:${[...(authState.user.roles ?? ["no_role"])].sort().join(",")}` : "unauthed:everyone"; - if (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, @@ -226,16 +249,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 */