diff --git a/AGENTS.md b/AGENTS.md index c42673f..140b4b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ - Read README and the relevant development/deployment guide before changes. Use English for source and repository documentation. - Collect the relevant issues, decide a bounded plan, implement and verify. Use a separate worktree; leave adjacent repositories and primary checkouts unchanged. - Preserve AGPL-3.0-only and third-party notices. Consume exact published Contracts packages; no sibling source imports or submodules. -- Use the pinned Node/npm toolchain, strict TypeScript, one root workspace lockfile and the documented commands. Business APIs, sessions, billing and AI execution are outside this static Web bootstrap. +- Use the pinned Node/npm toolchain, strict TypeScript, one root workspace lockfile and the documented commands. The server connection page only prepares the published Hello packaging example; business APIs, sessions, billing and AI execution remain outside this Web bootstrap. - Keep PR checks credential-free. Never print or commit tokens or put private values in browser bundles. A Cloudflare API token belongs in the GitHub environment secret, not a client variable. - Build and verify once, then deploy those same bytes. Distinguish mocked contract tests, local production-browser tests, real Cloudflare delivery and future C# product integration. - Follow the user's authorization for remote settings/deployment. Do not merge a PR without authorization. diff --git a/README.md b/README.md index a39cd51..24a9222 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ React and TypeScript Web foundation for the ArcForges family. This first increment contains a public Hello World site, an interactive local greeting, shared UI, published Contracts consumption and an automated Cloudflare Workers Static Assets delivery pipeline. -It does not implement the planned Account/Chat application, authentication, payments or a C# backend. The greeting runs locally and sends no name to a server. Business authority remains in ArcForges Cloud. +It does not implement the planned Account/Chat application, authentication, payments or a C# backend. The `/hello/` greeting runs locally and sends no name to a server. A separate `/cloud-hello/` page prepares the published gRPC-Web Hello call for the future Cloud container; it currently reports unavailable until that service is deployed. Business authority remains in ArcForges Cloud. ## Start locally @@ -30,7 +30,7 @@ The preview serves the actual candidate through local Wrangler at `http://127.0. | Path | Purpose | | ----------------------------------- | --------------------------------------------------------------------- | -| `apps/site` | Prerendered home and `/hello` pages | +| `apps/site` | Prerendered home, local greeting and server connection pages | | `apps/app` | Documented boundary for the future Account/Chat profiles | | `packages/ui` | Shared components and Tailwind/CSS styles | | `tooling` | TypeScript build, provenance, policy and Cloudflare delivery commands | @@ -46,7 +46,7 @@ PRs run source checks on Linux/Windows, dependency auditing/review, secret scann The main-only GitHub `cloudflare` environment contains the account variable and deployment secret. The custom-domain binding is managed in Cloudflare; CI verifies that it belongs to this Worker before deploying. PR checks remain credential-free. See [deployment setup and recovery](docs/deploying.md) and [evidence](docs/validation.md). -Workers Static Assets supports this static React build directly. Frameworks that need request-time server code require a Workers-compatible adapter/runtime. This setup does not host C# or provide an API proxy. See the [official React guide](https://developers.cloudflare.com/workers/framework-guides/web-apps/react/) and [static assets guide](https://developers.cloudflare.com/workers/static-assets/get-started/). +Workers Static Assets supports this static React build directly. Frameworks that need request-time server code require a Workers-compatible adapter/runtime. This setup does not host C# or provide an API proxy. The future Cloud Worker will own the same-origin `/api/*` route and forward to its Native AOT container; see the [Hello integration boundary and remaining Cloud setup](docs/cloud-hello.md). See also the [official React guide](https://developers.cloudflare.com/workers/framework-guides/web-apps/react/) and [static assets guide](https://developers.cloudflare.com/workers/static-assets/get-started/). ## Contribute diff --git a/apps/site/app/cloud-hello.ts b/apps/site/app/cloud-hello.ts new file mode 100644 index 0000000..47e2068 --- /dev/null +++ b/apps/site/app/cloud-hello.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { createHelloClient } from "@arcforges/api-client"; + +export const helloApiPath = "/api/arcforges.hello.v1.HelloService/SayHello"; + +export async function checkServerConnection( + origin: string, + signal: AbortSignal, + fetcher: typeof fetch = fetch, +) { + const client = createHelloClient({ + baseUrl: new URL("/api", origin).href, + useBinaryFormat: true, + defaultTimeoutMs: 10000, + fetch: (input, init) => fetcher(input, { ...init, credentials: "omit", redirect: "error" }), + }); + const response = await client.sayHello({ name: "ArcForges" }, { signal }); + if (response.message !== "Hello, ArcForges!") throw new Error("Unexpected Hello response"); + return response.message; +} diff --git a/apps/site/app/routes.ts b/apps/site/app/routes.ts index 6b646cf..48c1847 100644 --- a/apps/site/app/routes.ts +++ b/apps/site/app/routes.ts @@ -1,3 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { index, route, type RouteConfig } from "@react-router/dev/routes"; -export default [index("routes/home.tsx"), route("hello", "routes/hello.tsx")] satisfies RouteConfig; +export default [ + index("routes/home.tsx"), + route("hello", "routes/hello.tsx"), + route("cloud-hello", "routes/cloud-hello.tsx"), +] satisfies RouteConfig; diff --git a/apps/site/app/routes/cloud-hello.tsx b/apps/site/app/routes/cloud-hello.tsx new file mode 100644 index 0000000..d2251fd --- /dev/null +++ b/apps/site/app/routes/cloud-hello.tsx @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { Button } from "@arcforges/web-ui"; +import { useEffect, useRef, useState } from "react"; +import { checkServerConnection } from "../cloud-hello"; + +export function meta() { + return [{ title: "Server connection — ArcForges" }]; +} + +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]; + + 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}

