(null);
// Sync state when initialValue changes (e.g., when AI suggestions complete)
useEffect(() => {
setText(initialValue);
+ setError(null);
}, [initialValue]);
// Auto-resize textarea to fit content
@@ -52,12 +57,15 @@ export function EditableRecommendation({
const handleRegenerate = useCallback(async () => {
setLoading(true);
+ setError(null);
try {
const newText = await onRegenerate();
setText(newText);
onToast("Recommendation regenerated");
} catch (err) {
- onToast(aiErrorMessage(err, "Failed to regenerate"));
+ const message = aiErrorMessage(err, "Failed to regenerate");
+ onToast(message);
+ setError(message);
} finally {
setLoading(false);
}
@@ -108,6 +116,11 @@ export function EditableRecommendation({
rows={1}
className="w-full rounded-input bg-surface px-3 py-2 text-body-16 text-ink placeholder:text-faint outline-none focus:ring-1 focus:ring-brand transition-shadow resize-none overflow-hidden"
/>
+ {error && (
+
+ {error}
+
+ )}
{aiDisabled && (
Activate Optia Pro or add your own Anthropic key in options to use AI suggestions.
diff --git a/app/src/components/H2SelectionList.test.tsx b/app/src/components/H2SelectionList.test.tsx
index 1344cb2..6d68523 100644
--- a/app/src/components/H2SelectionList.test.tsx
+++ b/app/src/components/H2SelectionList.test.tsx
@@ -145,3 +145,100 @@ describe("H2SelectionList", () => {
});
});
});
+
+describe("H2SelectionList inline failure messages", () => {
+ const items = [
+ { index: 0, text: "Introduction", suggestion: "" },
+ { index: 1, text: "Features", suggestion: "Top Features" },
+ ];
+
+ const defaultProps = {
+ items,
+ onRegenerateOne: vi.fn().mockResolvedValue("Better heading"),
+ onRegenerateAll: vi.fn().mockResolvedValue(["New Intro", "New Features"]),
+ onToast: vi.fn(),
+ };
+
+ it("shows no failure messages initially", () => {
+ render();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("marks only the failed item after a per-item regenerate fails", async () => {
+ const user = userEvent.setup();
+ const onRegenerateOne = vi.fn().mockRejectedValue(new Error("fail"));
+ render();
+
+ await user.click(screen.getAllByTitle("Regenerate")[0]);
+
+ const alerts = screen.getAllByRole("alert");
+ expect(alerts).toHaveLength(1);
+ expect(alerts[0]).toHaveTextContent("Failed to regenerate");
+ });
+
+ it("surfaces the AI service's own message on a per-item failure", async () => {
+ const user = userEvent.setup();
+ const quota = Object.assign(new Error("AI quota reached."), { name: "AiProxyError" });
+ const onRegenerateOne = vi.fn().mockRejectedValue(quota);
+ render();
+
+ await user.click(screen.getAllByTitle("Regenerate")[1]);
+
+ expect(screen.getByRole("alert")).toHaveTextContent("AI quota reached.");
+ });
+
+ it("clears an item's failure once it regenerates successfully", async () => {
+ const user = userEvent.setup();
+ const onRegenerateOne = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("fail"))
+ .mockResolvedValueOnce("Better heading");
+ render();
+
+ await user.click(screen.getAllByTitle("Regenerate")[0]);
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+
+ await user.click(screen.getAllByTitle("Regenerate")[0]);
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(screen.getAllByRole("textbox")[0]).toHaveValue("Better heading");
+ });
+
+ it("marks the slots that Generate All could not fill", async () => {
+ const user = userEvent.setup();
+ const onRegenerateAll = vi.fn().mockResolvedValue([null, "New Features"]);
+ render();
+
+ await user.click(screen.getByText("Generate All"));
+
+ const alerts = screen.getAllByRole("alert");
+ expect(alerts).toHaveLength(1);
+ expect(alerts[0]).toHaveTextContent("Failed to generate");
+ expect(screen.getByDisplayValue("New Features")).toBeInTheDocument();
+ });
+
+ it("marks every item when Generate All fails outright", async () => {
+ const user = userEvent.setup();
+ const quota = Object.assign(new Error("AI quota reached."), { name: "AiProxyError" });
+ const onRegenerateAll = vi.fn().mockRejectedValue(quota);
+ render();
+
+ await user.click(screen.getByText("Generate All"));
+
+ const alerts = screen.getAllByRole("alert");
+ expect(alerts).toHaveLength(2);
+ for (const alert of alerts) expect(alert).toHaveTextContent("AI quota reached.");
+ });
+
+ it("clears earlier failures when Generate All later fills every slot", async () => {
+ const user = userEvent.setup();
+ const onRegenerateOne = vi.fn().mockRejectedValue(new Error("fail"));
+ render();
+
+ await user.click(screen.getAllByTitle("Regenerate")[0]);
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+
+ await user.click(screen.getByText("Generate All"));
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ expect(screen.getByDisplayValue("New Intro")).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/H2SelectionList.tsx b/app/src/components/H2SelectionList.tsx
index 7b3938c..81d7401 100644
--- a/app/src/components/H2SelectionList.tsx
+++ b/app/src/components/H2SelectionList.tsx
@@ -19,6 +19,13 @@ interface H2SelectionListProps {
className?: string;
}
+function omitIndex(errors: Record, index: number): Record {
+ if (!(index in errors)) return errors;
+ const next = { ...errors };
+ delete next[index];
+ return next;
+}
+
export function H2SelectionList({
items,
onRegenerateOne,
@@ -69,16 +76,22 @@ export function H2SelectionList({
const [loadingItems, setLoadingItems] = useState>(new Set());
const [copiedItems, setCopiedItems] = useState>(new Set());
const [loadingAll, setLoadingAll] = useState(false);
+ // Per-item generation failures, shown inside each item's card until that
+ // item is retried or a later Generate All fills it (#67).
+ const [errors, setErrors] = useState>({});
const handleRegenerateOne = useCallback(
async (index: number, h2Text: string) => {
setLoadingItems((prev) => new Set(prev).add(index));
+ setErrors((prev) => omitIndex(prev, index));
try {
const result = await onRegenerateOne(index, h2Text);
setSuggestions((prev) => ({ ...prev, [index]: result }));
onToast("H2 suggestion regenerated");
} catch (err) {
- onToast(aiErrorMessage(err, "Failed to regenerate"));
+ const message = aiErrorMessage(err, "Failed to regenerate");
+ onToast(message);
+ setErrors((prev) => ({ ...prev, [index]: message }));
} finally {
setLoadingItems((prev) => {
const next = new Set(prev);
@@ -92,10 +105,11 @@ export function H2SelectionList({
const handleRegenerateAll = useCallback(async () => {
setLoadingAll(true);
+ setErrors({});
try {
const results = await onRegenerateAll();
// Keep every suggestion that succeeded; failed slots stay empty for
- // per-item retry (their quota was refunded server-side).
+ // per-item retry (their quota was refunded server-side) and say so.
setSuggestions((prev) => {
const next = { ...prev };
items.forEach((item, i) => {
@@ -104,6 +118,13 @@ export function H2SelectionList({
});
return next;
});
+ setErrors(() => {
+ const next: Record = {};
+ items.forEach((item, i) => {
+ if (results[i] == null) next[item.index] = "Failed to generate";
+ });
+ return next;
+ });
const failed = items.filter((_, i) => results[i] == null).length;
onToast(
failed === 0
@@ -111,7 +132,10 @@ export function H2SelectionList({
: `Generated ${items.length - failed} of ${items.length} — retry the rest individually`,
);
} catch (err) {
- onToast(aiErrorMessage(err, "Failed to generate suggestions"));
+ // onRegenerateAll rejects only when every generation failed.
+ const message = aiErrorMessage(err, "Failed to generate suggestions");
+ onToast(message);
+ setErrors(Object.fromEntries(items.map((item) => [item.index, message])));
} finally {
setLoadingAll(false);
}
@@ -215,6 +239,11 @@ export function H2SelectionList({
+ {errors[item.index] && (
+
+ {errors[item.index]}
+
+ )}
);
})}
diff --git a/app/src/lib/entitlement-keys.test.ts b/app/src/lib/entitlement-keys.test.ts
new file mode 100644
index 0000000..03d792c
--- /dev/null
+++ b/app/src/lib/entitlement-keys.test.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect } from "vitest";
+import {
+ BACKEND_BASE_URL,
+ DEV_BACKEND_PROXY_PATH,
+ resolveBackendBaseUrl,
+} from "@/lib/entitlement-keys";
+
+const STAGING = "https://optia-backend-staging.paul-130.workers.dev";
+const PRODUCTION = "https://api.optia-api.com";
+
+// window.location shapes for the three places the app's modules load.
+const harnessPage = { protocol: "http:", origin: "http://localhost:5173" };
+const extensionPage = {
+ protocol: "chrome-extension:",
+ origin: "chrome-extension://gnlidlpidaoalbbmekofjednjkhhmehn",
+};
+
+describe("resolveBackendBaseUrl", () => {
+ it("routes the dev preview harness through the vite /api/backend proxy (#67)", () => {
+ expect(resolveBackendBaseUrl("development", harnessPage)).toBe(
+ `http://localhost:5173${DEV_BACKEND_PROXY_PATH}`,
+ );
+ });
+
+ it("keeps the proxy on the page origin when the harness is served elsewhere", () => {
+ expect(
+ resolveBackendBaseUrl("development", { protocol: "http:", origin: "http://192.168.1.20:4173" }),
+ ).toBe("http://192.168.1.20:4173/api/backend");
+ });
+
+ it("sends the unpacked dev extension (chrome-extension:// pages) to staging", () => {
+ expect(resolveBackendBaseUrl("development", extensionPage)).toBe(STAGING);
+ });
+
+ it("sends the service worker (no document location) to staging in development", () => {
+ expect(resolveBackendBaseUrl("development", undefined)).toBe(STAGING);
+ });
+
+ it("sends production builds to the production API regardless of location", () => {
+ expect(resolveBackendBaseUrl("production", harnessPage)).toBe(PRODUCTION);
+ expect(resolveBackendBaseUrl("production", extensionPage)).toBe(PRODUCTION);
+ expect(resolveBackendBaseUrl("production", undefined)).toBe(PRODUCTION);
+ });
+
+ it("sends every other mode (test, staging) to staging even on localhost", () => {
+ expect(resolveBackendBaseUrl("test", harnessPage)).toBe(STAGING);
+ expect(resolveBackendBaseUrl("staging", harnessPage)).toBe(STAGING);
+ });
+});
+
+describe("BACKEND_BASE_URL under vitest", () => {
+ it("is the staging URL, so unit tests never resolve to the harness proxy", () => {
+ expect(BACKEND_BASE_URL).toBe(STAGING);
+ });
+});
diff --git a/app/src/lib/entitlement-keys.ts b/app/src/lib/entitlement-keys.ts
index 6c65408..d6c64ba 100644
--- a/app/src/lib/entitlement-keys.ts
+++ b/app/src/lib/entitlement-keys.ts
@@ -32,9 +32,39 @@ const PRODUCTION_JWK: EntitlementJwk = {
const isProduction = import.meta.env.MODE === "production";
-export const BACKEND_BASE_URL: string = isProduction
- ? "https://api.optia-api.com"
- : "https://optia-backend-staging.paul-130.workers.dev";
+const PRODUCTION_BACKEND_URL = "https://api.optia-api.com";
+const STAGING_BACKEND_URL = "https://optia-backend-staging.paul-130.workers.dev";
+
+/** Mount path of the vite dev server's backend proxy (see vite.config.dev.ts). */
+export const DEV_BACKEND_PROXY_PATH = "/api/backend";
+
+/**
+ * Picks the backend origin for a build. Production builds use the production
+ * API and everything else uses staging, with one exception: the dev preview
+ * harness (dev.html served by vite over http://localhost) cannot call staging
+ * directly, because staging's CORS allowlist admits only the extension origin
+ * and the staging site (#67). The harness talks to staging through the dev
+ * server's /api/backend proxy instead, the same way BYOK calls go through
+ * /api/anthropic. `location` is the page's window.location, or undefined where
+ * there is no document (the service worker).
+ */
+export function resolveBackendBaseUrl(
+ mode: string,
+ location: Pick | undefined,
+): string {
+ if (mode === "production") return PRODUCTION_BACKEND_URL;
+ // Only the harness is served over http(s) in development; the unpacked dev
+ // extension's pages are chrome-extension:// and keep going to staging.
+ if (mode === "development" && location && /^https?:$/.test(location.protocol)) {
+ return `${location.origin}${DEV_BACKEND_PROXY_PATH}`;
+ }
+ return STAGING_BACKEND_URL;
+}
+
+export const BACKEND_BASE_URL: string = resolveBackendBaseUrl(
+ import.meta.env.MODE,
+ typeof window === "undefined" ? undefined : window.location,
+);
// Array to accommodate rotation overlap (multi-entry JWKS) later
export const ENTITLEMENT_JWKS: EntitlementJwk[] = isProduction
diff --git a/app/src/test/dev-backend-proxy.test.ts b/app/src/test/dev-backend-proxy.test.ts
new file mode 100644
index 0000000..a96006a
--- /dev/null
+++ b/app/src/test/dev-backend-proxy.test.ts
@@ -0,0 +1,165 @@
+// @vitest-environment node
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { Readable } from "node:stream";
+import type { ServerResponse } from "node:http";
+import type { Connect, ViteDevServer } from "vite";
+import { backendProxy, DEV_BACKEND_TARGET } from "../../vite.config.dev";
+
+// Drives the /api/backend middleware the dev preview harness relies on (#67)
+// with hand-built request/response objects and a stubbed global fetch, so the
+// proxy's forwarding rules are pinned without starting a vite server.
+
+type Handler = Connect.NextHandleFunction;
+
+async function mountBackendProxy(): Promise {
+ const plugin = backendProxy();
+ const mounted = new Map();
+ const server = {
+ middlewares: {
+ use: (path: string, handler: Handler) => {
+ mounted.set(path, handler);
+ },
+ },
+ } as unknown as ViteDevServer;
+
+ const hook = plugin.configureServer;
+ if (!hook) throw new Error("backendProxy has no configureServer hook");
+ await (typeof hook === "function" ? hook : hook.handler)(server);
+
+ const handler = mounted.get("/api/backend");
+ if (!handler) throw new Error("backendProxy did not mount /api/backend");
+ return handler;
+}
+
+interface FakeRequestInit {
+ method: string;
+ /** The path as connect hands it to a mounted middleware: mount prefix already stripped. */
+ url: string;
+ headers?: Record;
+ body?: string;
+}
+
+function fakeRequest({ method, url, headers = {}, body }: FakeRequestInit) {
+ const stream = Readable.from(body === undefined ? [] : [Buffer.from(body)]);
+ return Object.assign(stream, { method, url, headers }) as unknown as Connect.IncomingMessage;
+}
+
+function fakeResponse() {
+ const res = { writeHead: vi.fn(), end: vi.fn() };
+ res.writeHead.mockReturnValue(res);
+ return res as unknown as ServerResponse & typeof res;
+}
+
+function upstream(status: number, body: string, contentType = "application/json") {
+ return new Response(body, { status, headers: { "content-type": contentType } });
+}
+
+async function proxy(req: Connect.IncomingMessage) {
+ const handler = await mountBackendProxy();
+ const res = fakeResponse();
+ await handler(req, res, vi.fn());
+ return res;
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("backendProxy (dev harness → staging backend)", () => {
+ it("forwards a GET to the staging backend and relays status, content type and body", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(upstream(200, '{"models":["claude-x"]}', "application/json; charset=utf-8"));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const res = await proxy(fakeRequest({ method: "GET", url: "/ai/models?installId=abc" }));
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalledWith(`${DEV_BACKEND_TARGET}/ai/models?installId=abc`, {
+ method: "GET",
+ headers: {},
+ body: undefined,
+ });
+ expect(res.writeHead).toHaveBeenCalledWith(200, {
+ "Content-Type": "application/json; charset=utf-8",
+ "Access-Control-Allow-Origin": "*",
+ });
+ expect(res.end).toHaveBeenCalledWith('{"models":["claude-x"]}');
+ });
+
+ it("forwards a POST body with only the headers the backend accepts", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(upstream(200, '{"recommendation":"ok"}'));
+ vi.stubGlobal("fetch", fetchMock);
+ const body = JSON.stringify({ checkId: "title-keyword", keyword: "seo", installId: "install-1" });
+
+ await proxy(
+ fakeRequest({
+ method: "POST",
+ url: "/ai/generate",
+ body,
+ headers: {
+ "content-type": "application/json",
+ "x-optia-entitlement": "tok-123",
+ "x-optia-install-id": "install-1",
+ authorization: "Bearer abc",
+ // Browser-supplied headers that must not reach staging.
+ origin: "http://localhost:5173",
+ host: "localhost:5173",
+ referer: "http://localhost:5173/dev.html",
+ cookie: "session=secret",
+ "sec-fetch-mode": "cors",
+ },
+ }),
+ );
+
+ expect(fetchMock).toHaveBeenCalledWith(`${DEV_BACKEND_TARGET}/ai/generate`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-optia-entitlement": "tok-123",
+ "x-optia-install-id": "install-1",
+ authorization: "Bearer abc",
+ },
+ body,
+ });
+ });
+
+ it("relays upstream error statuses and bodies unchanged so the app's error mapping still applies", async () => {
+ const errorBody = '{"error":{"code":"QUOTA_EXCEEDED","message":"AI quota reached."}}';
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(upstream(429, errorBody)));
+
+ const res = await proxy(fakeRequest({ method: "POST", url: "/ai/generate", body: "{}" }));
+
+ expect(res.writeHead).toHaveBeenCalledWith(429, {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ });
+ expect(res.end).toHaveBeenCalledWith(errorBody);
+ });
+
+ it("answers CORS preflight itself without contacting the backend", async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+
+ const res = await proxy(fakeRequest({ method: "OPTIONS", url: "/ai/generate" }));
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(res.writeHead).toHaveBeenCalledWith(
+ 204,
+ expect.objectContaining({
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": expect.stringContaining("POST"),
+ }),
+ );
+ expect(res.end).toHaveBeenCalledWith();
+ });
+
+ it("responds 502 when the backend cannot be reached", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("fetch failed")));
+
+ const res = await proxy(fakeRequest({ method: "GET", url: "/health" }));
+
+ expect(res.writeHead).toHaveBeenCalledWith(502, { "Content-Type": "text/plain" });
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining("Backend proxy error"));
+ });
+});
diff --git a/app/vite.config.dev.ts b/app/vite.config.dev.ts
index 221dae2..75003a2 100644
--- a/app/vite.config.dev.ts
+++ b/app/vite.config.dev.ts
@@ -169,9 +169,90 @@ function anthropicProxy(): Plugin {
};
}
+/** The backend the dev preview harness reaches through /api/backend. */
+export const DEV_BACKEND_TARGET = "https://optia-backend-staging.paul-130.workers.dev";
+
+/**
+ * Vite plugin that proxies /api/backend/* to the staging Optia backend.
+ *
+ * The harness page is served from http://localhost, which staging's CORS
+ * allowlist rejects (it admits only the extension origin and the staging
+ * site), so hosted-AI, license and billing calls made straight from the page
+ * never get past the browser (#67). Routing them through the dev server
+ * sidesteps CORS the same way /api/anthropic does for BYOK calls. The app opts
+ * in via BACKEND_BASE_URL (src/lib/entitlement-keys.ts), which resolves to
+ * this path only in the harness — the extension itself never uses it.
+ */
+export function backendProxy(): Plugin {
+ // Only the request headers the backend's CORS policy accepts. Browser-added
+ // headers (Origin, Host, cookies, Sec-Fetch-*) stay on this side.
+ const forwarded = [
+ "content-type",
+ "authorization",
+ "x-optia-entitlement",
+ "x-optia-install-id",
+ ];
+ return {
+ name: "backend-proxy",
+ configureServer(server) {
+ server.middlewares.use("/api/backend", async (req, res) => {
+ // Handle CORS preflight (same-origin callers never send one; kept for parity)
+ if (req.method === "OPTIONS") {
+ res.writeHead(204, {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+ "Access-Control-Allow-Headers": forwarded.join(", "),
+ });
+ res.end();
+ return;
+ }
+
+ // connect strips the mount path, so req.url is the backend route with
+ // its query string intact (e.g. /ai/generate, /billing/session/cs_1?installId=...).
+ const method = req.method ?? "GET";
+ const targetUrl = `${DEV_BACKEND_TARGET}${req.url ?? ""}`;
+
+ try {
+ const headers: Record = {};
+ for (const name of forwarded) {
+ const value = req.headers[name];
+ if (typeof value === "string") headers[name] = value;
+ }
+
+ let body: string | undefined;
+ if (method !== "GET" && method !== "HEAD") {
+ const chunks: Buffer[] = [];
+ for await (const chunk of req) {
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
+ }
+ body = Buffer.concat(chunks).toString();
+ }
+
+ const response = await fetch(targetUrl, { method, headers, body });
+ const responseBody = await response.text();
+ console.log(`[Backend Proxy] ${method} ${req.url} -> ${response.status}`);
+ if (!response.ok) {
+ console.log("[Backend Proxy] Error response:", responseBody.slice(0, 500));
+ }
+ // Status and body pass through untouched so ai-proxy.ts / backend.ts
+ // map errors exactly as they would against staging directly.
+ res.writeHead(response.status, {
+ "Content-Type": response.headers.get("content-type") ?? "application/json",
+ "Access-Control-Allow-Origin": "*",
+ });
+ res.end(responseBody);
+ } catch (err) {
+ res.writeHead(502, { "Content-Type": "text/plain" });
+ res.end(`Backend proxy error: ${err}`);
+ }
+ });
+ },
+ };
+}
+
// Dev preview config — no CRXJS, just serves the side panel UI in a browser tab
export default defineConfig({
- plugins: [react(), fetchPageProxy(), anthropicProxy()],
+ plugins: [react(), fetchPageProxy(), anthropicProxy(), backendProxy()],
resolve: {
alias: {
"@": resolve(__dirname, "src"),