Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 71 additions & 10 deletions packages/commons/docs-loader/src/readonly-docs-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,22 +553,58 @@ const getRoot = async (
authConfig: AuthEdgeConfig | undefined,
cacheConfig: Required<CacheConfig>
) => {
const authCacheKey = authState.authed
? `authed:${authState.user?.roles?.sort().join(",") || "noroles"}`
: "anon";
console.log(`\n💾 [GET_ROOT] Cache MISS for getRoot - generating fresh root`);
console.log(` 🔑 Cache key: [${domainKey}, ${cacheConfig.cacheKeySuffix || '""'}, ${authCacheKey}]`);
console.log(` 👤 authState.authed: ${authState.authed}`);
console.log(` ⚙️ authConfig exists: ${!!authConfig}`);

console.log(`\n🌲 [GET_ROOT] Fetching unpruned root from unsafe_getRootCached`);
let root = await unsafe_getRootCached(cacheConfig)(domainKey);
console.log(` ✅ Got unpruned root with id: ${root.id}`);

if (authConfig) {
console.log(`\n🔀 [GET_ROOT] Calling pruneWithAuthState`);
console.log(` 👤 authState.authed: ${authState.authed}`);
console.log(` 👥 authState.user.roles: ${authState.authed ? JSON.stringify(authState.user?.roles || []) : 'N/A'}`);
console.log(` 📋 authConfig.allowlist: ${authConfig.allowlist?.length || 0}`);
console.log(` 📋 authConfig.denylist: ${authConfig.denylist?.length || 0}`);
console.log(` 📋 authConfig.anonymous: ${authConfig.anonymous?.length || 0}`);

root = pruneWithAuthState(authState, authConfig, root);

console.log(` ✅ [GET_ROOT] Finished pruning, root id: ${root.id}`);
} else {
console.log(`\n⚠️ [GET_ROOT] No authConfig - skipping pruneWithAuthState`);
}

FernNavigation.utils.mutableUpdatePointsTo(root);
console.log(`\n✅ [GET_ROOT] Returning pruned root\n`);
return root;
};

