+ ) : (
+ this.props.children
+ );
+ }
+}
+
+it("leaves a select that throws to the error boundary", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ mockApi(...adapterHandlers(DEFAULT_SESSION));
+ function Broken(): JSX.Element {
+ const query = useQuery({
+ ...adapterQueries.list({ adapterType: "LLM" }),
+ select: (): never => {
+ throw new TypeError("bad select");
+ },
+ });
+ return {() =>
Rows
};
+ }
+ renderV1(
+
+
+ ,
+ );
+ expect(await screen.findByText("Caught bad select")).toBeInTheDocument();
+});
diff --git a/frontend/src/v1/components/patterns/QueryView.tsx b/frontend/src/v1/components/patterns/QueryView.tsx
new file mode 100644
index 0000000000..40846b797c
--- /dev/null
+++ b/frontend/src/v1/components/patterns/QueryView.tsx
@@ -0,0 +1,43 @@
+import type { UseQueryResult } from "@tanstack/react-query";
+import { InlineCode } from "@v1/components/ui/code";
+import { InlineAlert, SkeletonText } from "@v1/components/ui/feedback";
+import { toApiError } from "@v1/services/errors";
+import type { JSX, ReactNode } from "react";
+
+export type QueryViewProps = Readonly<{
+ query: UseQueryResult;
+ /** Shown while loading; default three skeleton lines. */
+ loading?: ReactNode;
+ children: (data: T) => ReactNode;
+}>;
+
+/**
+ * Renders a query's three states: a skeleton while loading, an InlineAlert
+ * with Retry when the first fetch failed, and `children(data)` otherwise
+ * (a failed refresh keeps the data; app/query.ts toasts it).
+ */
+export function QueryView({
+ query,
+ loading,
+ children,
+}: QueryViewProps): JSX.Element {
+ if (query.status === "pending") {
+ return <>{loading ?? }>;
+ }
+ // The first fetch failed: decided on the cache, as app/query.ts does, not on `select`'s result.
+ if (query.isError && query.dataUpdatedAt === 0) {
+ const error = toApiError(query.error);
+ return (
+ void query.refetch()}>
+ {error.requestId ? (
+ <>
+ Request ID{" "}
+ {error.requestId}
+ >
+ ) : null}
+
+ );
+ }
+ // After a failed refresh, `data` is undefined only if `select` made it so, and T then allows it.
+ return <>{children(query.data as T)}>;
+}
diff --git a/frontend/src/v1/pages/demo/index.test.tsx b/frontend/src/v1/pages/demo/index.test.tsx
new file mode 100644
index 0000000000..45e4fb441c
--- /dev/null
+++ b/frontend/src/v1/pages/demo/index.test.tsx
@@ -0,0 +1,28 @@
+import { screen } from "@testing-library/react";
+import { setOrganizationId } from "@v1/services/context";
+import { DEFAULT_SESSION, mockApi, renderV1 } from "@v1/test";
+import { adapterFixtures, adapterHandlers } from "@v1/test/fixtures/adapters";
+import { afterEach, expect, it } from "vitest";
+import { DemoPage } from "./index";
+
+const ORG = DEFAULT_SESSION.organization.id;
+const at = { route: `/${ORG}/v1-demo`, path: "/:orgName/v1-demo" };
+
+afterEach(() => setOrganizationId(null));
+
+it("lists the organisation's LLM adapters through the sample service", async () => {
+ const seen: URL[] = [];
+ mockApi(...adapterHandlers(DEFAULT_SESSION, adapterFixtures.llm, seen));
+ const { container } = renderV1(, at);
+ expect(await screen.findByText("OpenAI GPT-4o")).toBeInTheDocument();
+ expect(screen.getByText("Claude")).toBeInTheDocument();
+ expect(seen[0]?.pathname).toBe(`/api/v1/unstract/${ORG}/adapter/`);
+ expect(seen[0]?.searchParams.get("adapter_type")).toBe("LLM");
+ await expect(container).toHaveNoA11yViolations();
+});
+
+it("says so when there are none", async () => {
+ mockApi(...adapterHandlers(DEFAULT_SESSION, []));
+ renderV1(, at);
+ expect(await screen.findByText("No LLM adapters yet.")).toBeInTheDocument();
+});
diff --git a/frontend/src/v1/pages/demo/index.tsx b/frontend/src/v1/pages/demo/index.tsx
index c3c873fa49..bcc6f9cdda 100644
--- a/frontend/src/v1/pages/demo/index.tsx
+++ b/frontend/src/v1/pages/demo/index.tsx
@@ -2,17 +2,47 @@
* Development-only demo pages that prove the version switch (UN-4271). They
* ship in every build but are reachable only with VITE_V1_DEV_ROUTES=true.
* DemoReviewPage stands in for Human Review: its "Switch to old version"
- * control is the shape of the one P6-T07 builds.
+ * control is the shape of the one P6-T07 builds. DemoPage also lists the
+ * organisation's LLM adapters, the sample of the data layer (UN-4313).
*/
+import { useQuery } from "@tanstack/react-query";
+import { QueryView } from "@v1/components/patterns/QueryView";
+import { adapterQueries } from "@v1/services/adapters";
+import { setOrganizationId } from "@v1/services/context";
import { useOldVersionStore } from "@v1/stores/oldVersion";
import type { JSX } from "react";
import { Link, useParams } from "react-router-dom";
+function LlmAdapters(): JSX.Element {
+ const adapters = useQuery(adapterQueries.list({ adapterType: "LLM" }));
+ return (
+
+ {(rows) =>
+ rows.length === 0 ? (
+
No LLM adapters yet.
+ ) : (
+
+ {rows.map((row) => (
+
{row.adapter_name}
+ ))}
+
+ )
+ }
+
+ );
+}
+
export function DemoPage(): JSX.Element {
+ // Stand-in until UN-4314's session: the URL's org name is the org id on OSS only.
+ setOrganizationId(useParams().orgName ?? null);
return (
-
+
v1 demo page
+
+
LLM adapters
+
+
);
}
diff --git a/frontend/src/v1/services/README.md b/frontend/src/v1/services/README.md
new file mode 100644
index 0000000000..bfba93d158
--- /dev/null
+++ b/frontend/src/v1/services/README.md
@@ -0,0 +1,105 @@
+# Services: the v1 data layer
+
+One way to call the backend (P0-T05). Pages read and write server state through
+TanStack Query with the typed functions here, and never write their own loading
+or error handling.
+
+## Files
+
+| File | Holds |
+| --- | --- |
+| `http.ts` | The one HTTP client, and `orgPath()`. Adds `X-CSRFToken` (from the `csrftoken` cookie) to same-origin POST, PUT, PATCH and DELETE, and a new `X-Request-ID` to every request; rejects with an `ApiError`; treats the auth middleware's 401 as session expiry, once per page load, so concurrent 401s redirect once. Only `services/` imports it, or axios, or calls `fetch`, `XMLHttpRequest` or `EventSource` (`lint:v1`). |
+| `context.ts` | What the client takes from the session: `setOrganizationId()` (the org `orgPath()` uses) and `onSessionExpired()` (default: v0's `/api/v1/logout` redirect; a handler must leave the page). The session (P0-T06, UN-4314) sets both. |
+| `errors.ts` | `ApiError`, `toApiError()`, `isCancelled()`, `mapFieldErrors()`. |
+| `report.ts` | `reportApiError()`: a toast through `notify()`, or a log line with the request id. |
+| `page.ts` | `Page` (the backend's `{count, next, previous, results}`), `PageParams` and `pageQuery()`. |
+| `.ts` | One module per backend resource, e.g. `adapters.ts`. |
+
+## Writing a service module
+
+- Backend URLs appear only here (`lint:v1`). Build org-scoped ones with
+ `orgPath("/adapter/", org)`, which gives `/api/v1/unstract//adapter/`,
+ and write the trailing slash exactly as the backend route has it. A cloud
+ plugin keeps its service modules in `plugins//services/`.
+- Every function takes typed params and `RequestOptions` (`{ signal, org }`),
+ passes the signal to the client and the org to `orgPath()`, and returns a
+ typed response. Never accept a free-form query object.
+- A paged endpoint takes `PageParams` (`page` from 1, `pageSize`), sends them
+ with `pageQuery()`, and returns `Page`. Its query sets
+ `placeholderData: previousPageInOrg(queryKey)`, so the previous page stays on
+ screen while the next loads, but never another organisation's (see
+ `adapterQueries.page`). A page past the end is a 404 ("Invalid page."), so
+ whoever holds the page number steps back to the last page,
+ `Math.ceil(count / pageSize)`, after a delete.
+- Export query keys and options per resource. Keys are
+ `[org, resource, "list" | "page" | "detail", params | id]`, and each query
+ fetches for the org in its key, so two organisations never share a cache
+ entry:
+
+```ts
+export const adapterKeys = {
+ all: () => [currentOrganizationId(), "adapters"] as const,
+ list: (params: ListAdaptersParams) => [...adapterKeys.all(), "list", params] as const,
+};
+export const adapterQueries = {
+ list: (params: ListAdaptersParams) =>
+ queryOptions({
+ queryKey: adapterKeys.list(params),
+ queryFn: ({ queryKey: [org], signal }) => listAdapters(params, { signal, org }),
+ }),
+};
+```
+
+- Invalidate a resource with `queryClient.invalidateQueries({ queryKey: adapterKeys.all() })`.
+- Fixtures go in `test/fixtures/.ts`, typed against the module's types
+ (see `test/fixtures/adapters.ts`).
+
+## Errors
+
+Every failure becomes an `ApiError` with `kind`, `message` (for people),
+`status`, `fieldErrors`, `formErrors` and `requestId`. A cancelled request
+rejects with axios's `CanceledError` instead; check `isCancelled()` first
+(`toApiError()` calls it `unknown`, "The request was cancelled.").
+
+| Kind | When |
+| --- | --- |
+| `validation` | `type: "validation_error"` (400); fields in `fieldErrors`, the rest in `formErrors` |
+| `subscription` | A `subscription_error` / `subscription_inactive` type or code, or a 402 with no `type`; a provider's typed 402 (`sdk_error`) is `client` |
+| `unauthenticated` | The auth middleware's 401 (no `type` in the body); a typed 401, such as a provider rejecting its key, is `client` |
+| `forbidden` | 403 |
+| `not_found` | 404 |
+| `client` | Any other 4xx |
+| `server` | 5xx |
+| `network` | No response |
+| `unknown` | Anything that is not an HTTP failure |
+
+## How failures reach the user
+
+`app/query.ts` reports every failure once, so pages do not:
+
+| Failure | Shown as |
+| --- | --- |
+| Mutation | A toast with the request id. Pass `meta: { notify: false }` when the form shows it; it is then logged. |
+| Read whose first fetch fails (also a next page) | Inline, by `QueryView` (`components/patterns/`): an `InlineAlert` with Retry and the request id, after a skeleton (or, for a next page, the previous page). Also logged. |
+| Refresh that fails while the query's data is on screen | A toast, replaced rather than stacked while the same query keeps failing. Every query that cannot reach the server shares one toast. |
+| A `select` that throws | The page's error boundary: it is a bug, not a failed request. |
+| 401 | One redirect to sign in; no toast. |
+| Subscription | A toast that stays until dismissed; inline like any other first-fetch failure. |
+| Cancellation | Nothing. |
+
+Reads retry twice on a network error, 502, 503 or 504. Mutations never retry.
+Offline, requests still go out (`networkMode: "always"`) and fail as
+`network`; nothing waits for the connection, and a save is never sent later.
+Reads refetch when the connection returns.
+
+## Forms
+
+```ts
+const save = useMutation({ mutationFn: createAdapter, meta: { notify: false } });
+const { fields, form } = mapFieldErrors(save.error, ["adapter_name", "description"]);
+```
+
+`fields` holds one message per field the form shows. `form` holds everything
+else, including errors for fields the form does not show, for one
+`InlineAlert` above the form; nothing is dropped. Before the first submit
+(`error` is null) or after a cancel, both are empty.
diff --git a/frontend/src/v1/services/adapters.test.ts b/frontend/src/v1/services/adapters.test.ts
new file mode 100644
index 0000000000..00052ed89e
--- /dev/null
+++ b/frontend/src/v1/services/adapters.test.ts
@@ -0,0 +1,103 @@
+import { QueryClient } from "@tanstack/react-query";
+import { DEFAULT_SESSION, makeSession, mockApi } from "@v1/test";
+import { adapterFixtures, adapterHandlers } from "@v1/test/fixtures/adapters";
+import { afterEach, expect, it } from "vitest";
+import {
+ adapterKeys,
+ adapterQueries,
+ listAdapters,
+ listAdaptersPage,
+} from "./adapters";
+import { setOrganizationId } from "./context";
+
+afterEach(() => setOrganizationId(null));
+
+it("lists the organisation's adapters of one type, with typed params", async () => {
+ setOrganizationId(DEFAULT_SESSION.organization.id);
+ const seen: URL[] = [];
+ mockApi(...adapterHandlers(DEFAULT_SESSION, adapterFixtures.llm, seen));
+ const rows = await listAdapters({ adapterType: "LLM", search: "gpt" });
+ expect(rows).toEqual(adapterFixtures.llm);
+ expect(seen[0]?.pathname).toBe("/api/v1/unstract/org-1/adapter/");
+ expect(Object.fromEntries(seen[0]?.searchParams ?? [])).toEqual({
+ adapter_type: "LLM",
+ search: "gpt",
+ });
+});
+
+it("sends no search parameter when none is given", async () => {
+ setOrganizationId(DEFAULT_SESSION.organization.id);
+ const seen: URL[] = [];
+ mockApi(...adapterHandlers(DEFAULT_SESSION, [], seen));
+ await listAdapters({ adapterType: "OCR" });
+ expect(seen[0]?.search).toBe("?adapter_type=OCR");
+});
+
+it("fetches one page with typed page params", async () => {
+ setOrganizationId(DEFAULT_SESSION.organization.id);
+ const seen: URL[] = [];
+ mockApi(...adapterHandlers(DEFAULT_SESSION, adapterFixtures.llm, seen));
+ const page = await listAdaptersPage({
+ adapterType: "LLM",
+ page: 2,
+ pageSize: 1,
+ });
+ expect(Object.fromEntries(seen[0]?.searchParams ?? [])).toEqual({
+ adapter_type: "LLM",
+ page: "2",
+ page_size: "1",
+ });
+ expect(page).toMatchObject({
+ count: 2,
+ next: null,
+ results: [adapterFixtures.llm[1]],
+ failing_count: 0,
+ });
+ expect(page.previous).toContain("page=1");
+});
+
+it("keys queries by organisation, so two organisations never share a cache entry", () => {
+ const params = { adapterType: "LLM" } as const;
+ setOrganizationId("org-1");
+ const first = adapterQueries.list(params).queryKey;
+ setOrganizationId(
+ makeSession({ organization: { id: "org-2" } }).organization.id,
+ );
+ const second = adapterQueries.list(params).queryKey;
+ expect(first).toEqual(["org-1", "adapters", "list", params]);
+ expect(second).toEqual(["org-2", "adapters", "list", params]);
+ expect(adapterKeys.all()).toEqual(["org-2", "adapters"]);
+ expect(
+ adapterQueries.page({ ...params, page: 1, pageSize: 10 }).queryKey,
+ ).toEqual([
+ "org-2",
+ "adapters",
+ "page",
+ { ...params, page: 1, pageSize: 10 },
+ ]);
+});
+
+// Each builds its query now and returns a run that fetches it later.
+const queries = {
+ list: () => {
+ const options = adapterQueries.list({ adapterType: "LLM" });
+ return () => new QueryClient().fetchQuery(options);
+ },
+ page: () => {
+ const params = { adapterType: "LLM", page: 1, pageSize: 10 } as const;
+ const options = adapterQueries.page(params);
+ return () => new QueryClient().fetchQuery(options);
+ },
+};
+
+it.each(
+ Object.entries(queries),
+)("%s fetches for the organisation in its key, even after the current one changes", async (_, build) => {
+ setOrganizationId("org-1");
+ const run = build();
+ setOrganizationId("org-2");
+ const seen: URL[] = [];
+ mockApi(...adapterHandlers(DEFAULT_SESSION, adapterFixtures.llm, seen));
+ await run();
+ expect(seen[0]?.pathname).toBe("/api/v1/unstract/org-1/adapter/");
+});
diff --git a/frontend/src/v1/services/adapters.ts b/frontend/src/v1/services/adapters.ts
new file mode 100644
index 0000000000..85d1587367
--- /dev/null
+++ b/frontend/src/v1/services/adapters.ts
@@ -0,0 +1,100 @@
+import { queryOptions } from "@tanstack/react-query";
+import { currentOrganizationId } from "./context";
+import { http, orgPath, type RequestOptions } from "./http";
+import {
+ type Page,
+ type PageParams,
+ pageQuery,
+ previousPageInOrg,
+} from "./page";
+
+/* Adapters (LLM, embedding, vector DB, text extractor, OCR) in the current organisation. */
+
+export const ADAPTER_TYPES = [
+ "LLM",
+ "EMBEDDING",
+ "VECTOR_DB",
+ "X2TEXT",
+ "OCR",
+] as const;
+export type AdapterType = (typeof ADAPTER_TYPES)[number];
+
+/** A row of `GET adapter/`; the fields v1 reads. */
+export type Adapter = Readonly<{
+ id: string;
+ adapter_id: string;
+ adapter_name: string;
+ adapter_type: AdapterType;
+ description: string | null;
+ icon: string;
+ created_at: string;
+ modified_at: string;
+}>;
+
+export type ListAdaptersParams = Readonly<{
+ adapterType: AdapterType;
+ search?: string;
+}>;
+
+export type ListAdaptersPageParams = ListAdaptersParams & PageParams;
+
+/** A page of adapters; the endpoint adds the failing count across all pages. */
+export type AdapterPage = Page & Readonly<{ failing_count: number }>;
+
+const filterQuery = (params: ListAdaptersParams) => ({
+ adapter_type: params.adapterType,
+ search: params.search,
+});
+
+/** `GET /api/v1/unstract//adapter/`, unpaged. */
+export async function listAdapters(
+ params: ListAdaptersParams,
+ { signal, org }: RequestOptions = {},
+): Promise {
+ const { data } = await http.get(orgPath("/adapter/", org), {
+ params: filterQuery(params),
+ signal,
+ });
+ return data;
+}
+
+/** The same list, one page at a time. */
+export async function listAdaptersPage(
+ params: ListAdaptersPageParams,
+ { signal, org }: RequestOptions = {},
+): Promise {
+ const { data } = await http.get(orgPath("/adapter/", org), {
+ params: { ...filterQuery(params), ...pageQuery(params) },
+ signal,
+ });
+ return data;
+}
+
+/** Query keys: `[org, "adapters", "list" | "page", params]`. */
+export const adapterKeys = {
+ all: () => [currentOrganizationId(), "adapters"] as const,
+ list: (params: ListAdaptersParams) =>
+ [...adapterKeys.all(), "list", params] as const,
+ page: (params: ListAdaptersPageParams) =>
+ [...adapterKeys.all(), "page", params] as const,
+};
+
+// Each query fetches for the org in its key, so rows never land under another org's key.
+export const adapterQueries = {
+ list: (params: ListAdaptersParams) =>
+ queryOptions({
+ queryKey: adapterKeys.list(params),
+ queryFn: ({ queryKey: [org], signal }) =>
+ listAdapters(params, { signal, org }),
+ }),
+ // The previous page stays on screen while the next one loads.
+ page: (params: ListAdaptersPageParams) => {
+ const queryKey = adapterKeys.page(params);
+ return queryOptions({
+ queryKey,
+ queryFn: ({ queryKey: [org], signal }) =>
+ listAdaptersPage(params, { signal, org }),
+ placeholderData: previousPageInOrg(queryKey),
+ });
+ },
+};
diff --git a/frontend/src/v1/services/context.test.ts b/frontend/src/v1/services/context.test.ts
new file mode 100644
index 0000000000..f6eef940e4
--- /dev/null
+++ b/frontend/src/v1/services/context.test.ts
@@ -0,0 +1,55 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ currentOrganizationId,
+ expireSession,
+ onSessionExpired,
+ setOrganizationId,
+} from "./context";
+
+afterEach(() => {
+ setOrganizationId(null);
+});
+
+describe("currentOrganizationId", () => {
+ it("gives the organisation that is set, and throws while none is", () => {
+ expect(() => currentOrganizationId()).toThrow(/No organisation is set/);
+ setOrganizationId("");
+ expect(() => currentOrganizationId()).toThrow(/No organisation is set/);
+ setOrganizationId("org-1");
+ expect(currentOrganizationId()).toBe("org-1");
+ });
+});
+
+describe("session expiry", () => {
+ it("sends the browser to the logout URL by default", () => {
+ const assign = vi
+ .spyOn(window.location, "assign")
+ .mockImplementation(() => undefined);
+ expireSession();
+ expect(assign).toHaveBeenCalledWith("/api/v1/logout");
+ });
+
+ it("runs a registered handler instead, until its cleanup", () => {
+ const assign = vi
+ .spyOn(window.location, "assign")
+ .mockImplementation(() => undefined);
+ const handler = vi.fn();
+ const cleanup = onSessionExpired(handler);
+ expireSession();
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(assign).not.toHaveBeenCalled();
+ cleanup();
+ expireSession();
+ expect(assign).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps a newer handler when an older one's cleanup runs", () => {
+ const older = onSessionExpired(vi.fn());
+ const newer = vi.fn();
+ const cleanup = onSessionExpired(newer);
+ older();
+ expireSession();
+ expect(newer).toHaveBeenCalledTimes(1);
+ cleanup();
+ });
+});
diff --git a/frontend/src/v1/services/context.ts b/frontend/src/v1/services/context.ts
new file mode 100644
index 0000000000..5c6156d27d
--- /dev/null
+++ b/frontend/src/v1/services/context.ts
@@ -0,0 +1,47 @@
+// What the HTTP client takes from the session (UN-4314): the org and expiry handling.
+
+// v0's target too. GET goes when v0 retires (account_v2/views.py), so UN-4314's handler uses the POST sign-out.
+const SESSION_EXPIRED_URL = "/api/v1/logout";
+
+export type SessionExpiredHandler = () => void;
+
+const leaveForLogin: SessionExpiredHandler = () => {
+ window.location.assign(SESSION_EXPIRED_URL);
+};
+
+let sessionExpiredHandler: SessionExpiredHandler = leaveForLogin;
+let organizationId: string | null = null;
+
+/** Sets the organisation org-scoped paths use. */
+export function setOrganizationId(id: string | null): void {
+ organizationId = id;
+}
+
+/** The current organisation id; throws while none is set. */
+export function currentOrganizationId(): string {
+ if (!organizationId) {
+ throw new Error(
+ "No organisation is set; the session sets it with setOrganizationId().",
+ );
+ }
+ return organizationId;
+}
+
+/**
+ * Replaces what happens on session expiry (default: v0's logout redirect).
+ * The handler must leave the page: the client calls it once per page load.
+ * Returns a cleanup that restores the default if this handler is current.
+ */
+export function onSessionExpired(handler: SessionExpiredHandler): () => void {
+ sessionExpiredHandler = handler;
+ return () => {
+ if (sessionExpiredHandler === handler) {
+ sessionExpiredHandler = leaveForLogin;
+ }
+ };
+}
+
+/** Runs the current session-expiry handler; the client calls it once. */
+export function expireSession(): void {
+ sessionExpiredHandler();
+}
diff --git a/frontend/src/v1/services/errors.test.ts b/frontend/src/v1/services/errors.test.ts
new file mode 100644
index 0000000000..979b39247d
--- /dev/null
+++ b/frontend/src/v1/services/errors.test.ts
@@ -0,0 +1,331 @@
+import { CancelledError } from "@tanstack/react-query";
+import { HttpResponse, http as mock, mockApi } from "@v1/test";
+import { AxiosError, type AxiosResponse, CanceledError } from "axios";
+import type { JsonBodyType } from "msw";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { onSessionExpired } from "./context";
+import {
+ ApiError,
+ isCancelled,
+ isTransient,
+ mapFieldErrors,
+ REQUEST_ID_HEADER,
+ toApiError,
+} from "./errors";
+import { createHttpClient } from "./http";
+
+const URL = "/api/v1/thing/";
+const SENT_ID = "sent-1";
+
+/* What the client rejects with when the backend answers `response`. */
+async function failureFor(response: Response | (() => Response)) {
+ mockApi(
+ mock.all(URL, typeof response === "function" ? response : () => response),
+ );
+ const headers = { [REQUEST_ID_HEADER]: SENT_ID };
+ const error = await createHttpClient()
+ .post(URL, {}, { headers })
+ .catch((e: unknown) => e);
+ expect(error).toBeInstanceOf(ApiError);
+ return error as ApiError;
+}
+
+const json = (body: JsonBodyType, status: number, headers?: HeadersInit) =>
+ HttpResponse.json(body, { status, headers });
+
+let cleanup: () => void;
+beforeEach(() => {
+ // A 401 would otherwise send the test's window to the logout URL.
+ cleanup = onSessionExpired(vi.fn());
+});
+afterEach(() => cleanup());
+
+describe("each kind, from the backend's own error bodies", () => {
+ it("validation: field and non-field errors, all in the message", async () => {
+ const error = await failureFor(
+ json(
+ {
+ type: "validation_error",
+ errors: [
+ {
+ code: "required",
+ detail: "This field is required.",
+ attr: "adapter_name",
+ },
+ {
+ code: "blank",
+ detail: "May not be blank.",
+ attr: "adapter_name",
+ },
+ {
+ code: "invalid",
+ detail: "Name already taken.",
+ attr: "non_field_errors",
+ },
+ ],
+ },
+ 400,
+ ),
+ );
+ expect(error).toMatchObject({
+ kind: "validation",
+ status: 400,
+ fieldErrors: {
+ adapter_name: ["This field is required.", "May not be blank."],
+ },
+ formErrors: ["Name already taken."],
+ });
+ expect(error.message).toBe(
+ "adapter_name: This field is required. adapter_name: May not be blank. Name already taken.",
+ );
+ });
+
+ it("subscription: the plugin's 402 body", async () => {
+ const error = await failureFor(
+ json(
+ {
+ type: "subscription_error",
+ errors: "Trial period expired. Please contact sales/admin.",
+ },
+ 402,
+ ),
+ );
+ expect(error).toMatchObject({
+ kind: "subscription",
+ message: "Trial period expired. Please contact sales/admin.",
+ });
+ });
+
+ it("subscription: a 400 whose error code is subscription_error", async () => {
+ const error = await failureFor(
+ json(
+ {
+ type: "client_error",
+ errors: [
+ {
+ code: "subscription_error",
+ detail: "Selected organization is not active.",
+ attr: null,
+ },
+ ],
+ },
+ 400,
+ ),
+ );
+ expect(error).toMatchObject({
+ kind: "subscription",
+ message: "Selected organization is not active.",
+ });
+ });
+
+ it("client, not subscription: a provider's 402 for exhausted credit", async () => {
+ const error = await failureFor(
+ json({ error: "Insufficient credits", type: "sdk_error" }, 402),
+ );
+ expect(error).toMatchObject({
+ kind: "client",
+ status: 402,
+ message: "Insufficient credits",
+ });
+ });
+
+ it("unauthenticated: the auth middleware's 401", async () => {
+ const error = await failureFor(json({ message: "Unauthorized" }, 401));
+ expect(error).toMatchObject({ kind: "unauthenticated", status: 401 });
+ });
+
+ it("unauthenticated: the middleware's 401 read as a Blob (a download)", () => {
+ const body = new Blob(['{"message":"Unauthorized"}'], {
+ type: "application/json",
+ });
+ const response = { status: 401, data: body } as AxiosResponse;
+ const cause = new AxiosError(
+ "401",
+ "ERR_BAD_REQUEST",
+ undefined,
+ null,
+ response,
+ );
+ expect(toApiError(cause)).toMatchObject({ kind: "unauthenticated" });
+ });
+
+ it.each([
+ [
+ "a provider rejecting its key",
+ { error: "Invalid API key.", type: "sdk_error" },
+ "Invalid API key.",
+ ],
+ [
+ "a connector's failed OAuth refresh",
+ {
+ type: "client_error",
+ errors: [{ code: "error", detail: "OAuth failed.", attr: null }],
+ },
+ "OAuth failed.",
+ ],
+ ])("client, not session expiry: a typed 401 from %s", async (_, body, message) => {
+ const expired = vi.fn();
+ const restore = onSessionExpired(expired);
+ const error = await failureFor(json(body, 401));
+ restore();
+ expect(error).toMatchObject({ kind: "client", status: 401, message });
+ expect(expired).not.toHaveBeenCalled();
+ });
+
+ it("forbidden: the auth middleware's 403", async () => {
+ const error = await failureFor(
+ json({ message: "Organization access denied" }, 403),
+ );
+ expect(error).toMatchObject({
+ kind: "forbidden",
+ message: "Organization access denied",
+ });
+ });
+
+ it("not found: the resource-named 404", async () => {
+ const error = await failureFor(
+ json(
+ {
+ type: "client_error",
+ errors: [
+ { code: "not_found", detail: "Adapter not found.", attr: null },
+ ],
+ },
+ 404,
+ ),
+ );
+ expect(error).toMatchObject({
+ kind: "not_found",
+ message: "Adapter not found.",
+ });
+ });
+
+ it("client: any other 4xx", async () => {
+ const error = await failureFor(json({ detail: "Already exists." }, 409));
+ expect(error).toMatchObject({ kind: "client", message: "Already exists." });
+ expect(error.fieldErrors).toEqual({});
+ });
+
+ it("server: a non-JSON 502 gets the default message and the echoed request id", async () => {
+ const error = await failureFor(
+ new HttpResponse("Bad Gateway", {
+ status: 502,
+ headers: { "Content-Type": "text/html", [REQUEST_ID_HEADER]: "srv-9" },
+ }),
+ );
+ expect(error).toMatchObject({
+ kind: "server",
+ status: 502,
+ message: "Something went wrong on our side. Try again.",
+ requestId: "srv-9",
+ });
+ });
+
+ it("server: the SDK's error text", async () => {
+ const error = await failureFor(
+ json({ error: "LLM call failed", type: "sdk_error" }, 500),
+ );
+ expect(error).toMatchObject({ kind: "server", message: "LLM call failed" });
+ });
+
+ it("network: no response, with the request id that was sent", async () => {
+ const error = await failureFor(() => HttpResponse.error());
+ expect(error).toMatchObject({
+ kind: "network",
+ status: undefined,
+ requestId: SENT_ID,
+ });
+ });
+
+ it("unknown: anything that is not an HTTP failure", () => {
+ const cause = new TypeError("x is undefined");
+ expect(toApiError(cause)).toMatchObject({
+ kind: "unknown",
+ message: "Something went wrong.",
+ cause,
+ });
+ });
+
+ it("unknown, not network: a cancelled request", () => {
+ for (const cause of [new CanceledError(), new CancelledError()]) {
+ expect(toApiError(cause)).toMatchObject({
+ kind: "unknown",
+ message: "The request was cancelled.",
+ cause,
+ });
+ }
+ });
+
+ it("returns an ApiError as it is", () => {
+ const error = new ApiError({ kind: "client", message: "m" });
+ expect(toApiError(error)).toBe(error);
+ });
+});
+
+describe("isCancelled and isTransient", () => {
+ it("recognises axios and TanStack cancellations", () => {
+ expect(isCancelled(new CanceledError())).toBe(true);
+ expect(isCancelled(new CancelledError())).toBe(true);
+ expect(isCancelled(new ApiError({ kind: "network", message: "m" }))).toBe(
+ false,
+ );
+ });
+
+ it("calls only network and gateway failures transient", () => {
+ const failure = (kind: ApiError["kind"], status?: number) =>
+ new ApiError({ kind, message: "m", status });
+ expect(isTransient(failure("network"))).toBe(true);
+ expect(isTransient(failure("server", 503))).toBe(true);
+ expect(isTransient(failure("server", 500))).toBe(false);
+ expect(isTransient(failure("validation", 400))).toBe(false);
+ expect(isTransient(new CanceledError())).toBe(false);
+ });
+});
+
+describe("mapFieldErrors", () => {
+ const validation = new ApiError({
+ kind: "validation",
+ message: "all",
+ fieldErrors: { adapter_name: ["Required."], metadata: ["Bad JSON."] },
+ formErrors: ["Name already taken."],
+ });
+
+ it("maps shown fields and moves the rest into the form-level message", () => {
+ expect(mapFieldErrors(validation, ["adapter_name", "description"])).toEqual(
+ {
+ fields: { adapter_name: "Required." },
+ form: "Name already taken. metadata: Bad JSON.",
+ },
+ );
+ });
+
+ it("leaves no form-level message when every error has a field", () => {
+ const fieldsOnly = new ApiError({
+ kind: "validation",
+ message: "m",
+ fieldErrors: { adapter_name: ["Required."] },
+ });
+ expect(mapFieldErrors(fieldsOnly, ["adapter_name"])).toEqual({
+ fields: { adapter_name: "Required." },
+ form: undefined,
+ });
+ });
+
+ it.each([
+ ["an idle mutation (null)", null],
+ ["no error (undefined)", undefined],
+ ["a cancelled request", new CanceledError()],
+ ])("shows nothing for %s", (_, error) => {
+ expect(mapFieldErrors(error, ["adapter_name"])).toEqual({
+ fields: {},
+ form: undefined,
+ });
+ });
+
+ it("puts any other failure in the form-level message", () => {
+ expect(mapFieldErrors(new TypeError("x"), ["adapter_name"])).toEqual({
+ fields: {},
+ form: "Something went wrong.",
+ });
+ });
+});
diff --git a/frontend/src/v1/services/errors.ts b/frontend/src/v1/services/errors.ts
new file mode 100644
index 0000000000..ad7fa7342b
--- /dev/null
+++ b/frontend/src/v1/services/errors.ts
@@ -0,0 +1,266 @@
+import { isCancelledError } from "@tanstack/react-query";
+import { type AxiosError, AxiosHeaders, isAxiosError, isCancel } from "axios";
+
+// The error contract: every failed request becomes one ApiError (see README.md).
+
+export const REQUEST_ID_HEADER = "X-Request-ID";
+
+export const API_ERROR_KINDS = [
+ "validation",
+ "subscription",
+ "unauthenticated",
+ "forbidden",
+ "not_found",
+ "client",
+ "server",
+ "network",
+ "unknown",
+] as const;
+export type ApiErrorKind = (typeof API_ERROR_KINDS)[number];
+
+const DEFAULT_MESSAGES: Readonly> = {
+ validation: "Some fields need attention.",
+ subscription: "Your plan does not allow this. Contact your admin.",
+ unauthenticated: "Your session has expired. Sign in again.",
+ forbidden: "You don't have permission to do this.",
+ not_found: "Not found.",
+ client: "The request could not be completed.",
+ server: "Something went wrong on our side. Try again.",
+ network: "Can't reach the server. Check your connection and try again.",
+ unknown: "Something went wrong.",
+};
+const CANCELLED_MESSAGE = "The request was cancelled.";
+
+export type FieldErrors = Readonly>;
+
+export type ApiErrorInit = Readonly<{
+ kind: ApiErrorKind;
+ message: string;
+ status?: number;
+ fieldErrors?: FieldErrors;
+ formErrors?: readonly string[];
+ requestId?: string;
+ cause?: unknown;
+}>;
+
+/** One failed request: what kind it is, a message for people, and its request id. */
+export class ApiError extends Error {
+ readonly kind: ApiErrorKind;
+ readonly status: number | undefined;
+ /** Validation errors by field (`attr`), as the backend names it. */
+ readonly fieldErrors: FieldErrors;
+ /** Validation errors not tied to a field. */
+ readonly formErrors: readonly string[];
+ readonly requestId: string | undefined;
+
+ constructor(init: ApiErrorInit) {
+ super(init.message, { cause: init.cause });
+ this.name = "ApiError";
+ this.kind = init.kind;
+ this.status = init.status;
+ this.fieldErrors = init.fieldErrors ?? {};
+ this.formErrors = init.formErrors ?? [];
+ this.requestId = init.requestId;
+ }
+}
+
+type ErrorItem = Readonly<{ code?: string; detail: string; attr?: string }>;
+
+// drf-standardized-errors, or a `{message}` / `{error}` / `{detail}` body.
+type ErrorBody = Readonly<{
+ type?: string;
+ items: readonly ErrorItem[];
+ text?: string;
+}>;
+
+const NON_FIELD = "non_field_errors";
+const GATEWAY_STATUSES = new Set([502, 503, 504]);
+const SUBSCRIPTION_TYPES = new Set([
+ "subscription_error",
+ "subscription_inactive",
+]);
+
+// Plain objects only: a Blob body's `type` is its MIME type, not an error type.
+function isRecord(value: unknown): value is Record {
+ if (typeof value !== "object" || value === null) {
+ return false;
+ }
+ const proto: unknown = Object.getPrototypeOf(value);
+ return proto === Object.prototype || proto === null;
+}
+
+const text = (value: unknown): string | undefined =>
+ typeof value === "string" && value !== "" ? value : undefined;
+
+function toItems(value: unknown): ErrorItem[] {
+ const detail = isRecord(value) ? text(value.detail) : undefined;
+ if (!isRecord(value) || detail === undefined) {
+ return [];
+ }
+ return [{ code: text(value.code), detail, attr: text(value.attr) }];
+}
+
+function parseBody(data: unknown): ErrorBody {
+ if (!isRecord(data)) {
+ return { items: [] };
+ }
+ const { errors } = data;
+ return {
+ type: text(data.type),
+ items: Array.isArray(errors) ? errors.flatMap(toItems) : [],
+ text:
+ text(data.error) ??
+ text(data.detail) ??
+ text(data.message) ??
+ text(errors),
+ };
+}
+
+// A provider's 402 comes typed (`sdk_error`); Unstract's carries a subscription type or code.
+const isSubscription = (status: number, body: ErrorBody): boolean =>
+ (status === 402 && body.type === undefined) ||
+ SUBSCRIPTION_TYPES.has(body.type ?? "") ||
+ body.items.some((item) => SUBSCRIPTION_TYPES.has(item.code ?? ""));
+
+function kindOf(status: number, body: ErrorBody): ApiErrorKind {
+ // Only the auth middleware's untyped 401 is session expiry; a typed one is a provider's or connector's.
+ if (status === 401 && body.type === undefined) {
+ return "unauthenticated";
+ }
+ if (isSubscription(status, body)) {
+ return "subscription";
+ }
+ if (body.type === "validation_error") {
+ return "validation";
+ }
+ if (status === 403) {
+ return "forbidden";
+ }
+ if (status === 404) {
+ return "not_found";
+ }
+ return status >= 500 ? "server" : "client";
+}
+
+const isFieldItem = (item: ErrorItem): item is ErrorItem & { attr: string } =>
+ item.attr !== undefined && item.attr !== NON_FIELD;
+
+const describe = (item: ErrorItem): string =>
+ isFieldItem(item) ? `${item.attr}: ${item.detail}` : item.detail;
+
+function splitItems(items: readonly ErrorItem[]) {
+ const fieldErrors: Record = {};
+ const formErrors: string[] = [];
+ for (const item of items) {
+ if (isFieldItem(item)) {
+ fieldErrors[item.attr] = [...(fieldErrors[item.attr] ?? []), item.detail];
+ } else {
+ formErrors.push(item.detail);
+ }
+ }
+ return { fieldErrors, formErrors };
+}
+
+function messageOf(kind: ApiErrorKind, body: ErrorBody): string {
+ if (kind === "validation") {
+ // Every error, so a toast drops none of them.
+ const all = body.items.map(describe).join(" ");
+ return all || body.text || DEFAULT_MESSAGES.validation;
+ }
+ return body.text ?? body.items[0]?.detail ?? DEFAULT_MESSAGES[kind];
+}
+
+// axios hands both request and response headers over as AxiosHeaders.
+const requestIdIn = (headers: unknown): string | undefined =>
+ headers instanceof AxiosHeaders
+ ? text(headers.get(REQUEST_ID_HEADER))
+ : undefined;
+
+/* The id the backend echoed, else the one the client sent. */
+const requestIdOf = (error: AxiosError): string | undefined =>
+ requestIdIn(error.response?.headers) ?? requestIdIn(error.config?.headers);
+
+function fromAxios(error: AxiosError): ApiError {
+ const requestId = requestIdOf(error);
+ if (!error.response) {
+ const message = DEFAULT_MESSAGES.network;
+ return new ApiError({ kind: "network", message, requestId, cause: error });
+ }
+ const { status, data } = error.response;
+ const body = parseBody(data);
+ const kind = kindOf(status, body);
+ return new ApiError({
+ kind,
+ status,
+ message: messageOf(kind, body),
+ ...(kind === "validation" ? splitItems(body.items) : {}),
+ requestId,
+ cause: error,
+ });
+}
+
+/** True for a request the caller cancelled; it is not a failure. */
+export function isCancelled(error: unknown): boolean {
+ return isCancel(error) || isCancelledError(error);
+}
+
+/** Maps any failure to an ApiError; a cancel is `unknown`. Check isCancelled() first. */
+export function toApiError(error: unknown): ApiError {
+ if (error instanceof ApiError) {
+ return error;
+ }
+ // Before axios: its cancel has no response, so it would read as `network`.
+ if (isCancelled(error)) {
+ const message = CANCELLED_MESSAGE;
+ return new ApiError({ kind: "unknown", message, cause: error });
+ }
+ if (isAxiosError(error)) {
+ return fromAxios(error);
+ }
+ const message = DEFAULT_MESSAGES.unknown;
+ return new ApiError({ kind: "unknown", message, cause: error });
+}
+
+/** Failures worth retrying for a read: no response, or a gateway error. */
+export function isTransient(error: unknown): boolean {
+ if (isCancelled(error)) {
+ return false;
+ }
+ const { kind, status } = toApiError(error);
+ return kind === "network" || GATEWAY_STATUSES.has(status ?? 0);
+}
+
+export type FormErrors = Readonly<{
+ /** One message per field the form shows. */
+ fields: Partial>;
+ /** Everything else, for one message above the form; never dropped. */
+ form: string | undefined;
+}>;
+
+/** Splits a failure into the form's field errors and one form-level message for the rest. */
+export function mapFieldErrors(
+ error: unknown,
+ fields: readonly F[],
+): FormErrors {
+ // No error yet (an idle mutation), or a cancel: nothing to show.
+ if (error == null || isCancelled(error)) {
+ return { fields: {}, form: undefined };
+ }
+ const apiError = toApiError(error);
+ const shown = new Set(fields);
+ const isShown = (attr: string): attr is F => shown.has(attr);
+ const mapped: Partial> = {};
+ const rest = [...apiError.formErrors];
+ for (const [attr, details] of Object.entries(apiError.fieldErrors)) {
+ if (isShown(attr)) {
+ mapped[attr] = details.join(" ");
+ } else {
+ rest.push(`${attr}: ${details.join(" ")}`);
+ }
+ }
+ if (rest.length > 0) {
+ return { fields: mapped, form: rest.join(" ") };
+ }
+ const anyField = Object.keys(mapped).length > 0;
+ return { fields: mapped, form: anyField ? undefined : apiError.message };
+}
diff --git a/frontend/src/v1/services/http.test.ts b/frontend/src/v1/services/http.test.ts
new file mode 100644
index 0000000000..5e6adfbbf9
--- /dev/null
+++ b/frontend/src/v1/services/http.test.ts
@@ -0,0 +1,208 @@
+import { delay, HttpResponse, http as mock, mockApi } from "@v1/test";
+import { isCancel } from "axios";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { onSessionExpired, setOrganizationId } from "./context";
+import { ApiError, REQUEST_ID_HEADER } from "./errors";
+import { createHttpClient, http, orgPath } from "./http";
+
+const URL = "/api/v1/echo/";
+const UUID_V4 =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+
+type Echo = { csrf: string | null; requestId: string | null };
+
+function mockEcho(): void {
+ mockApi(
+ mock.all(URL, ({ request }) =>
+ HttpResponse.json({
+ csrf: request.headers.get("X-CSRFToken"),
+ requestId: request.headers.get(REQUEST_ID_HEADER),
+ }),
+ ),
+ );
+}
+
+/* The browser's cookie string, as the client reads it. */
+function setCookies(cookies: string): void {
+ vi.spyOn(document, "cookie", "get").mockReturnValue(cookies);
+}
+
+describe("orgPath", () => {
+ afterEach(() => setOrganizationId(null));
+
+ it("builds the org-scoped path and keeps the trailing slash as written", () => {
+ setOrganizationId("org-1");
+ expect(orgPath("/adapter/")).toBe("/api/v1/unstract/org-1/adapter/");
+ expect(orgPath("/adapter/info/a1/")).toBe(
+ "/api/v1/unstract/org-1/adapter/info/a1/",
+ );
+ expect(orgPath("/flags")).toBe("/api/v1/unstract/org-1/flags");
+ });
+
+ it("uses the organisation given, else the current one", () => {
+ setOrganizationId("org-1");
+ expect(orgPath("/x/", "org-2")).toBe("/api/v1/unstract/org-2/x/");
+ expect(orgPath("/x/", undefined)).toBe("/api/v1/unstract/org-1/x/");
+ });
+
+ it("encodes the organisation id, so it stays one segment", () => {
+ setOrganizationId("a/b c");
+ expect(orgPath("/x/")).toBe("/api/v1/unstract/a%2Fb%20c/x/");
+ });
+
+ it("throws while no organisation is set", () => {
+ expect(() => orgPath("/adapter/")).toThrow(/No organisation is set/);
+ });
+});
+
+describe("CSRF header", () => {
+ beforeEach(() => {
+ setCookies("sessionid=s1; csrftoken=tok-123; theme=dark");
+ mockEcho();
+ });
+
+ it.each([
+ "post",
+ "put",
+ "patch",
+ "delete",
+ ] as const)("is sent on %s", async (method) => {
+ const { data } = await http.request({ url: URL, method });
+ expect(data.csrf).toBe("tok-123");
+ });
+
+ it("is not sent to another origin", async () => {
+ const other = "https://other.example/api/v1/echo/";
+ mockApi(
+ mock.post(other, ({ request }) =>
+ HttpResponse.json({
+ csrf: request.headers.get("X-CSRFToken"),
+ requestId: null,
+ }),
+ ),
+ );
+ const { data } = await http.post(other);
+ expect(data.csrf).toBeNull();
+ });
+
+ it("is not sent on get", async () => {
+ const { data } = await http.get(URL);
+ expect(data.csrf).toBeNull();
+ });
+
+ it("is not sent when there is no csrftoken cookie, or it is malformed", async () => {
+ setCookies("sessionid=s1");
+ expect((await http.post(URL)).data.csrf).toBeNull();
+ setCookies("csrftoken=%E0%A4%A");
+ expect((await http.post(URL)).data.csrf).toBeNull();
+ });
+});
+
+describe("request id header", () => {
+ beforeEach(mockEcho);
+
+ it("is a new UUID on every request", async () => {
+ const first = (await http.get(URL)).data.requestId;
+ const second = (await http.get(URL)).data.requestId;
+ expect(first).toMatch(UUID_V4);
+ expect(second).toMatch(UUID_V4);
+ expect(first).not.toBe(second);
+ });
+
+ it("is a UUID outside a secure context, where randomUUID is missing", async () => {
+ const { getRandomValues } = crypto;
+ vi.stubGlobal("crypto", { getRandomValues: getRandomValues.bind(crypto) });
+ const { data } = await http.get(URL);
+ expect(data.requestId).toMatch(UUID_V4);
+ });
+
+ it("keeps an id the caller set", async () => {
+ const headers = { [REQUEST_ID_HEADER]: "mine" };
+ const { data } = await http.get(URL, { headers });
+ expect(data.requestId).toBe("mine");
+ });
+});
+
+describe("failures", () => {
+ it("reject with an ApiError carrying the request id that was sent", async () => {
+ mockApi(mock.get(URL, () => HttpResponse.text("boom", { status: 500 })));
+ const headers = { [REQUEST_ID_HEADER]: "sent-1" };
+ const error = await http.get(URL, { headers }).catch((e: unknown) => e);
+ expect(error).toBeInstanceOf(ApiError);
+ expect(error).toMatchObject({ kind: "server", requestId: "sent-1" });
+ });
+});
+
+describe("401", () => {
+ it("expires the session once for concurrent 401s, and rejects each", async () => {
+ mockApi(
+ mock.get(URL, () =>
+ HttpResponse.json({ message: "Unauthorized" }, { status: 401 }),
+ ),
+ );
+ const expired = vi.fn();
+ const cleanup = onSessionExpired(expired);
+ const client = createHttpClient();
+ const results = await Promise.allSettled([
+ client.get(URL),
+ client.get(URL),
+ ]);
+ cleanup();
+ expect(expired).toHaveBeenCalledTimes(1);
+ for (const result of results) {
+ expect(result).toMatchObject({
+ status: "rejected",
+ reason: { kind: "unauthenticated" },
+ });
+ }
+ });
+
+ it("expires the session once per page load: the handler leaves the page", async () => {
+ mockApi(
+ mock.get(URL, () =>
+ HttpResponse.json({ message: "Unauthorized" }, { status: 401 }),
+ ),
+ );
+ const expired = vi.fn();
+ const cleanup = onSessionExpired(expired);
+ const client = createHttpClient();
+ const first = await client.get(URL).catch((e: unknown) => e);
+ const second = await client.get(URL).catch((e: unknown) => e);
+ cleanup();
+ expect(expired).toHaveBeenCalledTimes(1);
+ expect(first).toMatchObject({ kind: "unauthenticated" });
+ expect(second).toMatchObject({ kind: "unauthenticated" });
+ });
+
+ it("is not treated as expiry for other statuses", async () => {
+ mockApi(mock.get(URL, () => HttpResponse.json({}, { status: 403 })));
+ const expired = vi.fn();
+ const cleanup = onSessionExpired(expired);
+ await createHttpClient()
+ .get(URL)
+ .catch(() => undefined);
+ cleanup();
+ expect(expired).not.toHaveBeenCalled();
+ });
+});
+
+describe("cancellation", () => {
+ it("rejects as cancelled, not as an ApiError, when the signal aborts", async () => {
+ mockApi(
+ mock.get(URL, async () => {
+ await delay(50);
+ return HttpResponse.json({});
+ }),
+ );
+ const expired = vi.fn();
+ const cleanup = onSessionExpired(expired);
+ const controller = new AbortController();
+ const pending = http.get(URL, { signal: controller.signal });
+ controller.abort();
+ const error = await pending.catch((e: unknown) => e);
+ cleanup();
+ expect(isCancel(error)).toBe(true);
+ expect(error).not.toBeInstanceOf(ApiError);
+ expect(expired).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/v1/services/http.ts b/frontend/src/v1/services/http.ts
new file mode 100644
index 0000000000..061fadd6d7
--- /dev/null
+++ b/frontend/src/v1/services/http.ts
@@ -0,0 +1,101 @@
+import axios, {
+ type AxiosInstance,
+ type InternalAxiosRequestConfig,
+ isCancel,
+} from "axios";
+import { currentOrganizationId, expireSession } from "./context";
+import { REQUEST_ID_HEADER, toApiError } from "./errors";
+
+// The one HTTP client for v1 and its backend paths; only services/ imports it (see README.md).
+
+const CSRF_COOKIE = "csrftoken";
+const CSRF_HEADER = "X-CSRFToken";
+const MUTATING_METHODS = new Set(["post", "put", "patch", "delete"]);
+
+/** Options every service function takes. */
+export type RequestOptions = Readonly<{
+ signal?: AbortSignal;
+ /** The organisation to call; a query passes its key's, else the current one. */
+ org?: string;
+}>;
+
+/** `/api/v1/unstract/` + path, the trailing slash kept as written. */
+export function orgPath(
+ path: `/${string}`,
+ org: string = currentOrganizationId(),
+): string {
+ return `/api/v1/unstract/${encodeURIComponent(org)}${path}`;
+}
+
+function readCookie(name: string): string | undefined {
+ const prefix = `${name}=`;
+ const entry = document.cookie
+ .split("; ")
+ .find((part) => part.startsWith(prefix));
+ if (entry === undefined) {
+ return undefined;
+ }
+ try {
+ return decodeURIComponent(entry.slice(prefix.length));
+ } catch {
+ // A malformed value is no token: the backend refuses the request.
+ return undefined;
+ }
+}
+
+/* A v4 UUID; randomUUID exists only in secure contexts (not plain-http on-prem). */
+function newRequestId(): string {
+ if (typeof crypto.randomUUID === "function") {
+ return crypto.randomUUID();
+ }
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
+ "",
+ );
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+}
+
+// The CSRF token never goes to another origin.
+const isSameOrigin = (url: string | undefined): boolean =>
+ new URL(url ?? "", window.location.href).origin === window.location.origin;
+
+function addHeaders(
+ config: InternalAxiosRequestConfig,
+): InternalAxiosRequestConfig {
+ if (!config.headers.has(REQUEST_ID_HEADER)) {
+ config.headers.set(REQUEST_ID_HEADER, newRequestId());
+ }
+ const method = (config.method ?? "get").toLowerCase();
+ if (!MUTATING_METHODS.has(method) || !isSameOrigin(config.url)) {
+ return config;
+ }
+ const token = readCookie(CSRF_COOKIE);
+ if (token !== undefined) {
+ config.headers.set(CSRF_HEADER, token);
+ }
+ return config;
+}
+
+/** A client with its own session-expiry latch; services use `http`. */
+export function createHttpClient(): AxiosInstance {
+ const client = axios.create();
+ let expired = false;
+ client.interceptors.request.use(addHeaders);
+ client.interceptors.response.use(undefined, (error: unknown) => {
+ if (isCancel(error)) {
+ return Promise.reject(error);
+ }
+ const apiError = toApiError(error);
+ // Once per page load, so concurrent 401s redirect once; the handler leaves the page.
+ if (apiError.kind === "unauthenticated" && !expired) {
+ expired = true;
+ expireSession();
+ }
+ return Promise.reject(apiError);
+ });
+ return client;
+}
+
+export const http: AxiosInstance = createHttpClient();
diff --git a/frontend/src/v1/services/page.ts b/frontend/src/v1/services/page.ts
new file mode 100644
index 0000000000..66bff908fd
--- /dev/null
+++ b/frontend/src/v1/services/page.ts
@@ -0,0 +1,32 @@
+import type { QueryKey } from "@tanstack/react-query";
+
+// The backend's paged-list contract (DRF PageNumberPagination): `?page=&page_size=`.
+
+/** One page of a list endpoint. */
+export type Page = Readonly<{
+ /** Rows across all pages. */
+ count: number;
+ next: string | null;
+ previous: string | null;
+ results: readonly T[];
+}>;
+
+/** Which page to fetch; `page` starts at 1. */
+export type PageParams = Readonly<{ page: number; pageSize: number }>;
+
+/** The query parameters for a page, as the backend names them. */
+export function pageQuery({ page, pageSize }: PageParams) {
+ return { page, page_size: pageSize } as const;
+}
+
+/**
+ * A paged query's `placeholderData`: the previous page stays on screen while
+ * the next loads, but never one from another organisation (keys start with it).
+ */
+export const previousPageInOrg =
+ (queryKey: K) =>
+ (
+ previous: T | undefined,
+ previousQuery?: { queryKey: K },
+ ): T | undefined =>
+ previousQuery?.queryKey[0] === queryKey[0] ? previous : undefined;
diff --git a/frontend/src/v1/services/report.test.ts b/frontend/src/v1/services/report.test.ts
new file mode 100644
index 0000000000..6ba979752d
--- /dev/null
+++ b/frontend/src/v1/services/report.test.ts
@@ -0,0 +1,68 @@
+import { type NotifyInput, setNotifier } from "@v1/lib/notify";
+import { CanceledError } from "axios";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { ApiError } from "./errors";
+import { reportApiError } from "./report";
+
+let shown: NotifyInput[];
+let unregister: () => void;
+
+beforeEach(() => {
+ shown = [];
+ unregister = setNotifier({
+ show: (input) => {
+ shown.push(input);
+ return "toast-1";
+ },
+ dismiss: () => undefined,
+ });
+});
+afterEach(() => unregister());
+
+const failure = (kind: ApiError["kind"]) =>
+ new ApiError({ kind, message: `${kind} message`, requestId: "req-1" });
+
+it("shows a failure as an error toast with its request id", () => {
+ reportApiError(failure("server"), { id: "q1" });
+ expect(shown).toEqual([
+ {
+ kind: "error",
+ title: "server message",
+ requestId: "req-1",
+ persistent: false,
+ id: "q1",
+ },
+ ]);
+});
+
+it("keeps a subscription failure until it is dismissed", () => {
+ reportApiError(failure("subscription"));
+ expect(shown[0]).toMatchObject({ persistent: true });
+});
+
+it("only logs, with the request id, when the caller shows the error", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ reportApiError(failure("validation"), { notify: false });
+ expect(shown).toEqual([]);
+ expect(warn).toHaveBeenCalledWith(
+ "[v1 api] %s (request %s)",
+ "validation message",
+ "req-1",
+ );
+});
+
+it("reports nothing for a cancellation or an expired session", () => {
+ reportApiError(new CanceledError());
+ reportApiError(failure("unauthenticated"));
+ expect(shown).toEqual([]);
+});
+
+it("logs the cause of an unexpected failure and still shows it", () => {
+ const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const cause = new TypeError("boom");
+ reportApiError(cause);
+ expect(error).toHaveBeenCalledWith("[v1 api] unexpected failure", cause);
+ expect(shown).toEqual([
+ expect.objectContaining({ title: "Something went wrong." }),
+ ]);
+});
diff --git a/frontend/src/v1/services/report.ts b/frontend/src/v1/services/report.ts
new file mode 100644
index 0000000000..c0f68b9439
--- /dev/null
+++ b/frontend/src/v1/services/report.ts
@@ -0,0 +1,42 @@
+import { notify } from "@v1/lib/notify";
+import { isCancelled, toApiError } from "./errors";
+
+export type ReportOptions = Readonly<{
+ /** False when the caller shows the error itself; it is then only logged. */
+ notify?: boolean;
+ /** Reusing an id replaces the open toast instead of stacking another. */
+ id?: string;
+}>;
+
+/**
+ * Reports a failed request: a toast through notify(), or a log line with its
+ * request id when the caller shows it. Cancellations and session expiry
+ * (already redirected) report nothing.
+ */
+export function reportApiError(
+ error: unknown,
+ options: ReportOptions = {},
+): void {
+ if (isCancelled(error)) {
+ return;
+ }
+ const apiError = toApiError(error);
+ if (apiError.kind === "unauthenticated") {
+ return;
+ }
+ if (apiError.kind === "unknown") {
+ console.error("[v1 api] unexpected failure", apiError.cause);
+ }
+ if (options.notify === false) {
+ const requestId = apiError.requestId ?? "none";
+ console.warn("[v1 api] %s (request %s)", apiError.message, requestId);
+ return;
+ }
+ notify({
+ kind: "error",
+ title: apiError.message,
+ requestId: apiError.requestId,
+ persistent: apiError.kind === "subscription",
+ id: options.id,
+ });
+}
diff --git a/frontend/src/v1/test/README.md b/frontend/src/v1/test/README.md
index f1eec5558e..39bf5b8bbb 100644
--- a/frontend/src/v1/test/README.md
+++ b/frontend/src/v1/test/README.md
@@ -60,7 +60,8 @@ await user.click(screen.getByRole("button", { name: "Add" }));
- `user`: a `userEvent.setup()` instance. Pass `userEventOptions`, for example
`{ advanceTimers: vi.advanceTimersByTime }` with fake timers.
- `queryClient`: a fresh client per render, with no retries, no refetch on
- focus and no garbage collection mid-test. Pass `queryClient` to share one.
+ focus and no garbage collection mid-test. Pass `queryClient` to share one,
+ or `createQueryClient()` from `@v1/app/query` for the app's retry policy.
- `portal`: the `#v1-portal` container, themed like the root.
- `theme`: what the test rendered with.
@@ -80,7 +81,7 @@ the app's own provider once its task has landed, and a stand-in until then:
| Slot | Today | Replace with, when it lands |
| --- | --- | --- |
-| query | stand-in: `QueryClientProvider` over `createTestQueryClient()` | the data layer's provider (P0-T05, UN-4313) |
+| query | **real**: the app's `createQueryClient()` (P0-T05, UN-4313) with test defaults, so failures are reported as in the app | — |
| router | stand-in: `MemoryRouter` (+ `Routes` when `path` is set) | the app router in memory mode, with the route registry (P0-T03, UN-4271) |
| theme | **real**: `app/theme/ThemeProvider` (P0-T04, #2399) | — |
| session | none: no v1 code reads a session | the session store or provider, when it lands. Add `session` to `ProviderContext` and `RenderV1Options` with it |
@@ -106,7 +107,7 @@ import { apiPath, DEFAULT_SESSION, HttpResponse, http, mockApi } from "@v1/test"
mockApi(
http.get(apiPath(DEFAULT_SESSION, "/adapter/"), () =>
- HttpResponse.json(adapterFixtures.list),
+ HttpResponse.json(adapterFixtures.llm),
),
);
```
@@ -133,23 +134,10 @@ arguments. A test that leaves fake timers on gets real timers back after it.
Each service module in `services/` exports its request and response types.
Its fixtures live in `test/fixtures/.ts`, typed against those types
-so a contract change breaks the fixture at typecheck:
-
-```ts
-// test/fixtures/adapters.ts
-import type { Adapter } from "@v1/services/adapters";
-import { apiPath, HttpResponse, http, type TestSession } from "@v1/test";
-
-export const adapterFixtures = {
- list: [{ id: "a1", adapter_name: "OpenAI", adapter_type: "LLM" }],
-} satisfies Record;
-
-export const adapterHandlers = (session: TestSession) => [
- http.get(apiPath(session, "/adapter/"), () =>
- HttpResponse.json(adapterFixtures.list),
- ),
-];
-```
+with `satisfies`, so a contract change breaks the fixture at typecheck. See
+`test/fixtures/adapters.ts`: `adapterFixtures` holds the rows and
+`adapterHandlers(session, rows, seen)` answers `GET adapter/` with those
+rows, paged when the request asks for a page (it neither filters nor 404s).
## Accessibility
diff --git a/frontend/src/v1/test/fixtures/adapters.ts b/frontend/src/v1/test/fixtures/adapters.ts
new file mode 100644
index 0000000000..29caf8810d
--- /dev/null
+++ b/frontend/src/v1/test/fixtures/adapters.ts
@@ -0,0 +1,74 @@
+/* Fixtures for services/adapters, typed against its contract. */
+
+import type { Adapter, AdapterPage } from "@v1/services/adapters";
+import { apiPath, HttpResponse, http, type TestSession } from "@v1/test";
+
+export const adapterFixtures = {
+ llm: [
+ {
+ id: "4f0c2a8e-1d6b-4a51-9a39-6a3c1d2b7e10",
+ adapter_id: "openai|502ecf49-e47c-445c-9907-6d4b90c5cd17",
+ adapter_name: "OpenAI GPT-4o",
+ adapter_type: "LLM",
+ description: null,
+ icon: "/icons/adapter-icons/OpenAI.png",
+ created_at: "2026-11-01T10:00:00Z",
+ modified_at: "2026-11-02T10:00:00Z",
+ },
+ {
+ id: "9b7e5c3a-2f4d-4c1b-8e6a-0d9f8c7b6a51",
+ adapter_id: "anthropic|90ebd4cd-2f19-4cef-a884-9eeb6ac0f203",
+ adapter_name: "Claude",
+ adapter_type: "LLM",
+ description: "Default extraction model",
+ icon: "/icons/adapter-icons/Anthropic.png",
+ created_at: "2026-11-03T10:00:00Z",
+ modified_at: "2026-11-03T10:00:00Z",
+ },
+ ],
+} satisfies Record;
+
+/* The link to `page`, or null when it is out of range. */
+function pageLink(url: URL, page: number, last: number): string | null {
+ if (page < 1 || page > last) {
+ return null;
+ }
+ const link = new URL(url);
+ link.searchParams.set("page", String(page));
+ return link.href;
+}
+
+/* As the backend pages it: an envelope only when the caller asks for a page. */
+function answer(
+ url: URL,
+ rows: readonly Adapter[],
+): readonly Adapter[] | AdapterPage {
+ const page = Number(url.searchParams.get("page") || 0);
+ const size = Number(url.searchParams.get("page_size") || 0);
+ if (!page && !size) {
+ return rows;
+ }
+ const current = page || 1;
+ const perPage = size || 50;
+ const last = Math.ceil(rows.length / perPage);
+ return {
+ count: rows.length,
+ next: pageLink(url, current + 1, last),
+ previous: pageLink(url, current - 1, last),
+ results: rows.slice((current - 1) * perPage, current * perPage),
+ failing_count: 0,
+ };
+}
+
+/** `GET adapter/` answering `rows`, paged on request; records each URL in `seen`. */
+export const adapterHandlers = (
+ session: TestSession,
+ rows: readonly Adapter[] = adapterFixtures.llm,
+ seen: URL[] = [],
+) => [
+ http.get(apiPath(session, "/adapter/"), ({ request }) => {
+ const url = new URL(request.url);
+ seen.push(url);
+ return HttpResponse.json(answer(url, rows));
+ }),
+];
diff --git a/frontend/src/v1/test/providers.tsx b/frontend/src/v1/test/providers.tsx
index 296fcead21..2012b0680e 100644
--- a/frontend/src/v1/test/providers.tsx
+++ b/frontend/src/v1/test/providers.tsx
@@ -1,4 +1,5 @@
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createQueryClient } from "@v1/app/query";
import { ThemeProvider } from "@v1/app/theme/ThemeProvider";
import { getPortalContainer, PORTAL_CONTAINER_ID } from "@v1/lib/portal";
import { THEME_STORAGE_KEY } from "@v1/lib/theme";
@@ -12,7 +13,7 @@ import type { ThemeMode } from "./session";
* lands. A slot is the app's own provider once its task has merged, and a
* stand-in until then:
*
- * query stand-in P0-T05 data layer (UN-4313) → the app's QueryClient provider
+ * query real app/query (P0-T05, UN-4313), with test defaults
* router stand-in P0-T03 router + registry (UN-4271) → the app's router, in memory
* theme real app/theme/ThemeProvider (P0-T04, #2399)
*
@@ -38,13 +39,11 @@ export type ProviderSlot = (
props: Readonly<{ context: ProviderContext; children: ReactNode }>,
) => JSX.Element;
-/** A QueryClient for one test: no retries, no refetch on focus, no GC mid-test. */
+/** The app's QueryClient for one test: no retries, no refetch on focus, no GC mid-test. */
export function createTestQueryClient(): QueryClient {
- return new QueryClient({
- defaultOptions: {
- queries: { retry: false, gcTime: Infinity, refetchOnWindowFocus: false },
- mutations: { retry: false },
- },
+ return createQueryClient({
+ queries: { retry: false, gcTime: Infinity, refetchOnWindowFocus: false },
+ mutations: { retry: false },
});
}
diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs
index fcc736bba7..219612efaf 100644
--- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs
+++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs
@@ -116,6 +116,24 @@ const CASES = parseTable(`
# Outside the feedback folder, a static named radix-ui import without Toast is fine.
v1-notify-radix-named-ok | lint:v1 | -
v1-notify-feedback-ok | lint:v1 | -
+ # One way to call the backend: URLs and the HTTP client only in services/.
+ v1-backend-url-string | lint:v1 | Backend URLs appear only in services/ and plugins//services/
+ v1-backend-url-template | lint:v1 | Backend URLs appear only in services/ and plugins//services/
+ v1-backend-url-deployment | lint:v1 | Backend URLs appear only in services/ and plugins//services/
+ v1-http-client-axios | lint:v1 | Only services/ and plugins//services/ import the HTTP client
+ v1-http-client-module | lint:v1 | Only services/ and plugins//services/ import the HTTP client
+ v1-http-client-dynamic | lint:v1 | Only services/ and plugins//services/ import the HTTP client
+ v1-http-client-js-ext | lint:v1 | Only services/ and plugins//services/ import the HTTP client
+ # Biome's '../' ban stops lint:v1 first; check-v1 still catches it.
+ v1-http-client-relative | lint:v1 | never with '../'
+ v1-http-client-relative | node scripts/check-v1.mjs --code | Only services/ and plugins//services/ import the HTTP client
+ v1-raw-fetch | lint:v1 | Only services/ and plugins//services/ send requests
+ v1-raw-xhr | lint:v1 | Only services/ and plugins//services/ send requests
+ v1-raw-eventsource-global | lint:v1 | Only services/ and plugins//services/ send requests
+ v1-plugin-page-backend-url | lint:v1 | Backend URLs appear only in services/ and plugins//services/
+ v1-backend-url-services-ok | typecheck:v1 + lint:v1 | -
+ v1-plugin-services-ok | lint:v1 | -
+ v1-backend-url-test-ok | lint:v1 | -
v1-explicit-any | lint:v1 | noExplicitAny
# The v1 override must add to the shared rules, not replace them.
v1-base-rules-still-apply | lint:v1 | noDoubleEquals
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-deployment/src/v1/hooks/useRun.ts b/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-deployment/src/v1/hooks/useRun.ts
new file mode 100644
index 0000000000..812b4f1b8d
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-deployment/src/v1/hooks/useRun.ts
@@ -0,0 +1 @@
+export const RUN_URL = "/deployment/api/org-1/invoice/";
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-services-ok/src/v1/services/users.ts b/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-services-ok/src/v1/services/users.ts
new file mode 100644
index 0000000000..d9189b4dcf
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-services-ok/src/v1/services/users.ts
@@ -0,0 +1,12 @@
+import { http, orgPath, type RequestOptions } from "./http";
+
+export type User = Readonly<{ id: string; email: string }>;
+
+export async function listUsers({
+ signal,
+}: RequestOptions = {}): Promise {
+ const { data } = await http.get(orgPath("/users/"), { signal });
+ return data;
+}
+
+export const LOGOUT_URL = "/api/v1/logout";
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-string/src/v1/pages/demo/users.ts b/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-string/src/v1/pages/demo/users.ts
new file mode 100644
index 0000000000..cbce533dfe
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-string/src/v1/pages/demo/users.ts
@@ -0,0 +1 @@
+export const loadUsers = () => fetch("/api/v1/unstract/org-1/users/");
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-template/src/v1/shell/flags.ts b/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-template/src/v1/shell/flags.ts
new file mode 100644
index 0000000000..ca393083bb
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-template/src/v1/shell/flags.ts
@@ -0,0 +1 @@
+export const flagsUrl = (org: string) => `/api/v1/unstract/${org}/flags/`;
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-test-ok/src/v1/pages/demo/users.test.ts b/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-test-ok/src/v1/pages/demo/users.test.ts
new file mode 100644
index 0000000000..465eca1ccd
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-backend-url-test-ok/src/v1/pages/demo/users.test.ts
@@ -0,0 +1,5 @@
+import { expect, it } from "vitest";
+
+it("names the endpoint it mocks", () => {
+ expect("/api/v1/unstract/org-1/users/").toContain("/users/");
+});
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-http-client-axios/src/v1/components/patterns/load.ts b/frontend/test-fixtures/v1-boundary/cases/v1-http-client-axios/src/v1/components/patterns/load.ts
new file mode 100644
index 0000000000..e6d67571f3
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-http-client-axios/src/v1/components/patterns/load.ts
@@ -0,0 +1,3 @@
+import axios from "axios";
+
+export const load = (url: string) => axios.get(url);
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-http-client-dynamic/src/v1/stores/load.ts b/frontend/test-fixtures/v1-boundary/cases/v1-http-client-dynamic/src/v1/stores/load.ts
new file mode 100644
index 0000000000..0e397161aa
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-http-client-dynamic/src/v1/stores/load.ts
@@ -0,0 +1,2 @@
+export const load = async (url: string) =>
+ (await import("axios")).default.get(url);
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-http-client-js-ext/src/v1/pages/demo/path.ts b/frontend/test-fixtures/v1-boundary/cases/v1-http-client-js-ext/src/v1/pages/demo/path.ts
new file mode 100644
index 0000000000..4286a06071
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-http-client-js-ext/src/v1/pages/demo/path.ts
@@ -0,0 +1,3 @@
+import { orgPath } from "@v1/services/http.js";
+
+export const usersPath = () => orgPath("/users/");
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-http-client-module/src/v1/pages/demo/load.ts b/frontend/test-fixtures/v1-boundary/cases/v1-http-client-module/src/v1/pages/demo/load.ts
new file mode 100644
index 0000000000..8bb7140ae2
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-http-client-module/src/v1/pages/demo/load.ts
@@ -0,0 +1,3 @@
+import { http } from "@v1/services/http";
+
+export const load = (url: string) => http.get(url);
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-http-client-relative/src/v1/pages/demo/load.ts b/frontend/test-fixtures/v1-boundary/cases/v1-http-client-relative/src/v1/pages/demo/load.ts
new file mode 100644
index 0000000000..e549c5b78c
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-http-client-relative/src/v1/pages/demo/load.ts
@@ -0,0 +1,3 @@
+import { http } from "../../services/http";
+
+export const load = (url: string) => http.get(url);
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-plugin-page-backend-url/src/v1/plugins/billing/Plans.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-plugin-page-backend-url/src/v1/plugins/billing/Plans.tsx
new file mode 100644
index 0000000000..8cfb46c067
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-plugin-page-backend-url/src/v1/plugins/billing/Plans.tsx
@@ -0,0 +1 @@
+export const PLANS_URL = "/api/v1/plans/";
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-plugin-services-ok/src/v1/plugins/billing/services/plans.ts b/frontend/test-fixtures/v1-boundary/cases/v1-plugin-services-ok/src/v1/plugins/billing/services/plans.ts
new file mode 100644
index 0000000000..5a87d2573c
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-plugin-services-ok/src/v1/plugins/billing/services/plans.ts
@@ -0,0 +1,10 @@
+import { http, orgPath, type RequestOptions } from "@v1/services/http";
+
+export type Plan = Readonly<{ id: string; name: string }>;
+
+export async function listPlans({ signal }: RequestOptions = {}) {
+ const { data } = await http.get(orgPath("/plans/"), { signal });
+ return data;
+}
+
+export const PLANS_PAGE = "/api/v1/billing/plans/";
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-eventsource-global/src/v1/shell/stream.ts b/frontend/test-fixtures/v1-boundary/cases/v1-raw-eventsource-global/src/v1/shell/stream.ts
new file mode 100644
index 0000000000..09e71f5eb1
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-eventsource-global/src/v1/shell/stream.ts
@@ -0,0 +1 @@
+export const listen = (url: string) => new window.EventSource(url);
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-fetch/src/v1/pages/demo/load.ts b/frontend/test-fixtures/v1-boundary/cases/v1-raw-fetch/src/v1/pages/demo/load.ts
new file mode 100644
index 0000000000..9d93a5e636
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-fetch/src/v1/pages/demo/load.ts
@@ -0,0 +1 @@
+export const load = (url: string) => fetch(url, { method: "POST" });
diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-xhr/src/v1/hooks/useUpload.ts b/frontend/test-fixtures/v1-boundary/cases/v1-raw-xhr/src/v1/hooks/useUpload.ts
new file mode 100644
index 0000000000..8ac7fa308d
--- /dev/null
+++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-xhr/src/v1/hooks/useUpload.ts
@@ -0,0 +1 @@
+export const newUpload = () => new XMLHttpRequest();
diff --git a/tests/e2e/ui-v1/README.md b/tests/e2e/ui-v1/README.md
index fd6b627e4f..8474e3a48c 100644
--- a/tests/e2e/ui-v1/README.md
+++ b/tests/e2e/ui-v1/README.md
@@ -95,8 +95,10 @@ test.describe("P4-T01 API Deployments", { tag: DIST.both }, () => {
(`[data-ui-version="v1"]`, carrying `data-theme`), and the theme modes and
storage key from `src/v1/lib/theme.ts`.
- `specs/P0-T09.demo.spec.ts` proves the switch on P0-T03's demo pages
- (`/:orgName/v1-demo`, `/:orgName/v1-demo-review`). Those pages load no data,
- so the five-state helper is proven on `support/state-fixture.ts`.
+ (`/:orgName/v1-demo`, `/:orgName/v1-demo-review`). Their only data, the demo
+ page's adapter list, renders none of the state components the five-state
+ helper looks for (`STATE_TEST_IDS`), so it is proven on
+ `support/state-fixture.ts`.
Still open:
diff --git a/tests/e2e/ui-v1/specs/P0-T09.demo.spec.ts b/tests/e2e/ui-v1/specs/P0-T09.demo.spec.ts
index ec2f15216c..a36eae8656 100644
--- a/tests/e2e/ui-v1/specs/P0-T09.demo.spec.ts
+++ b/tests/e2e/ui-v1/specs/P0-T09.demo.spec.ts
@@ -9,8 +9,9 @@
* Part 2 runs on P0-T03's development-only demo pages, which the E2E image
* registers (VITE_V1_DEV_ROUTES=true): the version switch and its redirect,
* the theme matrix with an enforcing scan, and Human Review's old-version
- * round trip on its stand-in. The demo pages load no data, so the five-state
- * helper is proven by its self-tests (specs/harness/five-states.spec.ts).
+ * round trip on its stand-in. DemoPage's adapter list (UN-4313) renders none
+ * of the state components the five-state helper looks for (STATE_TEST_IDS), so
+ * the helper is proven by its self-tests (specs/harness/five-states.spec.ts).
*
* The redirect and the round trip also run in specs/harness/
* registry-routes.spec.ts, once per registered page. They stay here too, at
diff --git a/tests/e2e/ui-v1/support/state-fixture.ts b/tests/e2e/ui-v1/support/state-fixture.ts
index 49d8e624a3..516e60964b 100644
--- a/tests/e2e/ui-v1/support/state-fixture.ts
+++ b/tests/e2e/ui-v1/support/state-fixture.ts
@@ -1,9 +1,10 @@
/**
* A self-test fixture for the helpers, not an app page.
*
- * `expectFiveStates` needs a screen that loads data, and P0-T03's demo pages
- * (`/:orgName/v1-demo`) render static content. So the helper self-tests serve
- * this page instead: `page.route` fulfils `/__v1-harness-fixture` on the
+ * `expectFiveStates` needs a screen that renders the core state components
+ * (STATE_TEST_IDS), and P0-T03's demo pages (`/:orgName/v1-demo`) render none.
+ * So the helper self-tests serve this page instead: `page.route` fulfils
+ * `/__v1-harness-fixture` on the
* frontend origin with a minimal page that renders each state from its API
* (`/__v1-harness-fixture/api/items`), using the contract/v1-ui.ts markers.
* A green run here proves the helpers; the version switch is proven against