Skip to content
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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 });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") ?? "");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
Expand Down
37 changes: 25 additions & 12 deletions packages/fern-docs/bundle/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand All @@ -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,
Expand Down Expand Up @@ -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
*/
Expand Down