+
+
+ ); +} diff --git a/apps/site/app/routes/hello.tsx b/apps/site/app/routes/hello.tsx index 6bf94c9..7dcdec1 100644 --- a/apps/site/app/routes/hello.tsx +++ b/apps/site/app/routes/hello.tsx @@ -74,6 +74,9 @@ export default function Hello() { Your greeting

{message}

+

+ Check the server connection → +

); } diff --git a/apps/site/react-router.config.ts b/apps/site/react-router.config.ts index cc37601..30403bd 100644 --- a/apps/site/react-router.config.ts +++ b/apps/site/react-router.config.ts @@ -1,4 +1,4 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Config } from "@react-router/dev/config"; -export default { ssr: false, prerender: ["/", "/hello"] } satisfies Config; +export default { ssr: false, prerender: ["/", "/hello", "/cloud-hello"] } satisfies Config; diff --git a/docs/cloud-hello-plan.md b/docs/cloud-hello-plan.md new file mode 100644 index 0000000..7f91191 --- /dev/null +++ b/docs/cloud-hello-plan.md @@ -0,0 +1,25 @@ +# Cloud Hello preparation + +## Boundary and findings + +The owner selected a future C# Native AOT service in Cloudflare Containers. The custom-domain site is already deployed and its real three-browser suite passes. The local ArcForges directory has no Cloud checkout and no Cloud service is available for integration yet. + +Web currently uses published Contracts `1.0.0-ci.25.1`, whose `HelloService.SayHello` is an explicitly non-product packaging example. The existing greeting is entirely local. This increment prepares a separate server connection example without claiming to implement the Cloud service, authentication, billing or AI. + +The existing live delivery gate also expects Web's HTML 404 at `/api/*`. That assertion would conflict with the future Cloud route. Keep API fallback checks in local candidate tests; live Web verification must check only Web-owned paths. Cloud owns the public API's response assertions. + +## Decisions and implementation order + +1. Keep static Web delivery on `arcforges.com`. Add `/cloud-hello/` as a prerendered connection page. It sends a fixed `ArcForges` greeting only after an explicit click; the name entered in the existing local example is never sent. +2. Use the existing published `@arcforges/api-client` in binary gRPC-Web mode, with same-origin base `/api`. The exact request path is `/api/arcforges.hello.v1.HelloService/SayHello`. No REST replacement, new schema, registry package or browser secret is needed. +3. Use a ten-second deadline, cancel on navigation, suppress concurrent clicks and perform no automatic retries. Check for the contract's exact expected reply. Show unavailable/invalid responses as failure, never a locally generated successful server response. +4. The future Cloud repository will own a Worker route `arcforges.com/api/*`, forwarding requests to its C# container after removing `/api`. This route can precede the existing Web Custom Domain. Web needs neither an API proxy Worker nor a deployment-time binding to a service that does not exist yet. +5. Add browser tests with explicitly labelled gRPC-Web fixtures for request framing, success, unavailable service and recovery. Keep the local API 404 check alongside those local-only tests. Live Web tests check the idle page and Web-owned static paths without asserting Cloud's responses or simulating a backend. Record the separate gate that the future Cloud deployment must satisfy. + +## Closure and evidence + +Run source checks, candidate build/verification and the three-browser candidate suite; inspect the new page. PR CI must pass without credentials or access to a live C# service. Real Web delivery is distinct from the fixture tests. The actual Native AOT container, public API route and C#/protobuf transport require Cloud's later real integration test; they are not accepted by this Web PR. + +Post-implementation review corrected the live API-path ownership assertion and documented the static Worker's actual GET 404 / POST 405 behavior. Local source, candidate and all eighteen browser checks now pass; the live suite excludes the fixture cases. See [validation evidence](validation.md). No Cloud infrastructure or formal Design document was changed. + +Reference: [Cloudflare Custom Domains and routes](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/#interaction-with-routes). diff --git a/docs/cloud-hello.md b/docs/cloud-hello.md new file mode 100644 index 0000000..90809f1 --- /dev/null +++ b/docs/cloud-hello.md @@ -0,0 +1,40 @@ +# Connecting the future Cloud service + +## Current Web behavior + +`/hello/` remains local. `/cloud-hello/` uses the published Contracts client to send one fixed diagnostic greeting after the user clicks **Check connection**. It sends no user-entered name, account cookie, authorization header or Cloudflare management token. Requests have a ten-second deadline, are cancelled on navigation, and are never automatically retried. Only the expected server response is displayed as success. + +No Cloud service is currently deployed by this repository. Until its API route exists, the static Worker rejects the Hello POST with 405 Method Not Allowed; a GET at that missing path returns 404. The page shows an unavailable response. No mock is deployed and no successful response is generated locally. + +## Fixed integration boundary + +| Item | Value | +| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Browser SDK | `@arcforges/api-client` and `@arcforges/proto`, both `1.0.0-ci.25.1` | +| Browser base URL | Same origin, `/api` | +| Public method | `POST https://arcforges.com/api/arcforges.hello.v1.HelloService/SayHello` | +| Container method after prefix removal | `/arcforges.hello.v1.HelloService/SayHello` | +| Transport | Unary binary gRPC-Web, `application/grpc-web+proto` | +| Request | Published `SayHelloRequest`, `name = "ArcForges"` | +| Expected reply | Published `SayHelloResponse`, `message = "Hello, ArcForges!"` | +| Authority | [Contracts Hello schema](https://github.com/ArcForges/Contracts/blob/main/public/proto/arcforges/hello/v1/hello.proto), a packaging example, not a product API | + +The Web origin stays `https://arcforges.com`; CSP keeps `connect-src 'self'` and no cross-origin exception is needed. A later account/authenticated API must define its own session behavior; this anonymous diagnostic must not silently start forwarding credentials. + +## Work owned by the future Cloud repository + +1. Build and test the actual C# Native AOT Linux container implementing this published wire contract. Cloudflare currently requires a `linux/amd64` image. Validate its AOT build, startup, gRPC-Web response/trailer framing, and failure statuses; the existing Contracts HelloHost example alone is not AOT evidence. +2. Deploy its Cloud Worker and Container binding in the same Cloudflare account. Forward to the container through that binding, removing only the leading `/api` from the public path. Preserve request/response protobuf bytes and gRPC-Web content type, statuses and framed trailers. Do not convert the payload to ad-hoc JSON or forward back to the public API URL. +3. Attach **Worker route** `arcforges.com/api/*` to the Cloud Worker. Keep the apex **Custom Domain** attached to `arcforges-web`. A route runs ahead of that custom-domain origin. The API route needs no second DNS hostname, browser token, Web service binding or Web rebuild. Unknown API methods must return an API error/404 rather than Web HTML. +4. Keep the example explicitly bounded: only the Hello diagnostic, with input/resource limits and no model call, paid user operation or database mutation. Authentication, quotas and commercial APIs remain a separate product increment. Worker/Container account permissions and plan availability are configured when Cloud is implemented. +5. Cloud's deployment gate must invoke the **public same-origin method using the published client**, verify the expected protobuf reply, and verify both success and failure behavior. Then check the button in the deployed Web page. A container health endpoint, mocked fixture or Web deployment alone does not establish this chain. + +This repository does not provision the missing Cloud Worker, container image, API route, billing plan or credentials. + +## Evidence and recovery + +`cloud-hello-fixture.spec.ts` intercepts the browser API request with explicitly labelled protobuf wire fixtures. It verifies the actual published client's request, unavailable response and recovery. It is excluded from live Web verification so mocked success cannot be reported as a real C# integration. Live Web tests check that the new page loads and sends nothing automatically. + +Web delivery still verifies its assets and Web-owned public 404 behavior. Candidate-only tests verify that the static Worker cannot fake a successful API response; the live Web gate does not require Web HTML at `/api/*`, because Cloud will own those paths. Cloud owns the later API/container deployment and its independent real integration gate. Removing Cloud's API route restores the static Worker's rejection of that API request; the connection page reports failure instead of silently falling back to a local greeting. + +References: [routes before a Custom Domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/#interaction-with-routes), [Cloudflare Containers setup](https://developers.cloudflare.com/containers/get-started/). diff --git a/docs/deploying.md b/docs/deploying.md index 8f60810..390ea3f 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -36,7 +36,7 @@ All content is static and public. HTML revalidates, hashed `/assets/*` files are The source link identifies the candidate's source revision. Public content contains no secret; open-source code does not grant deployment authority. Adding a backend/AI proxy in future requires its own authentication, authorization, quota and abuse controls. This static preview has no paid model invocation path. -Use `https://arcforges.com`. The former Workers subdomain was another public entry to the same deployment, not a staging environment. Do not re-enable it merely to run CI. Disabling it in the dashboard alone is insufficient if a later Wrangler configuration enables it; the source configuration is authoritative. CORS for C# Cloud, auth cookies, API origins and production profile separation remain future integration work. +Use `https://arcforges.com`. The former Workers subdomain was another public entry to the same deployment, not a staging environment. Do not re-enable it merely to run CI. Disabling it in the dashboard alone is insufficient if a later Wrangler configuration enables it; the source configuration is authoritative. The [Cloud Hello boundary](cloud-hello.md) prepares an anonymous same-origin API call. Authenticated sessions and production profile separation remain future integration work. Live Web delivery checks only Web-owned paths; it must not require its HTML 404 at `/api/*` once Cloud owns that route. ## Failure and recovery diff --git a/docs/development.md b/docs/development.md index b79c23f..0a0dc25 100644 --- a/docs/development.md +++ b/docs/development.md @@ -23,9 +23,9 @@ Open `win.slnx` with a Visual Studio release supporting the JavaScript project S ## Application boundaries -- React Router framework mode is configured with `ssr: false` and build-time prerendering of `/` and `/hello`. A temporary server build is used by the framework during prerendering and excluded from the candidate. Its generated SPA fallback is also excluded. +- React Router framework mode is configured with `ssr: false` and build-time prerendering of `/`, `/hello` and `/cloud-hello`. A temporary server build is used by the framework during prerendering and excluded from the candidate. Its generated SPA fallback is also excluded. - Initial text and links work without JavaScript. Greeting controls remain disabled until hydration, cannot submit names as native form query parameters and are covered by `form-action 'none'`. Names are trimmed, limited to 80 Unicode code points and reject control characters. React renders the greeting as text. -- The example serializes real `@arcforges/proto` messages locally. `@arcforges/api-client` is tested with binary gRPC-Web success and failure fixtures. No Cloud endpoint or credential is invented. Real Cloud integration requires its published API, session and CORS decisions. +- The local example serializes real `@arcforges/proto` messages. The separate server connection page uses `@arcforges/api-client` against same-origin `/api`, with the published Hello contract and an explicitly unavailable state until Cloud is deployed. Browser protobuf fixtures are separate from live verification. See [Cloud Hello](cloud-hello.md) for the exact endpoint and remaining backend acceptance. - No AI, database, analytics, service worker, privileged proxy or user-data storage is included. The production custom domain is `arcforges.com`. React Router's scroll restoration may store scroll positions in session storage. - Future Account/Chat/operator/status delivery profiles remain separate work. Shared components live in `packages/ui`; profiles must not import business source from adjacent repositories. diff --git a/docs/validation.md b/docs/validation.md index 6be5760..2579a4a 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -21,6 +21,17 @@ Evidence is recorded separately for source checks, local production assets, host Custom-domain local validation passed the pinned restore, source checks, nine unit/component/delivery tests, candidate build, twelve Chromium/Firefox/WebKit tests and post-browser candidate verification. The live browser gate itself requires the new configuration to be deployed; local results do not establish that the Cloudflare edge honors `no-transform`. +## Custom-domain hosted delivery + +The custom-domain change and its bounded propagation correction are now deployed: [main run 35033094886](https://github.com/ArcForges/Web/actions/runs/35033094886) passed on source `0405df4b85c36f32988b0847d8c53fc0c80f1c24`, version `0.1.0-ci.10.1`. Its real file/header/404 checks and twelve Chromium/Firefox/WebKit tests passed, and the verified release was created. An independent public check returned that identity from `https://arcforges.com/__build.json` and HTTP 404 from the disabled `arcforges-web.sammilergood.workers.dev` entry. This establishes the existing site's delivery, not the subsequent Cloud connection feature or a running C# service. + +## Cloud Hello preparation: local evidence + +- Source checks passed with twelve unit/component/SDK/delivery tests after rebasing onto the verified custom-domain main commit. +- The candidate build and all eighteen Chromium/Firefox/WebKit checks passed, followed by candidate hash/file-set verification. Desktop and narrow-screen connection pages were visually inspected. +- Candidate tests confirm GET 404 and POST 405 before a Cloud API exists, explicitly mocked unavailable/success responses through the published binary client, and no automatic requests or retries. These do not validate a C# container. +- Listing the live suite confirms fifteen static/idle-page tests and excludes the three browser wire-fixture cases. This feature has not been deployed; its public API and Native AOT evidence remain pending Cloud implementation. + ## Outside scope - The gRPC-Web tests use binary wire fixtures; they do not call a real C# Cloud service. diff --git a/playwright.live.config.ts b/playwright.live.config.ts index adf2d53..28a59c6 100644 --- a/playwright.live.config.ts +++ b/playwright.live.config.ts @@ -6,6 +6,7 @@ import { deploymentUrl } from "./tooling/cloudflare.ts"; export default defineConfig({ ...candidateConfig, webServer: [], + testIgnore: ["**/cloud-hello-fixture.spec.ts"], outputDir: "test-results/live", reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-live-report" }]], use: { ...candidateConfig.use, baseURL: deploymentUrl }, diff --git a/tests/browser/cloud-hello-fixture.spec.ts b/tests/browser/cloud-hello-fixture.spec.ts new file mode 100644 index 0000000..741717e --- /dev/null +++ b/tests/browser/cloud-hello-fixture.spec.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Explicit browser wire fixtures; these do not call or validate a C# container. +import { expect, test } from "@playwright/test"; +import { helloApiPath } from "../../apps/site/app/cloud-hello"; +import { decodeHelloRequest, helloResponse } from "../fixtures/grpc-web"; + +test("published client reports unavailable API, then accepts a real protobuf response fixture", async ({ + page, + request, +}) => { + // Candidate-only: before Cloud owns /api/*, static assets must not fake an API response. + const missingApi = await request.get(helloApiPath); + expect(missingApi.status()).toBe(404); + expect(await missingApi.text()).toContain("Page not found"); + expect((await request.post(helloApiPath)).status()).toBe(405); + let requests = 0; + let unavailable = true; + await page.route("**/api/**", async (route) => { + requests++; + const request = route.request(); + expect(new URL(request.url()).pathname).toBe(helloApiPath); + expect(request.method()).toBe("POST"); + expect(request.headers()["content-type"]).toContain("application/grpc-web+proto"); + expect(request.headers().authorization).toBeUndefined(); + expect(decodeHelloRequest(request.postDataBuffer() ?? new Uint8Array()).name).toBe("ArcForges"); + if (unavailable) await route.fulfill({ status: 503, body: "Unavailable" }); + else + await route.fulfill({ + status: 200, + contentType: "application/grpc-web+proto", + body: Buffer.from(helloResponse()), + }); + }); + await page.goto("/cloud-hello/"); + const button = page.getByRole("button", { name: "Check connection" }); + await expect(button).toBeEnabled(); + expect(requests).toBe(0); + await button.click(); + await expect(page.getByRole("alert")).toContainText("server is unavailable"); + expect(requests).toBe(1); + unavailable = false; + await button.click(); + await expect(page.getByRole("status")).toContainText("Hello, ArcForges!"); + expect(requests).toBe(2); +}); diff --git a/tests/browser/site.spec.ts b/tests/browser/site.spec.ts index 099f19d..fdcda3d 100644 --- a/tests/browser/site.spec.ts +++ b/tests/browser/site.spec.ts @@ -38,10 +38,13 @@ test("public content and navigation work without JavaScript", async ({ browser, await expect(page.getByRole("status")).toContainText("Hello, World!"); await expect(page.getByLabel("Your name")).toBeDisabled(); await expect(page.getByRole("button", { name: "Say hello" })).toBeDisabled(); + await page.getByRole("link", { name: "Check the server connection" }).click(); + await expect(page.getByRole("button", { name: "Check connection" })).toBeDisabled(); await context.close(); }); test("pages are accessible and fit narrow screens", async ({ page }, info) => { - for (const route of ["/", "/hello/"]) { + for (const route of ["/", "/hello/", "/cloud-hello/"]) { + const pageName = route === "/" ? "home" : route.split("/")[1]; await page.goto(route); const results = await new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) @@ -52,17 +55,31 @@ test("pages are accessible and fit narrow screens", async ({ page }, info) => { await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), ).toBe(true); await page.screenshot({ - path: info.outputPath(route === "/" ? "home-mobile.png" : "hello-mobile.png"), + path: info.outputPath(`${pageName}-mobile.png`), fullPage: true, }); await page.setViewportSize({ width: 1440, height: 1000 }); await page.screenshot({ - path: info.outputPath(route === "/" ? "home-desktop.png" : "hello-desktop.png"), + path: info.outputPath(`${pageName}-desktop.png`), fullPage: true, }); } }); -test("static delivery has security/cache headers and no API or asset fallback", async ({ +test("server connection page makes no automatic API calls", async ({ page }) => { + const requests: string[] = []; + const errors: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname.startsWith("/api/")) requests.push(request.url()); + }); + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/cloud-hello/"); + await expect(page.getByRole("heading", { level: 1 })).toContainText("from the server"); + await expect(page.getByRole("button", { name: "Check connection" })).toBeEnabled(); + await expect(page.getByRole("status")).toContainText("No request sent yet"); + expect(requests).toEqual([]); + expect(errors).toEqual([]); +}); +test("static delivery has security/cache headers and real missing-page and asset responses", async ({ request, }) => { const home = await request.get("/"); @@ -76,12 +93,7 @@ test("static delivery has security/cache headers and no API or asset fallback", expect(asset).toBeDefined(); expect((await request.get(asset ?? "")).headers()["cache-control"]).toContain("immutable"); expect((await request.get("/__build.json")).headers()["cache-control"]).toContain("no-store"); - for (const path of [ - "/no-such-page", - "/api/login", - "/assets/missing.js", - "/__spa-fallback.html", - ]) { + for (const path of ["/no-such-page", "/assets/missing.js", "/__spa-fallback.html"]) { const missing = await request.get(path); expect(missing.status()).toBe(404); expect(await missing.text()).toContain("Page not found"); diff --git a/tests/fixtures/grpc-web.ts b/tests/fixtures/grpc-web.ts new file mode 100644 index 0000000..12afc4c --- /dev/null +++ b/tests/fixtures/grpc-web.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Test-only gRPC-Web fixture. Never included in the deployed static candidate. +import { SayHelloRequestSchema, SayHelloResponseSchema } from "@arcforges/proto"; +import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; + +export function frame(payload: Uint8Array, flag: number) { + const bytes = new Uint8Array(payload.length + 5); + bytes[0] = flag; + new DataView(bytes.buffer).setUint32(1, payload.length); + bytes.set(payload, 5); + return bytes; +} + +export function decodeHelloRequest(bytes: Uint8Array) { + if ( + bytes.length < 5 || + bytes[0] !== 0 || + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(1) !== bytes.length - 5 + ) + throw new Error("Expected one uncompressed protobuf request frame"); + return fromBinary(SayHelloRequestSchema, bytes.slice(5)); +} + +export function helloResponse(message = "Hello, ArcForges!") { + const data = frame( + toBinary(SayHelloResponseSchema, create(SayHelloResponseSchema, { message })), + 0, + ); + const trailers = frame(new TextEncoder().encode("grpc-status: 0\r\n"), 0x80); + const body = new Uint8Array(data.length + trailers.length); + body.set(data); + body.set(trailers, data.length); + return body; +} diff --git a/tests/unit/cloud-hello.test.ts b/tests/unit/cloud-hello.test.ts new file mode 100644 index 0000000..c011d55 --- /dev/null +++ b/tests/unit/cloud-hello.test.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { expect, test } from "vitest"; +import { checkServerConnection, helloApiPath } from "../../apps/site/app/cloud-hello"; +import { decodeHelloRequest, helloResponse } from "../fixtures/grpc-web"; + +test("connection uses the published binary client, same-origin API and no credentials", async () => { + const fetcher: typeof fetch = async (input, init) => { + const request = new Request(input, init); + expect(request.url).toBe(`https://arcforges.com${helloApiPath}`); + expect(request.method).toBe("POST"); + expect(request.headers.get("content-type")).toContain("application/grpc-web+proto"); + expect(request.credentials).toBe("omit"); + expect(request.redirect).toBe("error"); + expect(request.headers.has("authorization")).toBe(false); + expect(decodeHelloRequest(new Uint8Array(await request.arrayBuffer())).name).toBe("ArcForges"); + return new Response(helloResponse(), { + headers: { "content-type": "application/grpc-web+proto" }, + }); + }; + await expect( + checkServerConnection("https://arcforges.com", new AbortController().signal, fetcher), + ).resolves.toBe("Hello, ArcForges!"); +}); + +test("missing API and unexpected server output never become a local successful greeting", async () => { + for (const response of [ + new Response("Not found", { status: 404 }), + new Response("Method not allowed", { status: 405 }), + new Response("Not an API", { headers: { "content-type": "text/html" } }), + new Response(helloResponse("different service"), { + headers: { "content-type": "application/grpc-web+proto" }, + }), + ]) + await expect( + checkServerConnection( + "https://arcforges.com", + new AbortController().signal, + async () => response, + ), + ).rejects.toThrow(); +}); diff --git a/tooling/cloudflare.ts b/tooling/cloudflare.ts index 41da7ca..e1ff591 100644 --- a/tooling/cloudflare.ts +++ b/tooling/cloudflare.ts @@ -198,7 +198,8 @@ async function smoke() { if (route.startsWith("/assets/")) assert(response.headers.get("cache-control")?.includes("immutable")); } - for (const path of ["/api/missing", "/assets/missing.js", "/not-a-page"]) { + // The future Cloud Worker owns /api/*; verify only Web-owned public paths here. + for (const path of ["/assets/missing.js", "/not-a-page"]) { const response = await fetch(state.url + path, { signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]), redirect: "error", diff --git a/tooling/project.ts b/tooling/project.ts index 9737232..fe53174 100644 --- a/tooling/project.ts +++ b/tooling/project.ts @@ -132,6 +132,7 @@ export async function verify( for (const path of [ "assets/index.html", "assets/hello/index.html", + "assets/cloud-hello/index.html", "assets/404.html", "assets/_headers", "sbom.cdx.json",