Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
xrpbanks marked this conversation as resolved.
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
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_frontend_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions frontend/app/api/calorieapp/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -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";
2 changes: 1 addition & 1 deletion frontend/app/auth/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/AccountDataExportButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/AccountDataImportPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion frontend/components/AccountErasurePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/FoodSearchPlaceholder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/XamanLoginPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
8 changes: 5 additions & 3 deletions frontend/lib/backendRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions release-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
77 changes: 77 additions & 0 deletions tools/tests/backend_request_route.test.mjs
Original file line number Diff line number Diff line change
@@ -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",
])
);
});
54 changes: 27 additions & 27 deletions tools/tests/xaman_login_start_retry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]);
});

Expand Down Expand Up @@ -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);
Expand All @@ -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]);

Expand Down Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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";
Expand All @@ -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";
Expand All @@ -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";
Expand All @@ -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";
Expand All @@ -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";
Expand Down
8 changes: 4 additions & 4 deletions tools/tests/xaman_logout_request.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
});

Expand All @@ -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]);
});
Expand All @@ -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]);
});