const getRootCached = (cacheConfig: Required<CacheConfig>) =>
cache(async (domainKey: string, authState: AuthState, authConfig: AuthEdgeConfig | undefined) => {
return await unstable_cache(
const authCacheKey = authState.authed
? `authed:${authState.user?.roles?.sort().join(",") || "noroles"}`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
? `authed:${authState.user?.roles?.sort().join(",") || "noroles"}`
? `authed:${authState.user?.roles?.slice().sort().join(",") || "noroles"}`

The code mutates the original authState.user.roles array in-place by calling .sort() directly on it. This should create a copy before sorting to avoid mutating shared state.

View Details

Analysis

Array mutation bug in cache key generation mutates authState.user.roles

What fails: getRootCached() in packages/commons/docs-loader/src/readonly-docs-loader.ts:567 mutates authState.user.roles array when generating cache keys by calling .sort() directly on the original array

How to reproduce:

const authState = { authed: true, user: { roles: ["admin", "user", "editor"] } };
// Line 567: authState.user?.roles?.sort().join(",")
console.log(authState.user.roles); // ["admin", "editor", "user"] - mutated!

Result: The same mutated authState object is passed to pruneWithAuthState() for authorization decisions, causing authorization logic to see sorted roles instead of original JWT order

Expected: Authorization should use original role order from JWT; cache key generation should not mutate shared state

Fix: Create array copy before sorting: authState.user?.roles?.slice().sort().join(",")

: "anon";

console.log(`\n🔍 [PRUNE_CACHE_CHECK] Checking cache for getRoot`);
console.log(` 🔑 Cache key: [${domainKey}, ${cacheConfig.cacheKeySuffix || '""'}, ${authCacheKey}]`);
console.log(` 👤 authState.authed: ${authState.authed}`);

const result = await unstable_cache(
(domainKey: string, authState: AuthState, authConfig: AuthEdgeConfig | undefined) =>
getRoot(domainKey, authState, authConfig, cacheConfig),
[domainKey, cacheConfig.cacheKeySuffix],
{ tags: [domainKey, "getRoot"] }
)(domainKey, authState, authConfig);

console.log(`\n✅ [PRUNE_CACHE_RETURN] Returning root (cache hit if no CACHE_MISS log above)\n`);

return result;
});

const getNavigationNode = (cacheConfig: Required<CacheConfig>) =>
Expand Down Expand Up @@ -1111,25 +1147,50 @@ export const createCachedDocsLoader = async (
await clearKvCache(domainKey);
}

const authConfig = options?.skipAuth ? Promise.resolve(undefined) : getAuthConfig(domainKey);
const authConfig = options?.skipAuth
? (console.log(`\n⚙️ [AUTH_CONFIG] Using skipAuth mode - no authConfig`), Promise.resolve(undefined))
: (async () => {
console.log(`\n⚙️ [AUTH_CONFIG] Fetching authConfig for domainKey: ${domainKey}`);
const config = await getAuthConfig(domainKey);
console.log(` ✅ [AUTH_CONFIG] Fetched authConfig`);
console.log(` 📋 allowlist: ${config?.allowlist?.length || 0} paths`);
console.log(` 📋 denylist: ${config?.denylist?.length || 0} paths`);
console.log(` 📋 anonymous: ${config?.anonymous?.length || 0} paths`);
console.log(` 🔐 auth type: ${config?.type || 'none'}`);
return config;
})();
const metadata = getMetadata(config)(withoutStaging(domainKey));

const getAuthState = options?.skipAuth
? async (_pathname?: string) => ({
authed: true as const,
ok: true as const,
user: {},
partner: "custom" as const
})
? async (_pathname?: string) => {
console.log(`\n🔓 [GET_AUTH_STATE] Using skipAuth mode - returning authed=true`);
return {
authed: true as const,
ok: true as const,
user: {},
partner: "custom" as const
};
}
: cache(async (pathname?: string) => {
console.log(`\n🔐 [GET_AUTH_STATE] Fetching auth state for domainKey: ${domainKey}`);
console.log(` 🎫 fern_token present: ${!!fern_token}`);
console.log(` 📍 pathname: ${pathname || 'undefined'}`);

const { getAuthState } = await createGetAuthState(
host,
domainKey,
fern_token,
await authConfig,
await metadata
);
return await getAuthState(pathname);
const authState = await getAuthState(pathname);

console.log(` ✅ [GET_AUTH_STATE] Result - authed: ${authState.authed}`);
if (authState.authed) {
console.log(` 👥 roles: ${JSON.stringify(authState.user?.roles || [])}`);
}

return authState;
});

return {
Expand All @@ -1156,7 +1217,7 @@ export const createCachedDocsLoader = async (
{ tags: [domainKey, "endpointByLocator"] }
)
),
getRoot: async () => getRootCached(config)(domainKey, await getAuthState(), await authConfig),
getRoot: async () => getRoot(domainKey, await getAuthState(), await authConfig, config),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
getRoot: async () => getRoot(domainKey, await getAuthState(), await authConfig, config),
getRoot: async () => getRootCached(config)(domainKey, await getAuthState(), await authConfig),

The change from calling getRootCached() to calling getRoot() directly removes critical caching layers from the RBAC pruning operation, causing a significant performance regression where the expensive tree pruning will be re-executed on every request instead of being cached.

View Details

Analysis

Performance regression: RBAC pruning bypasses caching in getRoot() call

What fails: The change from getRootCached() to direct getRoot() call in readonly-docs-loader.ts line 1178 removes caching layers, causing expensive RBAC tree pruning to execute on every request instead of being cached.

How to reproduce:

// Multiple calls to the same domain with identical auth state
const loader = createCachedDocsLoader(config);
await loader.getRoot(); // First call - executes pruneWithAuthState()
await loader.getRoot(); // Second call - executes pruneWithAuthState() AGAIN (should be cached)

Result: pruneWithAuthState() runs fresh on every request, traversing entire navigation tree and applying RBAC rules repeatedly

Expected: RBAC pruning should be cached per (domain + auth state) combination using both React's cache() (request-level deduplication) and Next.js unstable_cache() (persistent caching) as provided by getRootCached()

Technical details:

  • getRootCached() wraps the entire getRoot() operation (base tree + pruning) with dual caching layers
  • Direct getRoot() call only caches base tree via unsafe_getRootCached(), but pruning runs uncached
  • Performance impact scales with documentation tree size (O(n) nodes per request vs O(1) cached)

getNavigationNode: async (id: string) =>
getNavigationNode(config)(domainKey, id, await getAuthState(), await authConfig),
unsafe_getFullRoot: () => unsafe_getRootCached(config)(domainKey),
Expand Down
12 changes: 12 additions & 0 deletions packages/commons/docs-server/src/loadDocsDefinitionFromS3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl as getUncachedSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { FdrAPI } from "@fern-api/fdr-sdk";
import { getS3KeyForV1DocsDefinition } from "@fern-api/fdr-sdk/docs";
import { writeFile } from "fs/promises";
import { cache } from "react";

import { isLocal } from "./isLocal";
Expand Down Expand Up @@ -57,6 +58,17 @@ export const loadDocsDefinitionFromS3 = cache(
if (response.ok) {
console.debug("Successfully loaded docs definition from S3: ", signedUrl);
const json = await response.json();

// Write to local file for debugging
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `/tmp/docs-definition-${cleanDomain.replace(/[^a-zA-Z0-9]/g, '-')}-${timestamp}.json`;
await writeFile(filename, JSON.stringify(json, null, 2));
console.log(`💾 [DEBUG] Saved docs definition to: ${filename}`);
} catch (writeError) {
console.error("Failed to write docs definition to file:", writeError);
Comment on lines +62 to +69
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Write to local file for debugging
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `/tmp/docs-definition-${cleanDomain.replace(/[^a-zA-Z0-9]/g, '-')}-${timestamp}.json`;
await writeFile(filename, JSON.stringify(json, null, 2));
console.log(`💾 [DEBUG] Saved docs definition to: ${filename}`);
} catch (writeError) {
console.error("Failed to write docs definition to file:", writeError);
// Write to local file for debugging (development only)
if (process.env.NODE_ENV === "development") {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `/tmp/docs-definition-${cleanDomain.replace(/[^a-zA-Z0-9]/g, '-')}-${timestamp}.json`;
await writeFile(filename, JSON.stringify(json, null, 2));
console.log(`💾 [DEBUG] Saved docs definition to: ${filename}`);
} catch (writeError) {
console.error("Failed to write docs definition to file:", writeError);
}

Debug code writes the full docs definition JSON to /tmp/ on every S3 load, which can cause disk space issues and I/O performance problems in production.

View Details

Analysis

Debug code writes docs definition to /tmp/ in production without environment check

What fails: loadDocsDefinitionFromS3() in packages/commons/docs-server/src/loadDocsDefinitionFromS3.ts writes JSON files to /tmp/ on every successful S3 load without checking NODE_ENV, causing disk space consumption in production

How to reproduce:

# Call loadDocsDefinitionFromS3 in production environment
NODE_ENV=production node -e "
const fs = require('fs').promises;
// Simulate the exact debug code from lines 62-70
(async () => {
  const json = {test: 'large docs data'};
  const cleanDomain = 'example.com';
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
  const filename = \`/tmp/docs-definition-\ 

Result: Creates files like /tmp/docs-definition-example-com-2025-11-03T22-43-57-118Z.json (0.4+ MB each) that accumulate indefinitely

Expected: Debug file writing should only occur in development environment, similar to other conditional debug code in the codebase (e.g. packages/commons/docs-server/src/analytics/posthog.ts:42)

}

return json as FdrAPI.docs.v2.read.LoadDocsForUrlResponse;
}
throw new Error(
Expand Down
10 changes: 10 additions & 0 deletions packages/commons/docs-server/src/withRbac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ function withAllowed<T extends (...args: any[]) => Gate>(predicate: T): (...args
}

export function pruneWithBasicTokenAnonymous(auth: PathnameViewerRules, node: RootNode): RootNode {
console.log(`\n👤 [PRUNE_ANONYMOUS] Starting pruning for ANONYMOUS user`);
console.log(` 📋 Auth rules - denylist: ${auth.denylist?.length || 0}, allowlist: ${auth.allowlist?.length || 0}, anonymous: ${auth.anonymous?.length || 0}`);

const result = Pruner.from(node)
// mark nodes that are authed
.authed(withDenied(withBasicTokenAnonymousCheck(auth)))
Expand All @@ -86,6 +89,7 @@ export function pruneWithBasicTokenAnonymous(auth: PathnameViewerRules, node: Ro
throw new Error("Failed to prune navigation tree");
}

console.log(`\n✅ [PRUNE_ANONYMOUS] Pruning complete for anonymous user\n`);
return result;
}

Expand All @@ -107,6 +111,10 @@ export function matchRoles(roles: string[] | "anonymous", filters: string[][]):
}

export function pruneWithBasicTokenAuthed(auth: PathnameViewerRules, node: RootNode, roles: string[] = []): RootNode {
console.log(`\n🔑 [PRUNE_AUTHENTICATED] Starting pruning for AUTHENTICATED user`);
console.log(` 👥 User roles: ${JSON.stringify(roles)}`);
console.log(` 📋 Auth rules - denylist: ${auth.denylist?.length || 0}, allowlist: ${auth.allowlist?.length || 0}, anonymous: ${auth.anonymous?.length || 0}`);

const result = Pruner.from(node)
// apply rbac
.keep(withAllowed(rbacViewGate(roles, true)))
Expand All @@ -121,10 +129,12 @@ export function pruneWithBasicTokenAuthed(auth: PathnameViewerRules, node: RootN
throw new Error("Failed to prune navigation tree");
}

console.log(`\n✅ [PRUNE_AUTHENTICATED] Pruning complete for authenticated user\n`);
return result;
}

export function pruneWithAuthState(authState: AuthState, authConfig: PathnameViewerRules, node: RootNode): RootNode {
console.log(`\n🚀 [PRUNE_WITH_AUTH_STATE] authState.authed = ${authState.authed}`);
return authState.authed
? pruneWithBasicTokenAuthed(authConfig, node, authState.user.roles)
: pruneWithBasicTokenAnonymous(authConfig, node);
Expand Down
23 changes: 22 additions & 1 deletion packages/fdr-sdk/src/navigation/utils/pruneNavigationTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,24 @@ export class Pruner<ROOT extends FernNavigation.NavigationNode> {

const unauthedParents = new Set<FernNavigation.NavigationNodeParent>();

console.log(`\n🔐 [PRUNER_AUTHED] Starting authed pruning pass`);
let authedCount = 0;
let unauthedCount = 0;

// step 1. mark nodes as authed if the match the predicate
FernNavigation.traverseBF(this.tree, (node, parents) => {
if (FernNavigation.hasMetadata(node)) {
node.authed = predicate(node, parents) ? true : undefined;
const predicateResult = predicate(node, parents);
node.authed = predicateResult ? true : undefined;

if (predicateResult) {
authedCount++;
// console.log(` 🔒 [PRUNER_AUTHED] Setting node.authed=true - title: "${node.title}", slug: ${node.slug}, id: ${node.id}`);
} else {
unauthedCount++;
// console.log(` ✅ [PRUNER_AUTHED] Setting node.authed=undefined - title: "${node.title}", slug: ${node.slug}, id: ${node.id}`);
}

if (!node.authed) {
for (const parent of parents) {
unauthedParents.add(parent);
Expand All @@ -69,17 +83,24 @@ export class Pruner<ROOT extends FernNavigation.NavigationNode> {
}
});

console.log(`\n📊 [PRUNER_AUTHED] Step 1 complete - ${authedCount} nodes marked as authed, ${unauthedCount} nodes unmarked\n`);

// step 2. remove auth flag on edge nodes that contain children that are unauthed
// this helps us improve the rendering of the navigation tree and avoid deleting nodes that contain
// children that can be visited.
// Note: sections with overview pages are skipped here because the pages need to be filtered out
// by the mutableDeleteChild function, which relies on the authed flag in a separate pass.
let edgeNodeCount = 0;
for (const parent of unauthedParents) {
if (FernNavigation.hasMetadata(parent) && !FernNavigation.isPage(parent)) {
console.log(` 🌳 [PRUNER_AUTHED] Clearing authed flag on edge node parent - title: "${parent.title}", slug: ${parent.slug}, id: ${parent.id}`);
parent.authed = undefined;
edgeNodeCount++;
}
}

console.log(`\n📊 [PRUNER_AUTHED] Step 2 complete - ${edgeNodeCount} edge node parents cleared\n`);

return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import * as FernNavigation from "@fern-api/fdr-sdk/navigation";
import { CONTINUE, SKIP } from "@fern-api/fdr-sdk/traversers";
import { isNonNullish, withDefaultProtocol } from "@fern-api/ui-core-utils";
import { getAuthEdgeConfig, getEdgeFlags } from "@fern-docs/edge-config";
import { unstable_cacheTag } from "next/cache";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { type NextRequest, NextResponse } from "next/server";
Expand Down Expand Up @@ -65,7 +64,7 @@ export async function GET(
let markdownProcessingMs = 0;

try {
const { content: streamedContent, timingStats } = await getLlmsTxtStreaming(
const { timingStats } = await getLlmsTxtStreaming(
host,
domain,
path,
Expand Down Expand Up @@ -123,6 +122,17 @@ async function getLlmsTxtStreaming(
): Promise<{ content: string; timingStats: any }> {
const loader = await createCachedDocsLoader(host, domain, fernToken);

// Debug: Check auth state
const authConfig = await loader.getAuthConfig();
const authState = await loader.getAuthState?.();
console.log(`\n====== [LLMS_TXT_REQUEST:${domain}] ======`);
console.log(`📝 fernToken present: ${!!fernToken}`);
console.log(`⚙️ authConfig exists: ${!!authConfig}`);
console.log(`👤 authState.authed: ${authState?.authed}`);
console.log(`🎭 authState.user.roles: ${authState?.authed ? JSON.stringify(authState.user?.roles || []) : 'N/A (not authed)'}`);
console.log(`🔑 authCacheKey would be: ${authState?.authed ? `authed:${authState.user?.roles?.sort().join(",") || "noroles"}` : "anon"}`);
console.log(`=======================================\n`);

// Time the root retrieval and section root
const rootStartTime = performance.now();
const root = getSectionRoot(await loader.getRoot(), path);
Expand Down Expand Up @@ -150,7 +160,20 @@ async function getLlmsTxtStreaming(
}[] = [];

const landingPage = getLandingPage(root);
const markdown = landingPage != null ? await getMarkdownForPath(landingPage, loader) : undefined;

// Check if landing page is authed or hidden before including its markdown
const shouldIncludeLandingPage =
landingPage != null &&
(!FernNavigation.hasMetadata(landingPage) || (!landingPage.authed && !landingPage.hidden));

const markdown = shouldIncludeLandingPage ? await getMarkdownForPath(landingPage, loader) : undefined;

if (landingPage?.authed) {
console.log(`🔒 [LLMS_TXT_SKIP:${domain}] AUTHED landing page - title: "${landingPage.title}", id: ${landingPage.id}`);
}
if (landingPage?.hidden) {
console.log(`🚫 [LLMS_TXT_SKIP:${domain}] HIDDEN landing page - title: "${landingPage.title}", id: ${landingPage.id}`);
}

const header = markdown?.content ?? `# ${root.title}`;
onChunk(header + "\n\n");
Expand All @@ -166,7 +189,12 @@ async function getLlmsTxtStreaming(
// if the node is hidden or authed, don't include it in the list
// TODO: include "hidden" nodes in `llms-full.txt`
if (FernNavigation.hasMetadata(node)) {
if (node.hidden || node.authed) {
if (node.hidden) {
console.log(`🚫 [LLMS_TXT_SKIP:${domain}] HIDDEN node - title: "${node.title}", slug: ${node.slug}, id: ${node.id}`);
return SKIP;
}
if (node.authed) {
console.log(`🔒 [LLMS_TXT_SKIP:${domain}] AUTHED node - title: "${node.title}", slug: ${node.slug}, id: ${node.id}, authed: ${node.authed}`);
return SKIP;
}
}
Expand Down Expand Up @@ -206,6 +234,11 @@ async function getLlmsTxtStreaming(
return CONTINUE;
});

console.log(`\n📊 [LLMS_TXT_SUMMARY:${domain}] Traversal complete`);
console.log(` 📄 Pages found: ${pageInfos.length}`);
console.log(` 🔌 API endpoints found: ${endpointPageInfos.length}`);
console.log(` ⏱️ Root retrieval: ${(rootEndTime - rootStartTime).toFixed(2)}ms\n`);

const markdownStartTime = performance.now();

if (pageInfos.length > 0) {
Expand Down
30 changes: 29 additions & 1 deletion packages/fern-docs/edge-config/src/getAuthEdgeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,45 @@ export async function getApiKeyInjectionDemoConfig(currentDomain: string): Promi
}

async function getRecord(currentDomain: string, key: string): Promise<AuthEdgeConfig | undefined> {
console.log(`\n🌐 [EDGE_CONFIG] Fetching record from edge config`);
console.log(` 🔑 key: "${key}"`);
console.log(` 🌍 currentDomain: "${currentDomain}"`);
console.log(` 🌍 withoutStaging: "${withoutStaging(currentDomain)}"`);

const domainToTokenConfigMap = await getEdge<Record<string, any>>(key);

if (!domainToTokenConfigMap) {
console.log(` ❌ [EDGE_CONFIG] No data found for key "${key}"`);
return;
}

console.log(` ✅ [EDGE_CONFIG] Found edge config data with ${Object.keys(domainToTokenConfigMap).length} domains`);
console.log(` 🔍 Looking up: "${currentDomain}"`);

const toRet = domainToTokenConfigMap?.[currentDomain] ?? domainToTokenConfigMap?.[withoutStaging(currentDomain)];

if (toRet != null) {
console.log(` ✅ [EDGE_CONFIG] Found config for domain`);
console.log(` 🔍 Raw config keys: ${Object.keys(toRet).join(", ")}`);

const config = AuthEdgeConfigSchema.safeParse(toRet);
// if the config is present, it should be valid.
// if it's malformed, custom auth for this domain will not work and may leak docs to the public.
if (!config.success) {
console.error(`Could not parse AuthEdgeConfigSchema for ${currentDomain}`, config.error);
console.error(` ❌ [EDGE_CONFIG] Could not parse AuthEdgeConfigSchema for ${currentDomain}`, config.error);
// TODO: sentry
return;
}

console.log(` ✅ [EDGE_CONFIG] Successfully parsed AuthEdgeConfig`);
console.log(` 🔐 type: ${config.data.type}`);
console.log(` 📋 allowlist: ${config.data.allowlist?.length || 0} paths`);
console.log(` 📋 denylist: ${config.data.denylist?.length || 0} paths`);
console.log(` 📋 anonymous: ${config.data.anonymous?.length || 0} paths`);

return config.data;
}

console.log(` ❌ [EDGE_CONFIG] No config found for domain "${currentDomain}" or "${withoutStaging(currentDomain)}"`);
return;
}
Loading