-
Notifications
You must be signed in to change notification settings - Fork 0
fix: docs search, and allow scalar to send requests to api.dw #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0c72ade
35cfc0a
7606da8
6022849
ead0ac3
bcc8356
6dea9a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| [] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,12 @@ import { withCspNonce } from "@/lib/scalar-api-reference"; | |
| export const GET = withCspNonce( | ||
| ApiReference({ | ||
| url: "/api/control-layer-openapi", | ||
| // Scalar loads its default web fonts from Google Fonts for BOTH the | ||
| // Inference and Control Layer references — independent of the Test Request | ||
| // button (hidden here) — and our `font-src 'self' data:` CSP blocks them. | ||
| // Use the system font stack instead of allowlisting an external font host. | ||
| // Keep this even though hideTestRequestButton is true. | ||
| withDefaultFonts: false, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: The comment references Why it matters: A future maintainer might assume Control Layer doesn't need Suggested fix: Expand the comment slightly: // Scalar loads its default web fonts from Google Fonts for both Inference and
// Control Layer API references, which our `font-src 'self' data:` CSP blocks.
// Use the system font stack instead of allowlisting an external font host.
withDefaultFonts: false, |
||
| metaData: { | ||
| title: "API Reference | Control Layer | Doubleword Docs", | ||
| description: "Complete API reference for the Doubleword Control Layer API", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,15 @@ | ||
| import { readFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
| import type { DocSearchIndexItem } from "@/sanity/types"; | ||
|
|
||
| let cached: DocSearchIndexItem[] | null = null; | ||
| // Generated at build time by scripts/build-search-index.mjs, which the `build` | ||
| // script runs before `next build`. We import it STATICALLY (rather than reading | ||
| // it from disk at runtime) so webpack inlines the data into the /api/search | ||
| // function bundle. Runtime fs reads of a generated file are unreliable on | ||
| // Vercel — files in public/ are stripped from the function, traced files don't | ||
| // always land where process.cwd() expects, and an edge runtime has no fs at | ||
| // all. A static import sidesteps all of that. The committed file is an empty | ||
| // `[]` placeholder so dev / typecheck / lint resolve the module; the real build | ||
| // overwrites it before webpack compiles. | ||
| import searchIndex from "../../data/search-index.json"; | ||
|
|
||
| export function loadSearchIndex(): DocSearchIndexItem[] { | ||
| if (cached) return cached; | ||
| const filePath = join(process.cwd(), "public", "search-index.json"); | ||
| const data: DocSearchIndexItem[] = JSON.parse(readFileSync(filePath, "utf-8")); | ||
| cached = data; | ||
| return data; | ||
| return searchIndex as DocSearchIndexItem[]; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import {describe, expect, it} from "vitest"; | ||
| import {stripMarkdown, searchDocs} from "./search"; | ||
| import type {DocSearchIndexItem} from "@/sanity/types"; | ||
|
|
||
| describe("stripMarkdown", () => { | ||
| it("strips markdown from strings", () => { | ||
| expect(stripMarkdown("# Title\n`code` **bold**")).toBe("Title code bold"); | ||
| }); | ||
|
|
||
| it("returns '' for non-string input instead of throwing", () => { | ||
| // Sanity bodies are Portable Text (arrays). Before the guard, this threw | ||
| // `e.replace is not a function` and 500'd the whole /api/search request. | ||
| expect(stripMarkdown([{_type: "block"}] as unknown as string)).toBe(""); | ||
| expect(stripMarkdown(undefined as unknown as string)).toBe(""); | ||
| expect(stripMarkdown({} as unknown as string)).toBe(""); | ||
| }); | ||
| }); | ||
|
|
||
| describe("searchDocs", () => { | ||
| it("does not throw when a doc body is Portable Text (non-string)", () => { | ||
| const docs = [ | ||
| { | ||
| _id: "1", | ||
| title: "Batch inference", | ||
| // Portable Text array, exactly what Sanity stores — not a string. | ||
| body: [ | ||
| {_type: "block", children: [{_type: "span", text: "about batches"}]}, | ||
| ], | ||
| slug: "batch-inference", | ||
| productSlug: "inference-api", | ||
| productName: "Inference API", | ||
| }, | ||
| ] as unknown as DocSearchIndexItem[]; | ||
|
|
||
| expect(() => searchDocs(docs, "batch")).not.toThrow(); | ||
| const results = searchDocs(docs, "batch"); | ||
| expect(results[0]?._id).toBe("1"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { NextRequest } from "next/server"; | ||
| import { middleware } from "./middleware"; | ||
|
|
||
| // Pull the CSP off the response middleware emits for a normal document request. | ||
| function cspFor( | ||
| url = "https://docs.doubleword.ai/inference-api/api-reference", | ||
| ): string { | ||
| const res = middleware(new NextRequest(url)); | ||
| return res.headers.get("content-security-policy") ?? ""; | ||
| } | ||
|
|
||
| // Return the directive (e.g. "connect-src ...") as a single trimmed string. | ||
| function directive(csp: string, name: string): string { | ||
| return ( | ||
| csp | ||
| .split(";") | ||
| .map((d) => d.trim()) | ||
| .find((d) => d === name || d.startsWith(`${name} `)) ?? "" | ||
| ); | ||
| } | ||
|
|
||
| describe("CSP middleware", () => { | ||
| it("allows the inference API host so Scalar's Test Request works", () => { | ||
| // The OpenAPI spec's server is https://api.doubleword.ai/v1, so Scalar fires | ||
| // its try-it fetch at that host from the browser. Without this entry the | ||
| // request is blocked and the user sees "Failed to fetch". | ||
| expect(directive(cspFor(), "connect-src")).toContain( | ||
| "https://api.doubleword.ai", | ||
| ); | ||
| }); | ||
|
|
||
| it("keeps the other connect-src allowances intact", () => { | ||
| const connectSrc = directive(cspFor(), "connect-src"); | ||
| expect(connectSrc).toContain("'self'"); // PostHog via /ingest rewrite | ||
| expect(connectSrc).toContain("https://app.doubleword.ai"); // SSO session check | ||
| expect(connectSrc).toContain("https://status.doubleword.ai"); // StatusWidget | ||
| }); | ||
|
|
||
| it("does not allowlist a font host — fonts stay self/data only", () => { | ||
| // We fixed the Scalar font violation by disabling its default fonts, not by | ||
| // opening font-src. Guard against a future regression that re-opens it. | ||
| expect(directive(cspFor(), "font-src")).toBe("font-src 'self' data:"); | ||
| }); | ||
|
|
||
| it("emits a per-request nonce in script-src", () => { | ||
| expect(cspFor()).toMatch(/script-src[^;]*'nonce-[^']+'/); | ||
| }); | ||
|
|
||
| it("uses a unique nonce per response", () => { | ||
| const first = cspFor().match(/'nonce-([^']+)'/)?.[1]; | ||
| const second = cspFor().match(/'nonce-([^']+)'/)?.[1]; | ||
| expect(first).toBeTruthy(); | ||
| expect(first).not.toBe(second); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,12 @@ import {NextRequest, NextResponse} from 'next/server' | |
| // `credentials: 'include'` to verify the SSO session. CORS is already | ||
| // allowed on the control-layer side (see | ||
| // internal/values/control-layer.yaml `allowed_origins`). | ||
| // - `https://api.doubleword.ai` for the Scalar API-reference "Test Request" | ||
| // feature at /inference-api/api-reference. The OpenAPI spec's server is | ||
| // `https://api.doubleword.ai/v1`, so Scalar fires the try-it `fetch` at that | ||
| // host from the browser; without it CSP blocks the request ("Failed to | ||
| // fetch"). CORS is already allowed on the api side — docs.doubleword.ai is | ||
| // in the same `allowed_origins` list (which covers the api proxy too). | ||
| // - `https://status.doubleword.ai` for the StatusWidget component, which | ||
| // fetches `/api/v1/summary` from the public status page to render | ||
| // live incident status inline in docs pages. | ||
|
|
@@ -35,7 +41,7 @@ function buildCsp(nonce: string): string { | |
| "style-src 'self' 'unsafe-inline'", | ||
| "img-src 'self' data: blob: https://cdn.sanity.io", | ||
| "font-src 'self' data:", | ||
| "connect-src 'self' https://app.doubleword.ai https://status.doubleword.ai", | ||
| "connect-src 'self' https://app.doubleword.ai https://api.doubleword.ai https://status.doubleword.ai", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: Duplicate Why it matters: The previous version already had Suggested fix: Deduplicate the list: "connect-src 'self' https://app.doubleword.ai https://api.doubleword.ai https://status.doubleword.ai",(Keep only one There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: Consider adding a comment inline here (similar to lines 27–32) explaining why Why it matters: Six months from now, someone auditing the CSP might wonder why this external host is allowlisted and whether it can be removed. An inline comment prevents accidental regression. Suggested fix: Add a brief comment near line 32 or directly above line 44: // - `https://api.doubleword.ai` for Scalar's Test Request feature (see line 44)or expand the existing comment block. |
||
| 'frame-src https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com', | ||
| "worker-src 'self' blob:", | ||
| "frame-ancestors 'none'", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-blocking: Consider using
&&vs;carefully here — if the search index build fails,next buildwon't run, which is probably desired. However, there's no error handling ifnext buildfails after a successful index build (edge case, but worth noting).Why it matters: If the search index takes significant time to generate and
next buildfails partway through, the index will be stale on the next run. This is likely acceptable given the rebuild frequency, but teams should be aware.Suggested fix: No change needed — this is fine as-is. Just documenting the trade-off: failing fast on index errors is correct, and Next.js handles its own failures appropriately.