Skip to content
Merged
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
6 changes: 3 additions & 3 deletions apps/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --filter leadtype build && bun run pipeline:convert && PORTLESS_PORT=1355 PORTLESS_HTTPS=0 portless run vite dev",
"build": "bun run --filter leadtype build && bun run pipeline:convert && vite build",
"dev": "bun run --filter leadtype build && bun run pipeline:build && PORTLESS_PORT=1355 PORTLESS_HTTPS=0 portless run vite dev",
"build": "bun run --filter leadtype build && bun run pipeline:build && vite build",
"preview": "PORTLESS_PORT=1355 PORTLESS_HTTPS=0 portless run vite preview",
"check-types": "tsgo --noEmit",
"test:e2e": "bun run --filter leadtype build && playwright test",
"test:e2e": "bun run --filter leadtype build && bun run pipeline:build && playwright test",
"convert": "bun run pipeline:convert",
"llm": "bun run pipeline:llm",
"setup:real": "bun run pipeline:setup-real",
Expand Down
32 changes: 30 additions & 2 deletions apps/example/scripts/llm-generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
* consumers.
*/

import { mkdir, writeFile } from "node:fs/promises";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
generateAgentReadabilityArtifacts,
generateLLMFullContextFiles,
generateLlmsTxt,
resolveDocsNavigation,
Expand Down Expand Up @@ -48,6 +49,16 @@ await generateLLMFullContextFiles({
groups: docsConfig.groups,
});

const agentReadability = await generateAgentReadabilityArtifacts({
outDir,
baseUrl,
product: {
name: docsConfig.product.name,
summary: docsConfig.product.summary,
},
groups: docsConfig.groups,
});

// Build the runtime sidebar manifest. Doing this in the build pipeline keeps
// the docs.config.ts as the single source of truth: the same call resolves
// frontmatter membership for the LLM bundles AND for the in-app sidebar.
Expand All @@ -71,5 +82,22 @@ await writeFile(
join(generatedDir, "docs-nav.json"),
`${JSON.stringify(navigation, null, 2)}\n`
);
await writeFile(
join(generatedDir, "agent-readability.json"),
`${JSON.stringify(agentReadability.manifest, null, 2)}\n`
);

// Static copies would be served by Vite/nitro before the middleware runs,
// so the live origin would never make it into <loc> / Sitemap:.
await Promise.all(
[
join(outDir, "sitemap.xml"),
join(outDir, "sitemap.md"),
join(outDir, "robots.txt"),
join(outDir, "docs", "sitemap.xml"),
join(outDir, "docs", "sitemap.md"),
join(outDir, "docs", "robots.txt"),
].map((file) => rm(file, { force: true }))
);

process.stdout.write("LLM files + nav manifest generated\n");
process.stdout.write("LLM files + agent readability manifests generated\n");
66 changes: 66 additions & 0 deletions apps/example/server/middleware/agent-readability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
createAgentMarkdownResponse,
createRobotsTxtResponse,
createSitemapMarkdownResponse,
createSitemapXmlResponse,
} from "leadtype/llm/readability";
import {
defineEventHandler,
getHeaders,
getMethod,
getRequestURL,
} from "nitro/h3";
import {
agentReadabilityManifest,
getRequestOrigin,
readMarkdownFile,
} from "../utils/agent-readability";

export default defineEventHandler(async (event) => {
const method = getMethod(event);
if (method !== "GET" && method !== "HEAD") {
return;
}

const url = getRequestURL(event);
const requestOrigin = getRequestOrigin(event);
const pathname = url.pathname;

switch (pathname) {
case "/sitemap.xml":
case "/docs/sitemap.xml":
return createSitemapXmlResponse({
manifest: agentReadabilityManifest,
requestOrigin,
});
case "/sitemap.md":
case "/docs/sitemap.md":
return createSitemapMarkdownResponse({
manifest: agentReadabilityManifest,
requestOrigin,
});
case "/robots.txt":
return createRobotsTxtResponse({
manifest: agentReadabilityManifest,
requestOrigin,
});
case "/docs/robots.txt":
return createRobotsTxtResponse({
manifest: agentReadabilityManifest,
requestOrigin,
sitemapUrlPath: "/docs/sitemap.xml",
});
default:
break;
}

const response = await createAgentMarkdownResponse({
urlPath: pathname,
method,
headers: getHeaders(event),
manifest: agentReadabilityManifest,
readMarkdownFile,
requestOrigin,
});
return response ?? undefined;
});
61 changes: 61 additions & 0 deletions apps/example/server/utils/agent-readability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type {
AgentReadabilityManifest,
MarkdownMirrorTarget,
} from "leadtype/llm/readability";
import {
getHeader,
getRequestProtocol,
getRequestURL,
type H3Event,
} from "nitro/h3";
import manifestJson from "../../src/generated/agent-readability.json" with {
type: "json",
};

export const agentReadabilityManifest: AgentReadabilityManifest = {
...manifestJson,
version: 1,
};

export function getRequestOrigin(event: H3Event): string | undefined {
const forwardedHost = getHeader(event, "x-forwarded-host")
?.split(",")[0]
?.trim();
const forwardedProto = getHeader(event, "x-forwarded-proto")
?.split(",")[0]
?.trim();
if (forwardedHost) {
const protocol = forwardedProto || getRequestProtocol(event) || "http";
return `${protocol}://${forwardedHost}`;
}
const url = getRequestURL(event);
return url.origin;
Comment on lines +22 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden forwarded-header origin derivation against spoofed hosts/protocols.

Line 23 and Line 26 trust x-forwarded-* directly. If those headers are not strictly sanitized by infrastructure, responses can emit attacker-controlled absolute URLs.

🔐 Suggested hardening
 export function getRequestOrigin(event: H3Event): string | undefined {
+  const requestUrl = getRequestURL(event);
   const forwardedHost = getHeader(event, "x-forwarded-host")
     ?.split(",")[0]
     ?.trim();
   const forwardedProto = getHeader(event, "x-forwarded-proto")
     ?.split(",")[0]
     ?.trim();
   if (forwardedHost) {
-    const protocol = forwardedProto || getRequestProtocol(event) || "http";
-    return `${protocol}://${forwardedHost}`;
+    const protocol =
+      forwardedProto === "https" || forwardedProto === "http"
+        ? forwardedProto
+        : getRequestProtocol(event) ?? "http";
+    try {
+      // Parse to reject malformed injected values.
+      return new URL(`${protocol}://${forwardedHost}`).origin;
+    } catch {
+      return requestUrl.origin;
+    }
   }
-  const url = getRequestURL(event);
-  return url.origin;
+  return requestUrl.origin;
 }

}

function isMissingFileError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ENOTDIR")
);
}

export async function readMarkdownFile(
target: MarkdownMirrorTarget
): Promise<string | null> {
try {
return await readFile(
join(process.cwd(), "public", target.filePath),
"utf8"
);
} catch (error) {
if (isMissingFileError(error)) {
return null;
}

throw error;
}
}
Loading
Loading