diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index c2ae8c4d0c3..83e8927096e 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -1,8 +1,13 @@ +--- +layer: website +role: Demo site and embed host for the Petrinaut editor +--- + # Petrinaut Website A website for demoing Petrinaut (libs/@hashintel/petrinaut). -A SPA plus a single API function that proxies AI requests to OpenAI. +A SPA with API functions for AI assistance and JSON oEmbed discovery. ## Quickstart @@ -15,7 +20,23 @@ turbo run dev The dev server runs at [http://localhost:5173](http://localhost:5173). A plugin in `vite.config.ts` loads the API function. -In production, the function in the `api` folder is automatically deployed as a Vercel Serverless Function. +In production, the functions in the `api` folder are automatically deployed as +Vercel Functions. + +## Example embeds and oEmbed + +Canonical example pages live below `/examples`. The JSON oEmbed endpoint at +`/api/oembed` accepts their production URLs and returns an +`/embed/examples/...` iframe. Canonical pages send both CSP `frame-ancestors +'none'` and `X-Frame-Options: DENY`; only the dedicated embed routes permit +third-party framing. The returned iframe is sandboxed with +`allow-scripts allow-same-origin` and does not send a referrer. + +Because this is a client-rendered SPA, a static `index.html` discovery link +cannot include the current example URL. `FullExamplePage` adds the standard +`application/json+oembed` link to the document head after the route mounts. +Consumers that do not execute JavaScript must call `/api/oembed` directly or +use provider-pattern discovery instead. ### Optimization demo with Petrinaut Opt diff --git a/apps/petrinaut-website/api/oembed.ts b/apps/petrinaut-website/api/oembed.ts new file mode 100644 index 00000000000..d7630168b55 --- /dev/null +++ b/apps/petrinaut-website/api/oembed.ts @@ -0,0 +1,263 @@ +/** + * @layerRoot website.api + * @role Server functions: JSON oEmbed discovery and the AI chat proxy + * @talksTo website.routes via the embed URL it returns to consumers + */ + +import { + getExampleCatalogEntry, + isExampleSlug, + PETRINAUT_DEMO_ORIGIN, + type ExampleSlug, +} from "../src/examples/catalog-metadata"; +import { + canonicalSearchString, + validateSharedExampleSearch, +} from "../src/examples/example-search"; + +const DEFAULT_WIDTH = 800; +const DEFAULT_HEIGHT = 450; +const SUCCESS_CACHE_CONTROL = + "public, max-age=300, s-maxage=86400, stale-while-revalidate=604800"; + +type OEmbedResponse = Readonly<{ + type: "rich"; + version: "1.0"; + title: string; + provider_name: "Petrinaut"; + provider_url: typeof PETRINAUT_DEMO_ORIGIN; + width: number; + height: number; + html: string; +}>; + +const corsHeaders = (): Headers => + new Headers({ + "Access-Control-Allow-Headers": "Accept", + "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", + "Access-Control-Allow-Origin": "*", + }); + +const jsonResponse = ( + body: unknown, + init: ResponseInit & { cacheable?: boolean } = {}, +): Response => { + const { cacheable = false, ...responseInit } = init; + const headers = corsHeaders(); + for (const [name, value] of new Headers(responseInit.headers)) { + headers.set(name, value); + } + headers.set("Cache-Control", cacheable ? SUCCESS_CACHE_CONTROL : "no-store"); + headers.set("Content-Type", "application/json; charset=utf-8"); + + return new Response(JSON.stringify(body), { ...responseInit, headers }); +}; + +const errorResponse = ( + status: 400 | 404 | 405 | 501, + error: string, +): Response => jsonResponse({ error }, { status }); + +const readPositiveFiniteMaximum = ( + searchParams: URLSearchParams, + name: "maxheight" | "maxwidth", +): number | undefined | null => { + if (!searchParams.has(name)) { + return undefined; + } + + const rawValue = searchParams.get(name); + // A consumer that always appends the optional oEmbed params sends them + // empty when the user set no size. That is "no maximum", not a bad request. + if (rawValue === null || rawValue.trim() === "") { + return undefined; + } + + // oEmbed defines these as integers. `Number` would also accept `0x10`, + // `1e3` and ` 400 `, which are typos rather than sizes. + if (!/^[0-9]+$/u.test(rawValue.trim())) { + return null; + } + + const value = Number(rawValue.trim()); + return value > 0 ? value : null; +}; + +const fitDimensions = ( + maxWidth: number | undefined, + maxHeight: number | undefined, +): { width: number; height: number } => { + const scale = Math.min( + 1, + maxWidth === undefined ? 1 : maxWidth / DEFAULT_WIDTH, + maxHeight === undefined ? 1 : maxHeight / DEFAULT_HEIGHT, + ); + + // Height follows from the clamped width, so the aspect ratio survives. + // Flooring each axis independently turns `?maxwidth=1` into a 1x1 embed. + const width = Math.max(1, Math.floor(DEFAULT_WIDTH * scale)); + return { + width, + height: Math.max(1, Math.round((width * DEFAULT_HEIGHT) / DEFAULT_WIDTH)), + }; +}; + +const escapeHtmlAttribute = (value: string): string => + value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +type ParsedExampleUrl = Readonly<{ + slug: ExampleSlug; + embedSearch: URLSearchParams; +}>; + +/** + * Carry over only state the embed page understands, by decoding the canonical + * URL through the shared contract and re-encoding it. One embed-specific rule: + * embeds always show a named scenario, so an explicit `none` (valid on the + * canonical page) resolves to the model's first scenario instead. + */ +const sanitizeEmbedSearch = (source: URLSearchParams): URLSearchParams => { + const search = validateSharedExampleSearch(Object.fromEntries(source)); + + return new URLSearchParams( + canonicalSearchString( + search.scenario === "none" ? { ...search, scenario: undefined } : search, + ), + ); +}; + +const parseExampleUrl = ( + rawUrl: string, +): ParsedExampleUrl | { error: string; status: 400 | 404 } => { + let sourceUrl: URL; + try { + sourceUrl = new URL(rawUrl); + } catch { + return { error: "The url parameter must be a valid URL", status: 400 }; + } + + // A well-formed URL this provider cannot embed is 404 rather than 400. + // oEmbed 1.0 section 2.3.1 lists 404 for "no response for this url", and + // consumers branch on it to fall back to a plain link. + if (sourceUrl.origin !== PETRINAUT_DEMO_ORIGIN) { + return { + error: `The url parameter must use ${PETRINAUT_DEMO_ORIGIN}`, + status: 404, + }; + } + + const pathMatch = sourceUrl.pathname.match(/^\/examples\/([^/]+?)\/?$/u); + if (!pathMatch) { + return { + error: "The url parameter is not a canonical example URL", + status: 404, + }; + } + + const slug = pathMatch[1]!; + if (!isExampleSlug(slug)) { + return { error: `Unknown example: ${slug}`, status: 404 }; + } + + return { + slug, + embedSearch: sanitizeEmbedSearch(sourceUrl.searchParams), + }; +}; + +const respond = async (request: Request): Promise => { + if (request.method === "OPTIONS") { + const headers = corsHeaders(); + headers.set("Cache-Control", "public, max-age=86400"); + return new Response(null, { headers, status: 204 }); + } + + const isHead = request.method === "HEAD"; + if (request.method !== "GET" && !isHead) { + const response = errorResponse(405, "Method not allowed"); + response.headers.set("Allow", "GET, HEAD, OPTIONS"); + return response; + } + + const endpointUrl = new URL(request.url); + // An empty `format=` means the consumer expressed no preference, and the + // parameter is case-insensitive in practice. + const format = + endpointUrl.searchParams.get("format")?.trim().toLowerCase() || null; + if (format !== null && format !== "json") { + // oEmbed 1.0 section 2.3.1: a format the provider cannot return is 501. + return errorResponse(501, "Only the json oEmbed format is supported"); + } + + const rawSourceUrl = endpointUrl.searchParams.get("url"); + if (!rawSourceUrl) { + return errorResponse(400, "Missing required url parameter"); + } + + const parsedExampleUrl = parseExampleUrl(rawSourceUrl); + if ("error" in parsedExampleUrl) { + return errorResponse(parsedExampleUrl.status, parsedExampleUrl.error); + } + + const maxWidth = readPositiveFiniteMaximum( + endpointUrl.searchParams, + "maxwidth", + ); + if (maxWidth === null) { + return errorResponse(400, "maxwidth must be a positive integer"); + } + const maxHeight = readPositiveFiniteMaximum( + endpointUrl.searchParams, + "maxheight", + ); + if (maxHeight === null) { + return errorResponse(400, "maxheight must be a positive integer"); + } + + const { width, height } = fitDimensions(maxWidth, maxHeight); + const embedUrl = new URL( + `/embed/examples/${parsedExampleUrl.slug}`, + PETRINAUT_DEMO_ORIGIN, + ); + embedUrl.search = parsedExampleUrl.embedSearch.toString(); + + const catalogEntry = getExampleCatalogEntry(parsedExampleUrl.slug)!; + const response: OEmbedResponse = { + type: "rich", + version: "1.0", + title: catalogEntry.title, + provider_name: "Petrinaut", + provider_url: PETRINAUT_DEMO_ORIGIN, + width, + height, + html: ``, + }; + + return jsonResponse(response, { cacheable: true }); +}; + +/** + * Serve Petrinaut example embeds using the JSON oEmbed 1.0 contract. + * + * Exported only through the default `{ fetch }` object, matching `chat.ts`, so + * Vercel's Node.js runtime treats this as a Web fetch handler and hands us a + * `Request`. Without that opt-in the default export is invoked with a Node.js + * `IncomingMessage`, which has no `Request` API. + * + * See https://vercel.com/changelog/node-js-vercel-functions-now-support-fetch-web-handlers + */ +const fetch = async (request: Request): Promise => { + const response = await respond(request); + // Strip the body at the single exit, so error replies to HEAD are bodiless + // too rather than only the 200. + return request.method === "HEAD" + ? new Response(null, { headers: response.headers, status: response.status }) + : response; +}; + +export default { fetch }; diff --git a/apps/petrinaut-website/src/examples/catalog-metadata.ts b/apps/petrinaut-website/src/examples/catalog-metadata.ts index 911ccc0778f..32e18c39077 100644 --- a/apps/petrinaut-website/src/examples/catalog-metadata.ts +++ b/apps/petrinaut-website/src/examples/catalog-metadata.ts @@ -5,6 +5,8 @@ * functions that only need to validate a public URL should not bundle every * example model and generated simulation artifact. */ +export const PETRINAUT_DEMO_ORIGIN = "https://demo.petrinaut.org"; + export const exampleSlugs = [ "gases-1-pn-consumption-trigger", "gases-1-pn", diff --git a/apps/petrinaut-website/src/examples/catalog.ts b/apps/petrinaut-website/src/examples/catalog.ts index 6e6c3807622..7cbda766ccf 100644 --- a/apps/petrinaut-website/src/examples/catalog.ts +++ b/apps/petrinaut-website/src/examples/catalog.ts @@ -1,3 +1,8 @@ +/** + * @layerRoot website.examples + * @role Publishes example models and the URL contract every example surface speaks + */ + import { parseSDCPNFile, type HirArtifacts, diff --git a/apps/petrinaut-website/src/examples/full-example-page.tsx b/apps/petrinaut-website/src/examples/full-example-page.tsx index d9005e4fb6b..3b3deb5465d 100644 --- a/apps/petrinaut-website/src/examples/full-example-page.tsx +++ b/apps/petrinaut-website/src/examples/full-example-page.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { css } from "@hashintel/ds-helpers/css"; import { Petrinaut } from "@hashintel/petrinaut/ui"; +import { getOEmbedDiscoveryUrl } from "./oembed-discovery"; import { getReadonlyExampleHandle } from "./readonly-example-handle"; import { useSharedSearchNavigation } from "./use-shared-search-navigation"; @@ -53,8 +54,20 @@ export const FullExamplePage = ({ }; }, [example.catalog.title]); + // The website is a client-rendered SPA, so the oEmbed discovery link cannot + // be baked into index.html; React 19 hoists this into document.head. + // Consumers that execute the page's JavaScript can then discover the same + // production oEmbed endpoint used by server integrations. + const discoveryUrl = getOEmbedDiscoveryUrl(example.catalog.slug, search); + return (
+ { + it("uses the production origin without leaking a development port", () => { + const endpoint = new URL( + getOEmbedDiscoveryUrl("gases-1-pn", { scenario: "steady" }), + ); + + expect(endpoint.origin).toBe("https://demo.petrinaut.org"); + expect(endpoint.searchParams.get("format")).toBe("json"); + expect(endpoint.searchParams.get("url")).toBe( + "https://demo.petrinaut.org/examples/gases-1-pn?scenario=steady", + ); + }); + + it("advertises one endpoint URL per location, whatever the address bar holds", () => { + // A foreign parameter is not part of the contract, so it must not produce + // a second endpoint URL for a response that is byte-identical. + const withoutForeignParam = getOEmbedDiscoveryUrl("gases-1-pn", { + scenario: "steady", + }); + const withForeignParam = getOEmbedDiscoveryUrl("gases-1-pn", { + scenario: "steady", + ...({ utm_source: "twitter" } as Record), + }); + + expect(withForeignParam).toBe(withoutForeignParam); + }); + + it("orders the contract parameters canonically", () => { + expect( + getOEmbedDiscoveryUrl("gases-2-spn", { + subnet: "subnet-a", + scenario: "scenario__drawing", + }), + ).toBe( + getOEmbedDiscoveryUrl("gases-2-spn", { + scenario: "scenario__drawing", + subnet: "subnet-a", + }), + ); + }); +}); diff --git a/apps/petrinaut-website/src/examples/oembed-discovery.ts b/apps/petrinaut-website/src/examples/oembed-discovery.ts new file mode 100644 index 00000000000..dab9bc9162c --- /dev/null +++ b/apps/petrinaut-website/src/examples/oembed-discovery.ts @@ -0,0 +1,25 @@ +import { PETRINAUT_DEMO_ORIGIN } from "./catalog-metadata"; +import { canonicalSearchString } from "./example-search"; + +import type { SharedExampleSearch } from "./example-search"; + +/** + * Builds the oEmbed endpoint URL a consumer should call for this example. + * + * The advertised `url` is rebuilt from the slug and the validated search + * rather than copied from the address bar, so a tracking parameter on the page + * does not advertise a distinct endpoint URL for a byte-identical response. + */ +export const getOEmbedDiscoveryUrl = ( + slug: string, + search: SharedExampleSearch, +): string => { + const sourceUrl = new URL(`/examples/${slug}`, PETRINAUT_DEMO_ORIGIN); + sourceUrl.search = canonicalSearchString(search); + + const endpointUrl = new URL("/api/oembed", PETRINAUT_DEMO_ORIGIN); + endpointUrl.searchParams.set("url", sourceUrl.href); + endpointUrl.searchParams.set("format", "json"); + + return endpointUrl.href; +}; diff --git a/apps/petrinaut-website/src/examples/oembed-endpoint.test.ts b/apps/petrinaut-website/src/examples/oembed-endpoint.test.ts new file mode 100644 index 00000000000..dd8285d8a93 --- /dev/null +++ b/apps/petrinaut-website/src/examples/oembed-endpoint.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from "vitest"; + +// The endpoint lives in `api/`, where every module is deployed as a Vercel +// function, so its test lives here instead. It is imported through the default +// export, the only one the module has: a named `fetch` export alongside it +// stops Vercel's runtime from invoking the function. +import oembedEndpoint from "../../api/oembed"; + +const { fetch } = oembedEndpoint; + +const endpointRequest = ( + sourceUrl?: string, + options: { + format?: string; + maxheight?: string; + maxwidth?: string; + method?: string; + } = {}, +): Request => { + const endpoint = new URL("https://demo.petrinaut.org/api/oembed"); + if (sourceUrl !== undefined) { + endpoint.searchParams.set("url", sourceUrl); + } + for (const name of ["format", "maxheight", "maxwidth"] as const) { + const value = options[name]; + if (value !== undefined) { + endpoint.searchParams.set(name, value); + } + } + return new Request(endpoint, { method: options.method ?? "GET" }); +}; + +const responseJson = async ( + response: Response, +): Promise> => + response.json() as Promise>; + +describe("Petrinaut oEmbed endpoint", () => { + it("returns a JSON oEmbed response for an unversioned canonical URL", async () => { + const response = await fetch( + endpointRequest("https://demo.petrinaut.org/examples/gases-1-pn"), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json; charset=utf-8", + ); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("cache-control")).toContain("public"); + expect(await responseJson(response)).toEqual({ + type: "rich", + version: "1.0", + title: "Gases 1 — One Customer", + provider_name: "Petrinaut", + provider_url: "https://demo.petrinaut.org", + width: 800, + height: 450, + html: '', + }); + }); + + it("preserves only valid embed state from the source URL", async () => { + const source = new URL("https://demo.petrinaut.org/examples/gases-2-spn"); + source.searchParams.set("mode", "simulate"); + source.searchParams.set("section", "metrics"); + source.searchParams.set("scenario", "scenario-1"); + source.searchParams.set("subnet", "subnet-1"); + source.searchParams.set("itemType", "transition"); + source.searchParams.set("itemId", "transition-1"); + source.searchParams.set("unrelated", "discard-me"); + + const response = await fetch( + endpointRequest(source.href, { format: "json" }), + ); + const body = await responseJson(response); + + expect(response.status).toBe(200); + expect(body.html).toBe( + '', + ); + }); + + it("drops an incomplete focused-item pair", async () => { + const response = await fetch( + endpointRequest( + "https://demo.petrinaut.org/examples/gases-1-pn?itemType=script&itemId=place-1", + ), + ); + const body = await responseJson(response); + + expect(body.html).not.toContain("itemId"); + expect(body.html).not.toContain("itemType"); + }); + + it("drops full-page no-scenario state from embeds", async () => { + const response = await fetch( + endpointRequest( + "https://demo.petrinaut.org/examples/gases-1-pn?scenario=none", + ), + ); + const body = await responseJson(response); + + expect(body.html).not.toContain("scenario="); + }); + + it("URL-encodes query values and HTML-escapes the iframe source", async () => { + const source = new URL( + "https://demo.petrinaut.org/examples/semiconductor-fab-drift", + ); + source.searchParams.set("scenario", '">&'); + source.searchParams.set("subnet", "one&two"); + + const response = await fetch(endpointRequest(source.href)); + const body = await responseJson(response); + const html = body.html as string; + + expect(html).not.toContain("