diff --git a/.gitleaks.toml b/.gitleaks.toml index 85eb6c3..8680aa3 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -15,3 +15,18 @@ regexes = [ '''^\s*"packages/connect-web/src/assert-fetch-api\.ts": "b577e990dcf088f942a1dcf66405aab52c7d6f72bed2757c64e213e32ce91dac",?$''', '''^\s*"packages/shared/ReactFlightPropertyAccess\.js": "b80258eb57aa5de9d4386788424c10b259b87b76f84498605bf10f0b0dd7c793",?$''', ] + +[[allowlists]] +description = "Reviewed public source hashes in the immutable browser resource r8 profile" +condition = "AND" +targetRules = ["generic-api-key"] +regexTarget = "line" +paths = ['''^eng/provenance/profiles/browser-resources-r8\.json$'''] +regexes = [ + '''^\s*"node_modules/@bufbuild/protobuf/dist/esm/wkt/gen/google/protobuf/api_pb\.js": "73e489001027c703bc0224ae73f78ecefe028e88284bd06952e8603c3d81472c",?$''', + '''^\s*"node_modules/@connectrpc/connect-web/dist/esm/assert-fetch-api\.js": "bd56033776818aaa82959c12561dd084d3a730180e6e8f1e681f5d3d439b6474",?$''', + '''^\s*"packages/protobuf/src/wkt/gen/google/protobuf/api_pb\.ts": "538052ccd9a02c464702000dfc92a5e7cc01b7b5f55970b14af39662c7b46ae5",?$''', + '''^\s*"packages/protobuf/src/wkt/gen/google/protobuf/api_pb\.ts": "440d9371d9b04a8025e6f89d0041c2a54c7c926abedfbd24cef1432ec3fc38a4",?$''', + '''^\s*"packages/connect-web/src/assert-fetch-api\.ts": "b577e990dcf088f942a1dcf66405aab52c7d6f72bed2757c64e213e32ce91dac",?$''', + '''^\s*"packages/shared/ReactFlightPropertyAccess\.js": "b80258eb57aa5de9d4386788424c10b259b87b76f84498605bf10f0b0dd7c793",?$''', +] diff --git a/apps/site/app/root.tsx b/apps/site/app/root.tsx index 50e5c80..1d891ec 100644 --- a/apps/site/app/root.tsx +++ b/apps/site/app/root.tsx @@ -9,12 +9,15 @@ import { Outlet, Scripts, ScrollRestoration, + useLocation, useRouteError, } from "react-router"; +import { localeForPath } from "./site-manifest"; export function Layout({ children }: { children: ReactNode }) { + const location = useLocation(); return ( - + diff --git a/apps/site/app/routes.ts b/apps/site/app/routes.ts index 48c1847..930a2e7 100644 --- a/apps/site/app/routes.ts +++ b/apps/site/app/routes.ts @@ -4,4 +4,5 @@ export default [ index("routes/home.tsx"), route("hello", "routes/hello.tsx"), route("cloud-hello", "routes/cloud-hello.tsx"), + route("*", "routes/localized-site.tsx"), ] satisfies RouteConfig; diff --git a/apps/site/app/routes/cloud-hello.tsx b/apps/site/app/routes/cloud-hello.tsx index d2251fd..fc50833 100644 --- a/apps/site/app/routes/cloud-hello.tsx +++ b/apps/site/app/routes/cloud-hello.tsx @@ -1,75 +1,22 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { Button } from "@arcforges/web-ui"; -import { useEffect, useRef, useState } from "react"; -import { checkServerConnection } from "../cloud-hello"; +import { useLoaderData } from "react-router"; +import { loadCloudHelloPage } from "../site-content.server"; +import { metadataForPage } from "../site-metadata"; +import { CloudHelloPageView } from "../site-pages"; -export function meta() { - return [{ title: "Server connection — ArcForges" }]; +export function loader() { + return loadCloudHelloPage(); } -export default function CloudHello() { - const [ready, setReady] = useState(false); - const [state, setState] = useState<"idle" | "pending" | "success" | "error">("idle"); - const [reply, setReply] = useState(""); - const active = useRef(null); - useEffect(() => { - setReady(true); - return () => active.current?.abort(); - }, []); - - async function connect() { - if (active.current) return; - const controller = new AbortController(); - active.current = controller; - setState("pending"); - try { - const message = await checkServerConnection(window.location.origin, controller.signal); - if (!controller.signal.aborted) { - setReply(message); - setState("success"); - } - } catch { - if (!controller.signal.aborted) setState("error"); - } finally { - if (active.current === controller) active.current = null; - } - } - - const message = { - idle: "No request sent yet.", - pending: "Contacting the server…", - success: reply, - error: "The server is unavailable or returned an unexpected response. Try again later.", - }[state]; +export function meta({ + loaderData, +}: { + loaderData: ReturnType | undefined; +}) { + return loaderData ? metadataForPage(loaderData) : []; +} - return ( -
- - ← Back to your hello - -

Connection example

-

- A hello, from the server. -

-

- Send a fixed “ArcForges” greeting to check the server connection. Your name from the local - example stays in your browser. Nothing is sent until you choose to connect. -

- - -
- Server response -

{message}

-
-
- ); +export default function CloudHello() { + const page = useLoaderData(); + return ; } diff --git a/apps/site/app/routes/hello.tsx b/apps/site/app/routes/hello.tsx index 7dcdec1..45d9cf3 100644 --- a/apps/site/app/routes/hello.tsx +++ b/apps/site/app/routes/hello.tsx @@ -1,82 +1,18 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { Button } from "@arcforges/web-ui"; -import { type FormEvent, useEffect, useState } from "react"; -import { greet } from "../hello"; +import { useLoaderData } from "react-router"; +import { loadHelloPage } from "../site-content.server"; +import { metadataForPage } from "../site-metadata"; +import { HelloPageView } from "../site-pages"; -export function meta() { - return [{ title: "Your hello — ArcForges" }]; +export function loader() { + return loadHelloPage(); +} + +export function meta({ loaderData }: { loaderData: ReturnType | undefined }) { + return loaderData ? metadataForPage(loaderData) : []; } export default function Hello() { - const [name, setName] = useState("World"); - const [message, setMessage] = useState("Hello, World!"); - const [error, setError] = useState(""); - const [ready, setReady] = useState(false); - useEffect(() => { - setReady(true); - }, []); - function submit(event: FormEvent) { - event.preventDefault(); - try { - setMessage(greet(name)); - setError(""); - } catch { - setError("Enter a name between 1 and 80 characters, without control characters."); - } - } - return ( -
- - ← Back home - -

-

-

- A hello, just for you. -

-

- Start with your name. This example runs in your browser, and your name stays on this page. -

-
- -
- setName(event.target.value)} - autoComplete="off" - spellCheck={false} - aria-invalid={Boolean(error)} - aria-describedby={error ? "name-error name-hint" : "name-hint"} - /> - -
-

- Up to 80 characters. No account needed. -

- {error && ( - - )} -
- -
- Your greeting -

{message}

-
-

- Check the server connection → -

-
- ); + const page = useLoaderData(); + return ; } diff --git a/apps/site/app/routes/home.tsx b/apps/site/app/routes/home.tsx index 65332e4..6c6191c 100644 --- a/apps/site/app/routes/home.tsx +++ b/apps/site/app/routes/home.tsx @@ -1,48 +1,18 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { Arrow } from "@arcforges/web-ui"; +import { useLoaderData } from "react-router"; +import { loadHomePage } from "../site-content.server"; +import { metadataForPage } from "../site-metadata"; +import { HomePageView } from "../site-pages"; -export function meta() { - return [ - { title: "Hello, world. — ArcForges" }, - { name: "description", content: "A small, open-source beginning for ArcForges Web." }, - ]; +export function loader() { + return loadHomePage(); +} + +export function meta({ loaderData }: { loaderData: ReturnType | undefined }) { + return loaderData ? metadataForPage(loaderData) : []; } export default function Home() { - return ( - <> -
-
-

-

-

- Hello, -
- world. -

-

- Every good thing starts somewhere. -
- Welcome to the first page of ArcForges Web. -

- - Make it your hello - - -
- -
-
-

A beginning you can explore.

-

- This small preview has one job: say hello. Try the example, look around the source, and - follow along as ArcForges takes shape. -

-
- - ); + const page = useLoaderData(); + return ; } diff --git a/apps/site/app/routes/localized-site.tsx b/apps/site/app/routes/localized-site.tsx new file mode 100644 index 0000000..7928aa5 --- /dev/null +++ b/apps/site/app/routes/localized-site.tsx @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { useLoaderData } from "react-router"; +import { metadataForPage } from "../site-metadata"; +import { loadLocalizedPage } from "../site-content.server"; +import { CloudHelloPageView, HelloPageView, HomePageView } from "../site-pages"; + +export function loader({ params }: { params: Record }) { + return loadLocalizedPage(params); +} + +export function meta({ + loaderData, +}: { + loaderData: Awaited> | undefined; +}) { + return loaderData ? metadataForPage(loaderData) : []; +} + +export default function LocalizedSitePage() { + const page = useLoaderData(); + switch (page.kind) { + case "home": + return ; + case "hello": + return ; + case "cloud-hello": + return ; + case "document": + return ( +
+ + ← Back home + +

Documentation · {page.version}

+

{page.title}

+

{page.description}

+

{page.body}

+
+ ); + } +} diff --git a/apps/site/app/site-content.server.ts b/apps/site/app/site-content.server.ts new file mode 100644 index 0000000..166b989 --- /dev/null +++ b/apps/site/app/site-content.server.ts @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { + normalizePublicPath, + pageById, + pageByLocalizedPath, + publicSiteManifest, + type PublicSiteManifest, + type SitePage, +} from "./site-manifest"; + +export type SitePageData = SitePage & { + canonicalUrl: string; + alternateLanguages: readonly { locale: string; href: string }[]; +}; + +type PageMetadata = Pick; + +function withMetadata( + page: T, + manifest: PublicSiteManifest = publicSiteManifest, +): T & PageMetadata { + return { + ...page, + canonicalUrl: new URL(page.localizedPath, manifest.origin).href, + alternateLanguages: manifest.pages + .filter((candidate) => candidate.id === page.id) + .sort((left, right) => (left.locale < right.locale ? -1 : left.locale > right.locale ? 1 : 0)) + .map((candidate) => ({ + locale: candidate.locale, + href: new URL(candidate.localizedPath, manifest.origin).href, + })), + }; +} + +export function loadHomePage() { + return withMetadata(pageById("home")); +} + +export function loadHelloPage() { + return withMetadata(pageById("hello")); +} + +export function loadCloudHelloPage() { + return withMetadata(pageById("cloud-hello")); +} + +export function loadLocalizedPage( + params: Record, + manifest: PublicSiteManifest = publicSiteManifest, +): SitePageData { + const locale = params.locale; + if (locale && !/^[a-z]{2,3}(?:-[A-Z]{2})?$/u.test(locale)) { + throw new Response("Page not found", { status: 404 }); + } + const wildcard = params["*"] ?? ""; + const pathSegments = wildcard.split("/").filter(Boolean); + const inferredLocale = + locale ?? manifest.locales.find((candidate) => candidate.pathPrefix === pathSegments[0])?.code; + let path: string; + try { + const defaultPath = wildcard.startsWith("docs/") ? `/${wildcard}` : `/docs/${wildcard}`; + path = normalizePublicPath( + locale ? `/${locale}/${wildcard}` : inferredLocale ? `/${wildcard}` : defaultPath, + ); + } catch { + throw new Response("Page not found", { status: 404 }); + } + const page = inferredLocale + ? pageByLocalizedPath(path, manifest) + : manifest.pages.find((candidate) => candidate.defaultPath === path); + if (!page || (inferredLocale && page.locale !== inferredLocale)) { + throw new Response("Page not found", { status: 404 }); + } + return withMetadata(page, manifest); +} diff --git a/apps/site/app/site-inputs.ts b/apps/site/app/site-inputs.ts new file mode 100644 index 0000000..84e6afb --- /dev/null +++ b/apps/site/app/site-inputs.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-only +export const inputs = { + schemaVersion: 1, + origin: "https://arcforges.com", + defaultLocale: "en", + locales: [{ code: "en", label: "English", pathPrefix: "en" }], + pages: [ + { + id: "home", + kind: "home", + locale: "en", + defaultPath: "/", + localizedPath: "/en/", + title: "Hello, world. — ArcForges", + description: "A small, open-source beginning for ArcForges Web.", + headingFirst: "Hello,", + headingSecond: "world.", + eyebrow: "ArcForges Web · Early preview", + lead: ["Every good thing starts somewhere.", "Welcome to the first page of ArcForges Web."], + primaryLink: { href: "/hello/", label: "Make it your hello" }, + introTitle: "A beginning you can explore.", + introBody: + "This small preview has one job: say hello. Try the example, look around the source, and follow along as ArcForges takes shape.", + }, + { + id: "hello", + kind: "hello", + locale: "en", + defaultPath: "/hello/", + localizedPath: "/en/hello/", + title: "Your hello — ArcForges", + description: "A local greeting example that runs in your browser.", + }, + { + id: "cloud-hello", + kind: "cloud-hello", + locale: "en", + defaultPath: "/cloud-hello/", + localizedPath: "/en/cloud-hello/", + title: "Server connection — ArcForges", + description: "An optional example that checks the ArcForges server connection.", + }, + ], + documentationVersions: [], + redirects: [ + { + source: "/account", + destination: "https://account.arcforges.com/", + status: 308, + }, + ], +} as const; diff --git a/apps/site/app/site-manifest.ts b/apps/site/app/site-manifest.ts new file mode 100644 index 0000000..75b1aa5 --- /dev/null +++ b/apps/site/app/site-manifest.ts @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { inputs } from "./site-inputs.ts"; + +export interface SiteLocale { + code: string; + label: string; + pathPrefix: string; +} + +interface SitePageBase { + id: string; + locale: string; + defaultPath?: string; + localizedPath: string; + title: string; + description: string; +} + +export interface HomePage extends SitePageBase { + kind: "home"; + eyebrow: string; + headingFirst: string; + headingSecond: string; + lead: string[]; + primaryLink: { href: string; label: string }; + introTitle: string; + introBody: string; +} + +export interface ExamplePage extends SitePageBase { + kind: "hello" | "cloud-hello"; +} + +export interface DocumentPage extends SitePageBase { + kind: "document"; + body: string; + version: string; +} + +export type SitePage = HomePage | ExamplePage | DocumentPage; + +export interface SiteRedirect { + source: string; + destination: string; + status: 301 | 302 | 303 | 307 | 308; +} + +export interface PublicSiteManifest { + schemaVersion: 1; + origin: string; + defaultLocale: string; + locales: readonly SiteLocale[]; + pages: readonly SitePage[]; + documentationVersions: readonly string[]; + redirects: readonly SiteRedirect[]; +} + +export const publicSiteManifest = inputs as unknown as PublicSiteManifest; + +function ordinal(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function normalizePublicPath(path: string): string { + if (!path.startsWith("/") || path.startsWith("//")) { + throw new Error(`Public paths must be root-relative: ${path}`); + } + const hasControlCharacter = Array.from(path).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint < 0x20 || codePoint === 0x7f; + }); + if (/[?#%]/u.test(path) || path.includes("\\") || path.includes("//") || hasControlCharacter) { + throw new Error( + `Public paths contain a query, fragment, encoding, repeated slash or control: ${path}`, + ); + } + const segments = path.split("/").filter(Boolean); + if ( + segments.some( + (segment) => + segment === "." || + segment === ".." || + !/^[A-Za-z0-9._~-]+$/u.test(segment) || + segment.endsWith("."), + ) + ) { + throw new Error(`Public paths contain an unsafe segment: ${path}`); + } + const normalized = `/${segments.join("/")}`; + return normalized === "/" ? normalized : `${normalized}/`; +} + +export function validatePublicSiteManifest( + manifest: PublicSiteManifest = publicSiteManifest, +): void { + if (manifest.schemaVersion !== 1) throw new Error("Unsupported public site manifest version"); + if (manifest.origin !== "https://arcforges.com") throw new Error("Unexpected public site origin"); + + const localeCodes = manifest.locales.map((locale) => locale.code); + const localePrefixes = manifest.locales.map((locale) => locale.pathPrefix); + if (new Set(localeCodes).size !== localeCodes.length) throw new Error("Duplicate site locale"); + if (new Set(localePrefixes).size !== localePrefixes.length) { + throw new Error("Duplicate site locale path prefix"); + } + if (!localeCodes.includes(manifest.defaultLocale)) throw new Error("Unknown default site locale"); + for (const locale of manifest.locales) { + if (!/^[a-z]{2,3}(?:-[A-Z]{2})?$/u.test(locale.code)) { + throw new Error(`Invalid locale code: ${locale.code}`); + } + if (!/^[a-z0-9-]+$/u.test(locale.pathPrefix)) { + throw new Error(`Invalid locale path prefix: ${locale.pathPrefix}`); + } + } + + const pageLocales = new Set(); + const paths = new Set(); + for (const page of manifest.pages) { + const identity = `${page.id}\u0000${page.locale}`; + if (!/^[a-z0-9-]+$/u.test(page.id) || pageLocales.has(identity)) { + throw new Error(`Invalid or duplicate public page/locale: ${page.id}/${page.locale}`); + } + pageLocales.add(identity); + if (!localeCodes.includes(page.locale)) throw new Error(`Unknown page locale: ${page.locale}`); + const locale = manifest.locales.find((candidate) => candidate.code === page.locale); + const localizedPath = normalizePublicPath(page.localizedPath); + if ( + !localizedPath.startsWith(`/${locale?.pathPrefix}/`) && + localizedPath !== `/${locale?.pathPrefix}` + ) { + throw new Error(`Localized path does not match locale prefix: ${page.localizedPath}`); + } + for (const candidate of [page.defaultPath, page.localizedPath].filter( + (path): path is string => path !== undefined, + )) { + const path = normalizePublicPath(candidate); + if (paths.has(path)) throw new Error(`Duplicate public route path: ${path}`); + paths.add(path); + const segments = path.split("/").filter(Boolean); + const routeRoot = localeCodes.includes(segments[0] ?? "") ? segments[1] : segments[0]; + if (["account", "chat", "ops", "api"].includes(routeRoot ?? "")) { + throw new Error(`Private application path cannot be a public site route: ${path}`); + } + } + if (!page.title.trim() || !page.description.trim()) { + throw new Error(`Public page metadata is required: ${page.id}`); + } + if (page.locale !== manifest.defaultLocale && page.defaultPath !== undefined) { + throw new Error(`Only the default locale may own a default path: ${page.id}/${page.locale}`); + } + if (page.locale === manifest.defaultLocale && page.defaultPath === undefined) { + throw new Error(`Default locale route is missing its compatibility path: ${page.id}`); + } + if (page.kind === "document" && !manifest.documentationVersions.includes(page.version)) { + throw new Error(`Document page uses an unregistered version: ${page.version}`); + } + } + + for (const pageId of new Set(manifest.pages.map((page) => page.id))) { + if (!pageLocales.has(`${pageId}\u0000${manifest.defaultLocale}`)) { + throw new Error(`Public page is missing its default locale: ${pageId}`); + } + } + + for (const page of manifest.pages) { + if (page.kind !== "home") continue; + const target = normalizePublicPath(page.primaryLink.href); + if (!paths.has(target)) { + throw new Error(`Home page primary link must target a known public route: ${target}`); + } + if (!page.primaryLink.label.trim()) { + throw new Error(`Home page primary link label is required: ${page.id}/${page.locale}`); + } + } + + const redirectSources = new Set(); + const redirectTargets = new Map(); + for (const redirect of manifest.redirects) { + const source = normalizePublicPath(redirect.source); + if (redirectSources.has(source)) throw new Error(`Duplicate redirect source: ${source}`); + if (paths.has(source)) + throw new Error(`Redirect source collides with a public route: ${source}`); + redirectSources.add(source); + const target = new URL(redirect.destination, manifest.origin); + if (target.protocol !== "https:") + throw new Error(`Redirect must use HTTPS: ${redirect.destination}`); + if (target.username || target.password || target.search || target.hash) { + throw new Error("Redirect destination cannot contain credentials, a query or a fragment"); + } + if (target.origin !== manifest.origin && target.origin !== "https://account.arcforges.com") { + throw new Error(`Redirect uses an unapproved public origin: ${target.origin}`); + } + if (target.origin === manifest.origin) { + redirectTargets.set(source, normalizePublicPath(target.pathname)); + } + if (![301, 302, 303, 307, 308].includes(redirect.status)) { + throw new Error(`Unsupported redirect status: ${redirect.status}`); + } + } + + for (const [source, firstTarget] of redirectTargets) { + const visited = new Set(); + let target = firstTarget; + while (redirectTargets.has(target)) { + if (visited.has(target) || target === source) throw new Error(`Redirect loop: ${source}`); + visited.add(target); + const next = redirectTargets.get(target); + if (next === undefined) break; + target = next; + } + if (!paths.has(target)) { + throw new Error( + `Redirect must resolve to a known public route or approved external origin: ${target}`, + ); + } + } + + const versions = manifest.documentationVersions; + if (new Set(versions).size !== versions.length) + throw new Error("Duplicate documentation version"); + if (versions.some((version) => !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(version))) { + throw new Error("Invalid documentation version identifier"); + } +} + +export function allPrerenderPaths(manifest: PublicSiteManifest = publicSiteManifest): string[] { + validatePublicSiteManifest(manifest); + const paths = manifest.pages.flatMap((page) => [page.defaultPath, page.localizedPath]); + return [ + ...new Set(paths.filter((path): path is string => path !== undefined).map(normalizePublicPath)), + ].sort(ordinal); +} + +export function localeForPath( + pathname: string, + manifest: PublicSiteManifest = publicSiteManifest, +): string { + const firstSegment = pathname.split("/").find((segment) => segment.length > 0); + return ( + manifest.locales.find((locale) => locale.pathPrefix === firstSegment)?.code ?? + manifest.defaultLocale + ); +} + +export function sitemapUrls(manifest: PublicSiteManifest = publicSiteManifest): string[] { + validatePublicSiteManifest(manifest); + return manifest.pages + .map((page) => new URL(normalizePublicPath(page.localizedPath), manifest.origin).href) + .sort(ordinal); +} + +export function renderSitemapXml(manifest: PublicSiteManifest = publicSiteManifest): string { + const urls = sitemapUrls(manifest); + const entries = urls.map((url) => ` ${escapeXml(url)}`).join("\n"); + return `\n\n${entries}\n\n`; +} + +export function renderRedirectRules(manifest: PublicSiteManifest = publicSiteManifest): string { + validatePublicSiteManifest(manifest); + return `${[...manifest.redirects] + .sort((left, right) => ordinal(left.source, right.source)) + .map((redirect) => `${redirect.source} ${redirect.destination} ${redirect.status}`) + .join("\n")}\n`; +} + +export function publicManifestDocument(manifest: PublicSiteManifest = publicSiteManifest) { + validatePublicSiteManifest(manifest); + return { + schemaVersion: manifest.schemaVersion, + origin: manifest.origin, + defaultLocale: manifest.defaultLocale, + locales: [...manifest.locales].sort((left, right) => ordinal(left.code, right.code)), + pages: [...manifest.pages] + .map((page) => ({ + id: page.id, + locale: page.locale, + paths: [page.defaultPath, page.localizedPath] + .filter((path): path is string => path !== undefined) + .map(normalizePublicPath) + .sort(ordinal), + canonical: new URL(normalizePublicPath(page.localizedPath), manifest.origin).href, + title: page.title, + description: page.description, + })) + .sort((left, right) => ordinal(left.id, right.id) || ordinal(left.locale, right.locale)), + documentationVersions: [...manifest.documentationVersions].sort(ordinal), + redirects: [...manifest.redirects] + .sort((left, right) => ordinal(left.source, right.source)) + .map((redirect) => ({ ...redirect })), + }; +} + +export function pageById(id: "home"): HomePage; +export function pageById(id: "hello"): ExamplePage; +export function pageById(id: "cloud-hello"): ExamplePage; +export function pageById(id: string): SitePage; +export function pageById(id: string): SitePage { + const page = publicSiteManifest.pages.find( + (candidate) => candidate.id === id && candidate.locale === publicSiteManifest.defaultLocale, + ); + if (!page) throw new Error(`Missing public site page: ${id}`); + return page; +} + +export function pageByLocalizedPath( + path: string, + manifest: PublicSiteManifest = publicSiteManifest, +): SitePage | undefined { + const normalized = normalizePublicPath(path); + return manifest.pages.find((page) => normalizePublicPath(page.localizedPath) === normalized); +} + +function escapeXml(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} diff --git a/apps/site/app/site-metadata.ts b/apps/site/app/site-metadata.ts new file mode 100644 index 0000000..20a72d3 --- /dev/null +++ b/apps/site/app/site-metadata.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import type { MetaDescriptor } from "react-router"; + +export interface SitePageMetadata { + title: string; + description: string; + canonicalUrl: string; + alternateLanguages: readonly { locale: string; href: string }[]; +} + +export function metadataForPage(page: SitePageMetadata) { + return [ + { title: page.title }, + { name: "description", content: page.description }, + { property: "og:title", content: page.title }, + { property: "og:description", content: page.description }, + { property: "og:type", content: "website" }, + { property: "og:url", content: page.canonicalUrl }, + { tagName: "link", rel: "canonical", href: page.canonicalUrl }, + ...page.alternateLanguages.map((alternate) => ({ + tagName: "link" as const, + rel: "alternate", + hrefLang: alternate.locale, + href: alternate.href, + })), + ] satisfies MetaDescriptor[]; +} diff --git a/apps/site/app/site-pages.tsx b/apps/site/app/site-pages.tsx new file mode 100644 index 0000000..9989304 --- /dev/null +++ b/apps/site/app/site-pages.tsx @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { Arrow, Button } from "@arcforges/web-ui"; +import { type FormEvent, useEffect, useRef, useState } from "react"; +import { checkServerConnection } from "./cloud-hello"; +import { greet } from "./hello"; +import type { ExamplePage, HomePage } from "./site-manifest"; + +export function HomePageView({ page }: { page: HomePage }) { + return ( + <> +
+
+

+

+

+ {page.headingFirst} +
+ {page.headingSecond} +

+

+ {page.lead.map((line, index) => ( + + {index > 0 &&
} + {line} +
+ ))} +

+ + {page.primaryLink.label} + + +
+ +
+
+

{page.introTitle}

+

{page.introBody}

+
+ + ); +} + +export function HelloPageView({ page }: { page: ExamplePage }) { + const [name, setName] = useState("World"); + const [message, setMessage] = useState("Hello, World!"); + const [error, setError] = useState(""); + const [ready, setReady] = useState(false); + useEffect(() => { + setReady(true); + }, []); + function submit(event: FormEvent) { + event.preventDefault(); + try { + setMessage(greet(name)); + setError(""); + } catch { + setError("Enter a name between 1 and 80 characters, without control characters."); + } + } + return ( +
+ + ← Back home + +

+

+

{page.title}

+

{page.description}

+
+ +
+ setName(event.target.value)} + autoComplete="off" + spellCheck={false} + aria-invalid={Boolean(error)} + aria-describedby={error ? "name-error name-hint" : "name-hint"} + /> + +
+

+ Up to 80 characters. No account needed. +

+ {error && ( + + )} +
+ +
+ Your greeting +

{message}

+
+

+ Check the server connection → +

+
+ ); +} + +export function CloudHelloPageView({ page }: { page: ExamplePage }) { + const [ready, setReady] = useState(false); + const [state, setState] = useState<"idle" | "pending" | "success" | "error">("idle"); + const [reply, setReply] = useState(""); + const active = useRef(null); + useEffect(() => { + setReady(true); + return () => active.current?.abort(); + }, []); + + async function connect() { + if (active.current) return; + const controller = new AbortController(); + active.current = controller; + setState("pending"); + try { + const message = await checkServerConnection(window.location.origin, controller.signal); + if (!controller.signal.aborted) { + setReply(message); + setState("success"); + } + } catch { + if (!controller.signal.aborted) setState("error"); + } finally { + if (active.current === controller) active.current = null; + } + } + + const message = { + idle: "No request sent yet.", + pending: "Contacting the server…", + success: reply, + error: "The server is unavailable or returned an unexpected response. Try again later.", + }[state]; + + return ( +
+ + ← Back to your hello + +

Connection example

+

{page.title}

+

{page.description}

+ + +
+ Server response +

{message}

+
+
+ ); +} diff --git a/apps/site/package.json b/apps/site/package.json index 4dbb60e..b40c05e 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "react-router dev --host 127.0.0.1 --port 5173 --strictPort", "build": "react-router build", + "postbuild": "vitest run --config vitest.config.ts", "typecheck": "react-router typegen && tsc -p tsconfig.json" }, "dependencies": { diff --git a/apps/site/react-router.config.ts b/apps/site/react-router.config.ts index 30403bd..e8a4cb8 100644 --- a/apps/site/react-router.config.ts +++ b/apps/site/react-router.config.ts @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Config } from "@react-router/dev/config"; +import { allPrerenderPaths } from "./app/site-manifest"; -export default { ssr: false, prerender: ["/", "/hello", "/cloud-hello"] } satisfies Config; +export default { ssr: false, prerender: allPrerenderPaths() } satisfies Config; diff --git a/apps/site/tests/site-manifest.test.ts b/apps/site/tests/site-manifest.test.ts new file mode 100644 index 0000000..45d49fd --- /dev/null +++ b/apps/site/tests/site-manifest.test.ts @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; +import { + allPrerenderPaths, + localeForPath, + pageById, + publicManifestDocument, + publicSiteManifest, + renderRedirectRules, + renderSitemapXml, + sitemapUrls, + validatePublicSiteManifest, + type DocumentPage, + type ExamplePage, + type HomePage, + type PublicSiteManifest, + type SitePage, +} from "../app/site-manifest"; +import { loadLocalizedPage } from "../app/site-content.server"; +import { metadataForPage } from "../app/site-metadata"; +import routeConfig from "../app/routes"; + +const siteRoot = fileURLToPath(new URL("..", import.meta.url)); +const clientRoot = join(siteRoot, "build", "client"); + +function testOnlyVersionedManifest(): PublicSiteManifest { + const home: HomePage = { + id: "home", + kind: "home", + locale: "fr", + localizedPath: "/fr/", + title: "Bonjour, monde. — ArcForges", + description: "Fixture only: translated public home page.", + eyebrow: "ArcForges Web · Test fixture", + headingFirst: "Bonjour,", + headingSecond: "monde.", + lead: ["Fixture locale content."], + primaryLink: { href: "/fr/hello/", label: "Test link" }, + introTitle: "Fixture locale", + introBody: "This translation exists only in a WEB.01 unit fixture.", + }; + const documents: DocumentPage[] = ["v1", "v2"].map((version) => ({ + id: `docs-arcscope-${version}`, + kind: "document", + locale: "en", + defaultPath: `/docs/arcscope/${version}/`, + localizedPath: `/en/docs/arcscope/${version}/`, + title: `ArcScope docs ${version}`, + description: `Fixture-only ArcScope documentation version ${version}.`, + body: `Fixture body for version ${version}.`, + version, + })); + const frenchHello: ExamplePage = { + id: "hello", + kind: "hello", + locale: "fr", + localizedPath: "/fr/hello/", + title: "Bonjour — ArcForges", + description: "Fixture-only localized hello page.", + }; + const base = publicSiteManifest as PublicSiteManifest; + return { + ...base, + locales: [...base.locales, { code: "fr", label: "Français", pathPrefix: "fr" }], + pages: [...base.pages, home, frenchHello, ...documents] as SitePage[], + documentationVersions: ["v1", "v2"], + redirects: [ + ...base.redirects, + { source: "/old-docs", destination: "/en/docs/arcscope/v2/", status: 308 }, + ], + }; +} + +describe("public static-site inventory", () => { + test("registers each route module once so route identifiers are unique", () => { + const files = routeConfig.flatMap((route) => ("file" in route ? [route.file] : [])); + expect(new Set(files).size).toBe(files.length); + }); + + test("pre-renders every default and locale-scoped public route in stable order", () => { + expect(allPrerenderPaths()).toEqual([ + "/", + "/cloud-hello/", + "/en/", + "/en/cloud-hello/", + "/en/hello/", + "/hello/", + ]); + expect(allPrerenderPaths()).toEqual([...allPrerenderPaths()].sort()); + }); + + test("publishes no private Account, Chat, Operations, API or runtime-config route", () => { + const document = publicManifestDocument(); + const routePaths = document.pages.flatMap((page) => page.paths); + expect(routePaths.some((path) => /^\/(?:account|chat|ops|api)(?:\/|$)/u.test(path))).toBe( + false, + ); + expect(JSON.stringify(document)).not.toMatch( + /(?:clientSecret|apiKey|privatePrice|accountRoute|chatRoute)/iu, + ); + expect(publicSiteManifest.redirects).toContainEqual({ + source: "/account", + destination: "https://account.arcforges.com/", + status: 308, + }); + }); + + test("generates deterministic canonical metadata, language alternates, sitemap and redirects", () => { + const home = pageById("home"); + const homeMetadata = { + ...home, + canonicalUrl: "https://arcforges.com/en/", + alternateLanguages: [{ locale: "en", href: "https://arcforges.com/en/" }], + }; + expect(metadataForPage(homeMetadata)).toContainEqual({ title: "Hello, world. — ArcForges" }); + expect(metadataForPage(homeMetadata)).toContainEqual({ + tagName: "link", + rel: "canonical", + href: "https://arcforges.com/en/", + }); + expect(metadataForPage(homeMetadata)).toContainEqual({ + tagName: "link", + rel: "alternate", + hrefLang: "en", + href: "https://arcforges.com/en/", + }); + expect(renderSitemapXml()).toContain("https://arcforges.com/en/"); + expect(renderSitemapXml()).not.toContain("/account"); + expect(renderRedirectRules()).toBe("/account https://account.arcforges.com/ 308\n"); + }); + + test("a named test-only fixture covers multiple locales and documentation versions", () => { + const fixture = testOnlyVersionedManifest(); + validatePublicSiteManifest(fixture); + expect(allPrerenderPaths(fixture)).toContain("/fr/"); + expect(allPrerenderPaths(fixture)).toContain("/fr/hello/"); + expect(allPrerenderPaths(fixture)).toContain("/docs/arcscope/v1/"); + expect(allPrerenderPaths(fixture)).toContain("/en/docs/arcscope/v2/"); + expect(publicManifestDocument(fixture).documentationVersions).toEqual(["v1", "v2"]); + expect(sitemapUrls(fixture)).toContain("https://arcforges.com/fr/"); + expect(sitemapUrls(fixture)).toContain("https://arcforges.com/en/docs/arcscope/v2/"); + expect(renderRedirectRules(fixture)).toContain("/old-docs /en/docs/arcscope/v2/ 308"); + expect(loadLocalizedPage({ locale: undefined, "*": "arcscope/v2/" }, fixture).kind).toBe( + "document", + ); + expect(loadLocalizedPage({ locale: undefined, "*": "docs/arcscope/v2/" }, fixture).kind).toBe( + "document", + ); + expect(loadLocalizedPage({ locale: undefined, "*": "fr/hello/" }, fixture).locale).toBe("fr"); + expect(loadLocalizedPage({ locale: "fr", "*": "hello/" }, fixture).locale).toBe("fr"); + expect(localeForPath("/fr/hello/", fixture)).toBe("fr"); + expect(localeForPath("/hello/", fixture)).toBe("en"); + const shuffled = { + ...fixture, + locales: [...fixture.locales].reverse(), + pages: [...fixture.pages].reverse(), + redirects: [...fixture.redirects].reverse(), + }; + expect(allPrerenderPaths(shuffled)).toEqual(allPrerenderPaths(fixture)); + expect(publicManifestDocument(shuffled)).toEqual(publicManifestDocument(fixture)); + expect(renderSitemapXml(shuffled)).toBe(renderSitemapXml(fixture)); + expect(renderRedirectRules(shuffled)).toBe(renderRedirectRules(fixture)); + + const homeAlternates = fixture.pages + .filter((page) => page.id === "home") + .map((page) => ({ + locale: page.locale, + href: new URL(page.localizedPath, fixture.origin).href, + })); + const frenchHome = fixture.pages.find((page) => page.id === "home" && page.locale === "fr"); + if (!frenchHome) throw new Error("The test-only locale fixture is missing its French home"); + const frenchMetadata = metadataForPage({ + title: frenchHome.title, + description: frenchHome.description, + canonicalUrl: new URL(frenchHome.localizedPath, fixture.origin).href, + alternateLanguages: homeAlternates, + }); + expect(frenchMetadata).toContainEqual({ + tagName: "link", + rel: "canonical", + href: "https://arcforges.com/fr/", + }); + expect(frenchMetadata).toContainEqual({ + tagName: "link", + rel: "alternate", + hrefLang: "en", + href: "https://arcforges.com/en/", + }); + expect(frenchMetadata).toContainEqual({ + tagName: "link", + rel: "alternate", + hrefLang: "fr", + href: "https://arcforges.com/fr/", + }); + }); + + test("rejects route collisions, private route additions and redirect traps", () => { + const base = publicSiteManifest as PublicSiteManifest; + const englishHome = pageById("home"); + expect(() => + validatePublicSiteManifest({ + ...base, + pages: [...base.pages, { ...englishHome, localizedPath: "/en/" }], + }), + ).toThrow(/Invalid or duplicate public page\/locale/u); + expect(() => + validatePublicSiteManifest({ + ...base, + pages: [ + ...base.pages, + { + id: "private", + kind: "hello", + locale: "en", + localizedPath: "/en/account/", + title: "Private route", + description: "This private route must not enter the public site.", + }, + ], + }), + ).toThrow(/Private application path/u); + expect(() => + validatePublicSiteManifest({ + ...base, + redirects: [{ source: "/loop/", destination: "/loop/", status: 308 }], + }), + ).toThrow(/Redirect loop/u); + expect(() => + validatePublicSiteManifest({ + ...base, + redirects: [ + { source: "/old-a/", destination: "/old-b/", status: 308 }, + { source: "/old-b/", destination: "/old-a/", status: 308 }, + ], + }), + ).toThrow(/Redirect loop/u); + expect(() => + validatePublicSiteManifest({ + ...base, + redirects: [{ source: "/hello/", destination: "/en/hello/", status: 308 }], + }), + ).toThrow(/collides with a public route/u); + expect(() => + validatePublicSiteManifest({ + ...base, + pages: base.pages.map((page) => + page.id === "home" + ? { ...page, primaryLink: { href: "/account/", label: "Account" } } + : page, + ), + }), + ).toThrow(/known public route/u); + expect(() => + validatePublicSiteManifest({ + ...base, + redirects: [ + { source: "/old/", destination: "https://arcforges.com/account/", status: 308 }, + ], + }), + ).toThrow(/known public route/u); + }); + + test("a one-page metadata edit stays local to that page's public inventory", () => { + const original = publicSiteManifest as PublicSiteManifest; + const changed: PublicSiteManifest = { + ...original, + pages: original.pages.map((page) => + page.id === "home" ? { ...page, description: `${page.description} Revised.` } : page, + ), + }; + const before = publicManifestDocument(original); + const after = publicManifestDocument(changed); + expect(after.pages.find((page) => page.id === "home")?.description).toBe( + `${before.pages.find((page) => page.id === "home")?.description} Revised.`, + ); + expect(after.pages.filter((page) => page.id !== "home")).toEqual( + before.pages.filter((page) => page.id !== "home"), + ); + expect(renderSitemapXml(changed)).toBe(renderSitemapXml(original)); + expect(renderRedirectRules(changed)).toBe(renderRedirectRules(original)); + }); + + test("the built HTML is complete before scripts and generated public artifacts match the inventory", async () => { + const home = await readFile(join(clientRoot, "index.html"), "utf8"); + const localizedHome = await readFile(join(clientRoot, "en", "index.html"), "utf8"); + const notFound = await readFile(join(clientRoot, "404.html"), "utf8"); + const emittedManifest = await readFile(join(clientRoot, "site-manifest.json"), "utf8"); + const emittedSitemap = await readFile(join(clientRoot, "sitemap.xml"), "utf8"); + const emittedRedirects = await readFile(join(clientRoot, "_redirects"), "utf8"); + + for (const html of [home, localizedHome]) { + expect(html).toContain("A beginning you can explore."); + expect(html).toContain("Make it your hello"); + expect(html).toContain('rel="canonical"'); + expect(html).toMatch(/hreflang="en"/iu); + expect(html).toContain('name="robots" content="noindex, nofollow"'); + } + const helloAnchor = 'Make it your hello'; + const helloAnchorIndex = home.indexOf(helloAnchor); + const firstScriptIndex = home.indexOf(" { const receipt = await json(path.join(candidate, "provenance/receipt.json")); - assert.equal(receipt.parsedModules, 303); - assert.equal(receipt.emittedModules, 136); + assert.equal(receipt.parsedModules, 260); + assert.equal(receipt.emittedModules, 129); const bom = await json(path.join(candidate, "runtime-sbom.cdx.json")); const names = new Set(bom.components.map((component: { name: string }) => component.name)); for (const name of [ @@ -29,6 +38,143 @@ test("the actual compiled candidate has the complete browser and legal closure", assert(!names.has("isbot") && !names.has("cookie-es")); }); +const r8ProfilePath = "eng/provenance/profiles/browser-resources-r8.json"; +const expectedR8SourcePaths = [ + "node_modules/@bufbuild/protobuf/dist/esm/wkt/gen/google/protobuf/api_pb.js", + "node_modules/@connectrpc/connect-web/dist/esm/assert-fetch-api.js", + "packages/protobuf/src/wkt/gen/google/protobuf/api_pb.ts", + "packages/connect-web/src/assert-fetch-api.ts", + "packages/shared/ReactFlightPropertyAccess.js", +]; +type DigestBinding = { path: string; digest: string }; + +function literalArray(block: string, key: string) { + const start = block.indexOf(`${key} = [`); + assert.notEqual(start, -1, `Missing ${key} array in Gitleaks allowlist`); + const contentStart = start + `${key} = [`.length; + const firstLineEnd = block.indexOf("\n", contentStart); + const firstLine = block.slice(contentStart, firstLineEnd === -1 ? block.length : firstLineEnd); + const inlineClose = firstLine.lastIndexOf("]"); + const end = inlineClose === -1 ? block.indexOf("\n]", contentStart) : contentStart + inlineClose; + assert.notEqual(end, -1, `Unterminated ${key} array in Gitleaks allowlist`); + return block + .slice(contentStart, end) + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + assert(line.startsWith("'''"), `Expected TOML literal string in ${key}`); + assert(line.endsWith("''',") || line.endsWith("'''"), `Malformed ${key} entry`); + return line.endsWith("''',") ? line.slice(3, -4) : line.slice(3, -3); + }); +} + +function isSha256(value: unknown): value is string { + return ( + typeof value === "string" && + value.length === 64 && + [...value].every((character) => "0123456789abcdef".includes(character)) + ); +} + +function profileBindings(value: unknown): DigestBinding[] { + if (Array.isArray(value)) return value.flatMap(profileBindings); + if (value === null || typeof value !== "object") return []; + return Object.entries(value).flatMap(([key, child]) => [ + ...(expectedR8SourcePaths.includes(key) && isSha256(child) + ? [{ path: key, digest: child }] + : []), + ...profileBindings(child), + ]); +} + +async function r8GitleaksFixture() { + const root = process.cwd(); + const config = await readFile(path.join(root, ".gitleaks.toml"), "utf8"); + const profile = await json(path.join(root, r8ProfilePath)); + const blocks = config + .split(/(?=^\[\[allowlists\]\]\s*$)/mu) + .filter((block) => block.startsWith("[[allowlists]]")); + const applicable = blocks.filter((block) => + literalArray(block, "paths").some((pattern) => new RegExp(pattern).test(r8ProfilePath)), + ); + assert.equal(applicable.length, 1, "r8 must have exactly one applicable allowlist"); + + const [block] = applicable; + assert(block); + assert(block.split(/\r?\n/u).includes('condition = "AND"')); + assert(block.split(/\r?\n/u).includes('targetRules = ["generic-api-key"]')); + assert(block.split(/\r?\n/u).includes('regexTarget = "line"')); + assert.deepEqual(literalArray(block, "paths"), [ + String.raw`^eng/provenance/profiles/browser-resources-r8\.json$`, + ]); + + const observedRows = profileBindings(profile); + const uniqueBindings = [ + ...new Map( + observedRows.map((binding) => [`${binding.path}\n${binding.digest}`, binding]), + ).values(), + ]; + assert.equal(observedRows.length, 8, "The r8 profile must retain all eight observed rows"); + assert.equal( + uniqueBindings.length, + 6, + "The eight rows must contain exactly six unique path/digest bindings", + ); + assert.deepEqual( + [...new Set(uniqueBindings.map(({ path: sourcePath }) => sourcePath))].sort(), + [...expectedR8SourcePaths].sort(), + "Only the five observed public source paths may be bound", + ); + + const expectedPatterns = uniqueBindings + .map(({ path: sourcePath, digest }) => { + const escapedPath = sourcePath.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + return `^\\s*"${escapedPath}": "${digest}",?$`; + }) + .sort(); + const patterns = literalArray(block, "regexes"); + assert.deepEqual( + [...patterns].sort(), + expectedPatterns, + "Only the six exact profile-backed bindings may be ignored", + ); + + const linePatterns = patterns.map((pattern) => new RegExp(pattern)); + const pathPatterns = literalArray(block, "paths").map((pattern) => new RegExp(pattern)); + const matches = (file: string, line: string) => + pathPatterns.some((pattern) => pattern.test(file)) && + linePatterns.some((pattern) => pattern.test(line)); + const formatRow = (file: string, digest: string) => `"${file}": "${digest}",`; + for (const { path: sourcePath, digest } of observedRows) + assert(matches(r8ProfilePath, formatRow(sourcePath, digest))); + + return { uniqueBindings, matches, formatRow }; +} + +test("the r8 Gitleaks exception exactly matches profile-backed digest bindings", async () => { + const { uniqueBindings, matches, formatRow } = await r8GitleaksFixture(); + for (const { path: sourcePath, digest } of uniqueBindings) + assert(matches(r8ProfilePath, formatRow(sourcePath, digest))); +}); + +test("the r8 Gitleaks exception rejects digest, path and credential mutations", async () => { + const { uniqueBindings, matches, formatRow } = await r8GitleaksFixture(); + const first = uniqueBindings[0]; + assert(first); + const changedDigest = `${first.digest.slice(0, -1)}${first.digest.endsWith("0") ? "1" : "0"}`; + const differentSourcePath = `${first.path}.unreviewed`; + const otherProfilePath = "eng/provenance/profiles/browser-resources-r9.json"; + const unrelatedDigest = "f".repeat(64); + const credentialLine = ['"api', '_key": "', "ghp", "_", "example", '"'].join(""); + + assert(!matches(r8ProfilePath, formatRow(first.path, changedDigest))); + assert(!matches(r8ProfilePath, formatRow(differentSourcePath, first.digest))); + assert(!matches(otherProfilePath, formatRow(first.path, first.digest))); + assert(!matches(r8ProfilePath, formatRow(first.path, unrelatedDigest))); + assert(!matches(r8ProfilePath, credentialLine)); +}); + async function reseal(root: string) { const receipt = await json(path.join(root, "provenance/receipt.json")); receipt.members = Object.fromEntries( @@ -81,7 +227,18 @@ rejects( rejects( "reject omitted compiled modules", async (root) => { - const file = (await files(root)).find((file) => /\/hello_pb-.*\.js$/u.test(file)); + const profile = await json(path.join(root, "provenance/profile.json")); + const chunk = profile.graph.chunks.find( + (item: { modules: string[] }) => item.modules.length > 0, + ); + assert(chunk); + const stem = path.posix.basename(chunk.name).replace(/\.js$/u, ""); + const file = (await files(root)).find( + (file) => + file.startsWith("assets/assets/") && + file.endsWith(".js") && + path.posix.basename(file).startsWith(`${stem}-`), + ); assert(file); await rm(path.join(root, file)); }, @@ -134,7 +291,10 @@ rejects( rejects( "reject a modified used record", async (root) => { - const file = "provenance/records/browser-resources-r2.json"; + const receipt = await json(path.join(root, "provenance/receipt.json")); + const id = receipt.records.find((value: string) => value.startsWith("browser-resources-")); + assert(id); + const file = `provenance/records/${id}.json`; const value = await json(path.join(root, file)); value.review.rationale = "forged"; await save(path.join(root, file), value); @@ -153,7 +313,10 @@ rejects( rejects( "reject reformatting of immutable candidate records", async (root) => { - await edit(root, "provenance/records/browser-resources-r2.json", (text) => `${text}\n`); + const receipt = await json(path.join(root, "provenance/receipt.json")); + const id = receipt.records.find((value: string) => value.startsWith("browser-resources-")); + assert(id); + await edit(root, `provenance/records/${id}.json`, (text) => `${text}\n`); }, /Candidate record bytes changed/u, ); @@ -173,6 +336,21 @@ rejects( }, /Security\/cache headers changed/u, ); +rejects( + "reject CSP that omits exact scripts from localized prerenders", + async (root) => { + const pages = await Promise.all( + ["assets/index.html", "assets/hello/index.html", "assets/cloud-hello/index.html"].map( + (file) => readFile(path.join(root, file), "utf8"), + ), + ); + const csp = contentSecurityPolicy(pages); + await edit(root, "assets/_headers", (text) => + text.replace(/^ {2}Content-Security-Policy:.*$/mu, ` Content-Security-Policy: ${csp}`), + ); + }, + /Security\/cache headers changed/u, +); rejects( "reject browser SBOM dependency-edge tampering", async (root) => { @@ -194,9 +372,11 @@ rejects( rejects( "reject changed Contracts source identity", async (root) => { - await edit(root, "contracts/proto/source.json", (text) => - text.replace("1fb1dfaaaaa7a9f2f4c64a6e1c6a2b7de47d67b0", "a".repeat(40)), - ); + const file = path.join(root, "contracts/proto/source.json"); + const value = await json(file); + assert.match(value.commit, /^[a-f0-9]{40}$/u); + value.commit = `${value.commit[0] === "0" ? "1" : "0"}${value.commit.slice(1)}`; + await save(file, value); }, /Published Contracts member changed/u, ); diff --git a/tests/unit/hello.test.tsx b/tests/unit/hello.test.tsx index 3d4ac7a..7657bca 100644 --- a/tests/unit/hello.test.tsx +++ b/tests/unit/hello.test.tsx @@ -2,24 +2,32 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { createMemoryRouter, RouterProvider } from "react-router"; import { afterEach, expect, test } from "vitest"; import { greet } from "../../apps/site/app/hello"; -import Hello from "../../apps/site/app/routes/hello"; +import Hello, { loader } from "../../apps/site/app/routes/hello"; afterEach(cleanup); test("published protobuf round-trip preserves Unicode names", () => { expect(greet(" 世界 🌍 ")).toBe("Hello, 世界 🌍!"); expect(greet("🌍".repeat(80))).toBe(`Hello, ${"🌍".repeat(80)}!`); for (const invalid of [" ", "x".repeat(81), "hi\u0000"]) expect(() => greet(invalid)).toThrow(); }); -test("form validates, recovers and renders untrusted names as text", () => { - render(); - const input = screen.getByLabelText("Your name"); - fireEvent.change(input, { target: { value: "" } }); - fireEvent.click(screen.getByRole("button", { name: "Say hello" })); - expect(screen.getByRole("alert")).toBeVisible(); - fireEvent.change(input, { target: { value: "" } }); - fireEvent.click(screen.getByRole("button", { name: "Say hello" })); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(screen.getByRole("status")).toHaveTextContent("Hello, !"); - expect(document.querySelector("img")).toBeNull(); +test("form validates, recovers and renders untrusted names as text", async () => { + const router = createMemoryRouter([{ path: "/hello", loader, element: }], { + initialEntries: ["/hello"], + }); + try { + render(); + const input = await screen.findByLabelText("Your name"); + fireEvent.change(input, { target: { value: "" } }); + fireEvent.click(screen.getByRole("button", { name: "Say hello" })); + expect(screen.getByRole("alert")).toBeVisible(); + fireEvent.change(input, { target: { value: "" } }); + fireEvent.click(screen.getByRole("button", { name: "Say hello" })); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Hello, !"); + expect(document.querySelector("img")).toBeNull(); + } finally { + router.dispose(); + } }); diff --git a/tooling/browser-provenance.ts b/tooling/browser-provenance.ts index dc063e4..8f8cd4a 100644 --- a/tooling/browser-provenance.ts +++ b/tooling/browser-provenance.ts @@ -7,7 +7,7 @@ import type { Plugin } from "vite"; import { auditProvenance, hash, parseDocument, readOwned, relative, store } from "./provenance.ts"; const owner = path.resolve(import.meta.dirname, ".."); -export const profilePath = "eng/provenance/profiles/browser-resources-r7.json"; +export const profilePath = "eng/provenance/profiles/browser-resources-r8.json"; const sha = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex"); const lf = (value: Buffer) => new TextDecoder("utf-8", { fatal: true }).decode(value).replaceAll("\r\n", "\n"); diff --git a/tooling/project.ts b/tooling/project.ts index b6db8b0..ccaab5c 100644 --- a/tooling/project.ts +++ b/tooling/project.ts @@ -167,9 +167,9 @@ export async function verify( ]) assert(manifest.files[path], `Missing required file: ${path}`); const html = await Promise.all( - ["index.html", "hello/index.html", "cloud-hello/index.html"].map((file) => - readFile(join(directory, "assets", file), "utf8"), - ), + (await files(join(directory, "assets"))) + .filter((file) => file.endsWith(".html")) + .map((file) => readFile(join(directory, "assets", file), "utf8")), ); verifyBrowserCandidate(directory, manifest, await notices(), securityHeaders(html)); const { $schema: _schema, ...expectedConfig } = await json(join(root, "wrangler.json"));