diff --git a/README.md b/README.md index 30f9cff..4430306 100644 --- a/README.md +++ b/README.md @@ -146,9 +146,10 @@ Frontend environment setup: - Preferred variable: BACKEND_URL - Existing deployments may continue using NEXT_PUBLIC_BACKEND_URL as a fallback - Local development value: http://localhost:8000 -The browser calls the frontend's same-origin `/api/backend` proxy. The proxy +The browser calls the frontend's same-origin `/api/calorieapp` proxy. The proxy forwards only supported CalorieApp endpoints to the configured backend and -keeps CalorieApp cookies first-party to the frontend origin. +keeps CalorieApp cookies first-party to the frontend origin. The legacy +`/api/backend` alias remains available for backward compatibility. Production Xaman sign-in is owned by the WordPress page rendered through the `[calorieapp_embed]` shortcode. WordPress creates a SignIn payload without a diff --git a/backend/tests/test_frontend_configuration.py b/backend/tests/test_frontend_configuration.py index f96bdff..cfe2421 100644 --- a/backend/tests/test_frontend_configuration.py +++ b/backend/tests/test_frontend_configuration.py @@ -44,7 +44,7 @@ def test_account_export_ui_is_versioned_private_and_proxy_allowlisted(): ).read_text(encoding="utf-8") )["locales"]["en"]["export"] - assert '"/api/backend"' in export_source + assert '"/api/calorieapp"' in export_source assert "${BACKEND_BASE_URL}/api/identity/export" in export_source assert '"calorieapp-account-data-v2"' in export_source assert '"calorieapp-account-data-v2.json"' in export_source diff --git a/frontend/app/api/calorieapp/[...path]/route.ts b/frontend/app/api/calorieapp/[...path]/route.ts new file mode 100644 index 0000000..d3d0580 --- /dev/null +++ b/frontend/app/api/calorieapp/[...path]/route.ts @@ -0,0 +1,8 @@ +// Keep the legacy `/api/backend` route available for existing clients while +// exposing a brand-specific path that browser privacy filters do not block. +export { + dynamic, + GET, + POST, + DELETE, +} from "../../backend/[...path]/route"; diff --git a/frontend/app/auth/callback/page.tsx b/frontend/app/auth/callback/page.tsx index 111c1ab..0deb8b2 100644 --- a/frontend/app/auth/callback/page.tsx +++ b/frontend/app/auth/callback/page.tsx @@ -9,7 +9,7 @@ import { } from "@/lib/backendRequest"; import { safeWordPressReturn } from "@/lib/wordpressReturn"; -const BACKEND_BASE_URL = "/api/backend"; +const BACKEND_BASE_URL = "/api/calorieapp"; type CallbackResponse = { redirect_to: string; diff --git a/frontend/components/AccountDataExportButton.tsx b/frontend/components/AccountDataExportButton.tsx index 6e7fde1..a88b3a3 100644 --- a/frontend/components/AccountDataExportButton.tsx +++ b/frontend/components/AccountDataExportButton.tsx @@ -13,7 +13,7 @@ import { } from "@/lib/privateExportRequest"; import { getAccountPrivacyCopy } from "@/lib/accountPrivacyCopy"; -const BACKEND_BASE_URL = "/api/backend"; +const BACKEND_BASE_URL = "/api/calorieapp"; const ACCOUNT_EXPORT_VERSION = "calorieapp-account-data-v2"; const ACCOUNT_EXPORT_FILENAME = "calorieapp-account-data-v2.json"; const PRIVATE_EXPORT_URL_REVOCATION_DELAY_MS = 1_000; diff --git a/frontend/components/AccountDataImportPanel.tsx b/frontend/components/AccountDataImportPanel.tsx index b01bec7..5ff10a3 100644 --- a/frontend/components/AccountDataImportPanel.tsx +++ b/frontend/components/AccountDataImportPanel.tsx @@ -18,7 +18,7 @@ import { } from "@/lib/accountImportRequest"; import { getAccountPrivacyCopy } from "@/lib/accountPrivacyCopy"; -const BACKEND_BASE_URL = "/api/backend"; +const BACKEND_BASE_URL = "/api/calorieapp"; export const ACCOUNT_IMPORT_MAX_BYTES = 5 * 1024 * 1024; export const ACCOUNT_IMPORT_MAX_USER_ID_BYTES = 255; diff --git a/frontend/components/AccountErasurePanel.tsx b/frontend/components/AccountErasurePanel.tsx index 95f2dd1..cda5876 100644 --- a/frontend/components/AccountErasurePanel.tsx +++ b/frontend/components/AccountErasurePanel.tsx @@ -13,7 +13,7 @@ import { } from "@/lib/accountErasureRequest"; import { getAccountPrivacyCopy } from "@/lib/accountPrivacyCopy"; -const BACKEND_BASE_URL = "/api/backend"; +const BACKEND_BASE_URL = "/api/calorieapp"; const ACCOUNT_ERASURE_ACKNOWLEDGEMENT = "delete-my-calorieapp-account"; type AccountErasurePanelProps = { diff --git a/frontend/components/FoodSearchPlaceholder.tsx b/frontend/components/FoodSearchPlaceholder.tsx index f0df401..d924b39 100644 --- a/frontend/components/FoodSearchPlaceholder.tsx +++ b/frontend/components/FoodSearchPlaceholder.tsx @@ -20,7 +20,7 @@ import { waitForBackendReady, } from "@/lib/backendRequest"; -const BACKEND_BASE_URL = "/api/backend"; +const BACKEND_BASE_URL = "/api/calorieapp"; type PortionOption = "whole" | "half" | "quarter" | "custom"; const SIGN_IN_REQUIRED_LOG_MESSAGE = "Your session has expired or you are not signed in. Please sign in again to manage food logs."; diff --git a/frontend/components/XamanLoginPanel.tsx b/frontend/components/XamanLoginPanel.tsx index 42f62b1..a100210 100644 --- a/frontend/components/XamanLoginPanel.tsx +++ b/frontend/components/XamanLoginPanel.tsx @@ -47,7 +47,7 @@ type PendingLogin = { locale: string; }; -const BACKEND_BASE_URL = "/api/backend"; +const BACKEND_BASE_URL = "/api/calorieapp"; const ACCOUNT_ERASURE_UI_ENABLED = process.env.NEXT_PUBLIC_ACCOUNT_ERASURE_UI_ENABLED === "true"; const ACCOUNT_DATA_IMPORT_UI_ENABLED = diff --git a/frontend/lib/backendRequest.ts b/frontend/lib/backendRequest.ts index 1fa4efc..709fb49 100644 --- a/frontend/lib/backendRequest.ts +++ b/frontend/lib/backendRequest.ts @@ -4,9 +4,11 @@ export const DEFAULT_BACKEND_WARMUP_TIMEOUT_MS = 180_000; // Render may not wake one free service from another free service's proxy // request. In production this public URL is raced with the browser-safe // same-origin health route; all application requests continue to use the -// same-origin proxy. +// same-origin proxy. Keep the public browser path brand-specific: privacy +// filters can block generic `/api/backend/*` request paths before they leave +// the browser. export const BACKEND_WAKE_BASE_URL = - process.env.NEXT_PUBLIC_BACKEND_WAKE_URL?.trim() || "/api/backend"; + process.env.NEXT_PUBLIC_BACKEND_WAKE_URL?.trim() || "/api/calorieapp"; const BACKEND_WARMUP_ATTEMPT_TIMEOUT_MS = 70_000; const BACKEND_WARMUP_INITIAL_RETRY_DELAY_MS = 5_000; @@ -196,7 +198,7 @@ export async function waitForBackendReady( signal?: AbortSignal, timeoutMs = DEFAULT_BACKEND_WARMUP_TIMEOUT_MS ) { - const sameOriginBaseUrl = "/api/backend"; + const sameOriginBaseUrl = "/api/calorieapp"; const normalizedBaseUrl = backendBaseUrl.replace(/\/$/, ""); if (normalizedBaseUrl === sameOriginBaseUrl) { diff --git a/release-check.sh b/release-check.sh index bce9457..74410ef 100644 --- a/release-check.sh +++ b/release-check.sh @@ -73,6 +73,7 @@ node --test \ "$repo_root/tools/tests/account_data_export_validation.test.mjs" \ "$repo_root/tools/tests/account_erasure_ui.test.mjs" \ "$repo_root/tools/tests/account_privacy_locales.test.mjs" \ + "$repo_root/tools/tests/backend_request_route.test.mjs" \ "$repo_root/tools/tests/calorieapp_embed_readiness.test.mjs" \ "$repo_root/tools/tests/identity_locales.test.mjs" \ "$repo_root/tools/tests/xaman_logout_request.test.mjs" \ diff --git a/tools/tests/backend_request_route.test.mjs b/tools/tests/backend_request_route.test.mjs new file mode 100644 index 0000000..518b80c --- /dev/null +++ b/tools/tests/backend_request_route.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +const REQUEST_HELPER_PATH = new URL( + "../../frontend/lib/backendRequest.ts", + import.meta.url +); +const requireFromFrontend = createRequire( + new URL("../../frontend/package.json", import.meta.url) +); + +async function loadBackendRequest(fetch) { + const typescript = requireFromFrontend("typescript"); + const source = await readFile(REQUEST_HELPER_PATH, "utf8"); + const compiled = typescript.transpileModule(source, { + compilerOptions: { + module: typescript.ModuleKind.CommonJS, + target: typescript.ScriptTarget.ES2022, + }, + }).outputText; + const module = { exports: {} }; + const context = vm.createContext({ + AbortController, + Date, + Error, + Math, + Number, + Promise, + URL, + clearTimeout, + console, + fetch, + module, + exports: module.exports, + process: { + env: { NEXT_PUBLIC_BACKEND_WAKE_URL: "https://backend.example" }, + }, + setTimeout, + }); + + vm.runInContext(compiled, context); + return module.exports; +} + +test("readiness races the public origin with the filter-safe same-origin route", async () => { + const calls = []; + const backendRequest = await loadBackendRequest(async (url) => { + calls.push(url); + if (url !== "/api/calorieapp/health") { + throw new Error("synthetic blocked cross-origin request"); + } + + return { + ok: true, + headers: { get: () => "application/json" }, + json: async () => ({ status: "ok" }), + }; + }); + + await backendRequest.waitForBackendReady( + backendRequest.BACKEND_WAKE_BASE_URL, + undefined, + 1_000 + ); + + assert.equal(calls.length, 2); + assert.deepEqual( + new Set(calls), + new Set([ + "https://backend.example/health", + "/api/calorieapp/health", + ]) + ); +}); diff --git a/tools/tests/xaman_login_start_retry.test.mjs b/tools/tests/xaman_login_start_retry.test.mjs index 7a7bf54..77b0cda 100644 --- a/tools/tests/xaman_login_start_retry.test.mjs +++ b/tools/tests/xaman_login_start_retry.test.mjs @@ -143,8 +143,8 @@ test("logout retries the cookie-clearing endpoint after an interrupted response" await module.exports.requestCalorieAppLogout(); assert.deepEqual(requests, [ - { url: "/api/backend/api/identity/logout", timeoutMs: 75000 }, - { url: "/api/backend/api/identity/logout", timeoutMs: 15000 }, + { url: "/api/calorieapp/api/identity/logout", timeoutMs: 75000 }, + { url: "/api/calorieapp/api/identity/logout", timeoutMs: 15000 }, ]); }); @@ -807,8 +807,8 @@ test("embedded completion recovers safely without replaying one-time codes", asy assert.equal(directUser.user_id, "calorieapp-user"); assert.deepEqual(requests, [ - { url: "/api/backend/api/identity/callback", method: "POST" }, - { url: "/api/backend/api/identity/me", method: "GET" }, + { url: "/api/calorieapp/api/identity/callback", method: "POST" }, + { url: "/api/calorieapp/api/identity/me", method: "GET" }, ]); assert.deepEqual(scheduledDelays, []); assert.equal(cancelledResponseBodies, 0); @@ -825,9 +825,9 @@ test("embedded completion recovers safely without replaying one-time codes", asy ); assert.equal(transientCookieUser.user_id, "calorieapp-user"); assert.deepEqual(requests.slice(transientCookieStart), [ - { url: "/api/backend/api/identity/callback", method: "POST" }, - { url: "/api/backend/api/identity/me", method: "GET" }, - { url: "/api/backend/api/identity/me", method: "GET" }, + { url: "/api/calorieapp/api/identity/callback", method: "POST" }, + { url: "/api/calorieapp/api/identity/me", method: "GET" }, + { url: "/api/calorieapp/api/identity/me", method: "GET" }, ]); assert.deepEqual(scheduledDelays, [5000]); @@ -868,10 +868,10 @@ test("embedded completion recovers safely without replaying one-time codes", asy ); assert.equal(fallbackUser.user_id, "calorieapp-user"); assert.deepEqual(requests.slice(fallbackStart), [ - { url: "/api/backend/api/identity/callback", method: "POST" }, - { url: "/api/backend/api/identity/me", method: "GET" }, - { url: "/api/backend/api/identity/login/status", method: "POST" }, - { url: "/api/backend/api/identity/me", method: "GET" }, + { url: "/api/calorieapp/api/identity/callback", method: "POST" }, + { url: "/api/calorieapp/api/identity/me", method: "GET" }, + { url: "/api/calorieapp/api/identity/login/status", method: "POST" }, + { url: "/api/calorieapp/api/identity/me", method: "GET" }, ]); scenario = "rate-limited"; @@ -893,8 +893,8 @@ test("embedded completion recovers safely without replaying one-time codes", asy } ); assert.deepEqual(requests.slice(rateLimitStart), [ - { url: "/api/backend/api/identity/callback", method: "POST" }, - { url: "/api/backend/api/identity/login/status", method: "POST" }, + { url: "/api/calorieapp/api/identity/callback", method: "POST" }, + { url: "/api/calorieapp/api/identity/login/status", method: "POST" }, ]); scenario = "ambiguous-completed"; @@ -909,9 +909,9 @@ test("embedded completion recovers safely without replaying one-time codes", asy ); assert.equal(recoveredUser.user_id, "calorieapp-user"); assert.deepEqual(requests.slice(ambiguousStart), [ - { url: "/api/backend/api/identity/callback", method: "POST" }, - { url: "/api/backend/api/identity/login/status", method: "POST" }, - { url: "/api/backend/api/identity/me", method: "GET" }, + { url: "/api/calorieapp/api/identity/callback", method: "POST" }, + { url: "/api/calorieapp/api/identity/login/status", method: "POST" }, + { url: "/api/calorieapp/api/identity/me", method: "GET" }, ]); scenario = "ambiguous-session-unavailable"; @@ -932,9 +932,9 @@ test("embedded completion recovers safely without replaying one-time codes", asy } ); assert.deepEqual(requests.slice(unavailableSessionStart), [ - { url: "/api/backend/api/identity/callback", method: "POST" }, - { url: "/api/backend/api/identity/login/status", method: "POST" }, - { url: "/api/backend/api/identity/me", method: "GET" }, + { url: "/api/calorieapp/api/identity/callback", method: "POST" }, + { url: "/api/calorieapp/api/identity/login/status", method: "POST" }, + { url: "/api/calorieapp/api/identity/me", method: "GET" }, ]); scenario = "malformed-success"; @@ -949,9 +949,9 @@ test("embedded completion recovers safely without replaying one-time codes", asy ); assert.equal(malformedRecoveryUser.user_id, "calorieapp-user"); assert.deepEqual(requests.slice(malformedStart), [ - { url: "/api/backend/api/identity/callback", method: "POST" }, - { url: "/api/backend/api/identity/login/status", method: "POST" }, - { url: "/api/backend/api/identity/me", method: "GET" }, + { url: "/api/calorieapp/api/identity/callback", method: "POST" }, + { url: "/api/calorieapp/api/identity/login/status", method: "POST" }, + { url: "/api/calorieapp/api/identity/me", method: "GET" }, ]); scenario = "consumed-after-success"; @@ -966,9 +966,9 @@ test("embedded completion recovers safely without replaying one-time codes", asy ); assert.equal(consumedRecoveryUser.user_id, "calorieapp-user"); assert.deepEqual(requests.slice(consumedStart), [ - { url: "/api/backend/api/identity/callback", method: "POST" }, - { url: "/api/backend/api/identity/login/status", method: "POST" }, - { url: "/api/backend/api/identity/me", method: "GET" }, + { url: "/api/calorieapp/api/identity/callback", method: "POST" }, + { url: "/api/calorieapp/api/identity/login/status", method: "POST" }, + { url: "/api/calorieapp/api/identity/me", method: "GET" }, ]); scenario = "permanent-callback"; @@ -984,8 +984,8 @@ test("embedded completion recovers safely without replaying one-time codes", asy /Callback failed with 400/ ); assert.deepEqual(requests.slice(permanentStart), [ - { url: "/api/backend/api/identity/callback", method: "POST" }, - { url: "/api/backend/api/identity/login/status", method: "POST" }, + { url: "/api/calorieapp/api/identity/callback", method: "POST" }, + { url: "/api/calorieapp/api/identity/login/status", method: "POST" }, ]); scenario = "permanent-me"; diff --git a/tools/tests/xaman_logout_request.test.mjs b/tools/tests/xaman_logout_request.test.mjs index 57baf39..916203e 100644 --- a/tools/tests/xaman_logout_request.test.mjs +++ b/tools/tests/xaman_logout_request.test.mjs @@ -83,7 +83,7 @@ test("joint logout uses the same-origin proxy without a readiness wait", async ( await login.requestCalorieAppLogout(); assert.deepEqual(events, [ - "request:/api/backend/api/identity/logout:75000", + "request:/api/calorieapp/api/identity/logout:75000", ]); }); @@ -104,7 +104,7 @@ test("joint logout accepts an absent app session and rejects an HTTP failure", a await assert.rejects(login.requestCalorieAppLogout(), /Unable to log out/); assert.equal(calls.length, 3); - assert.equal(calls[0].url, "/api/backend/api/identity/logout"); + assert.equal(calls[0].url, "/api/calorieapp/api/identity/logout"); assert.equal(calls[0].options.method, "POST"); assert.deepEqual(calls.map(({ timeoutMs }) => timeoutMs), [75000, 75000, 75000]); }); @@ -122,8 +122,8 @@ test("joint logout retries cookie clearing after a transport interruption", asyn await login.requestCalorieAppLogout(); assert.deepEqual(calls.map(({ url }) => url), [ - "/api/backend/api/identity/logout", - "/api/backend/api/identity/logout", + "/api/calorieapp/api/identity/logout", + "/api/calorieapp/api/identity/logout", ]); assert.deepEqual(calls.map(({ timeoutMs }) => timeoutMs), [75000, 15000]); });