Skip to content

Commit 2e37e5b

Browse files
committed
Use configured browser API URL for Live Server static previews - PR_26169_025-browser-api-url-config
1 parent be22d2b commit 2e37e5b

15 files changed

Lines changed: 4807 additions & 1287 deletions

assets/theme-v2/js/account-auth-service.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { fetchServerApi } from "../../../src/api/public-config-client.js";
2+
13
export const ACCOUNT_SERVICE_READY_MESSAGE = "Account service is available.";
24
export const ACCOUNT_IDENTITY_SETUP_MESSAGE = "Account identity setup is incomplete. Please contact support.";
35
export const PASSWORD_RESET_RATE_LIMIT_MESSAGE = "Too many reset requests. Please wait and try again later.";
@@ -54,7 +56,7 @@ async function readJson(response, fallbackMessage) {
5456
}
5557

5658
export async function requestAccountAuth(path, options = {}, fallbackMessage = accountActionFailureMessage(path)) {
57-
const response = await fetch(`/api/auth/${path}`, {
59+
const response = await fetchServerApi(`/auth/${path}`, {
5860
body: options.body ? JSON.stringify(options.body) : undefined,
5961
headers: options.body ? { "content-type": "application/json" } : undefined,
6062
method: options.method || "GET",
@@ -63,7 +65,7 @@ export async function requestAccountAuth(path, options = {}, fallbackMessage = a
6365
}
6466

6567
export async function requestCurrentSession(fallbackMessage = accountActionFailureMessage("sign-in")) {
66-
const response = await fetch("/api/session/current", {
68+
const response = await fetchServerApi("/session/current", {
6769
headers: { "accept": "application/json" },
6870
method: "GET",
6971
});

assets/theme-v2/js/gamefoundry-partials.js

Lines changed: 201 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,197 @@
127127
const currentScript = document.currentScript || document.querySelector("script[src*='gamefoundry-partials.js']");
128128
const assetRoot = currentScript ? new URL("../", currentScript.src) : null;
129129
let navigationAdminMenuCache = null;
130+
let publicConfigCache = null;
131+
let publicConfigDataCache = null;
132+
let publicConfigLoaded = false;
133+
let publicConfigSource = "";
134+
const publicConfigDiagnostics = [];
135+
const publicConfigRoute = "/api/public/config";
136+
137+
function publishPublicConfigDiagnostics() {
138+
window.GameFoundryPublicConfigDiagnostics = {
139+
apiUrlConfigured: Boolean(publicConfigCache?.apiUrl),
140+
diagnostics: publicConfigDiagnostics.slice(),
141+
source: publicConfigSource
142+
};
143+
}
144+
145+
function recordPublicConfigDiagnostic(message) {
146+
const normalizedMessage = String(message || "").trim();
147+
if (normalizedMessage && !publicConfigDiagnostics.includes(normalizedMessage)) {
148+
publicConfigDiagnostics.push(normalizedMessage);
149+
}
150+
publishPublicConfigDiagnostics();
151+
return normalizedMessage;
152+
}
153+
154+
function normalizePublicConfig(value) {
155+
const config = value && typeof value === "object" ? value : {};
156+
return {
157+
apiUrl: typeof config.apiUrl === "string" ? config.apiUrl.trim() : "",
158+
environmentLabel: typeof config.environmentLabel === "string" ? config.environmentLabel.trim() : "",
159+
siteUrl: typeof config.siteUrl === "string" ? config.siteUrl.trim() : ""
160+
};
161+
}
162+
163+
function publicConfigFromPayload(payload) {
164+
if (payload?.ok === false) {
165+
return null;
166+
}
167+
return normalizePublicConfig(payload?.data?.publicConfig || payload?.publicConfig || {});
168+
}
169+
170+
function sameOriginConfigUrl() {
171+
return new URL(publicConfigRoute, window.location.origin).href;
172+
}
173+
174+
function isLocalHostname(hostname) {
175+
return ["localhost", "127.0.0.1", "::1"].includes(String(hostname || "").toLowerCase());
176+
}
177+
178+
function companionLocalConfigUrl() {
179+
if (!isLocalHostname(window.location.hostname)) {
180+
return "";
181+
}
182+
const port = Number(window.location.port || 0);
183+
if (!Number.isInteger(port) || port <= 0) {
184+
return "";
185+
}
186+
const url = new URL(publicConfigRoute, window.location.origin);
187+
url.port = String(port + 1);
188+
return url.href;
189+
}
190+
191+
function publicConfigCandidateUrls() {
192+
const sameOriginUrl = sameOriginConfigUrl();
193+
const companionUrl = companionLocalConfigUrl();
194+
const urls = window.location.port === "5500"
195+
? [companionUrl, sameOriginUrl]
196+
: [sameOriginUrl, companionUrl];
197+
return Array.from(new Set(urls.filter(Boolean)));
198+
}
199+
200+
function missingApiUrlDiagnostic() {
201+
return "GAMEFOUNDRY_API_URL is missing from the server public config. Falling back to same-origin /api; static-only Live Server origins cannot serve API routes. Set GAMEFOUNDRY_API_URL in .env and restart the site API.";
202+
}
203+
204+
function setLoadedPublicConfig(config, source, data) {
205+
publicConfigLoaded = true;
206+
publicConfigCache = normalizePublicConfig(config);
207+
publicConfigDataCache = data && typeof data === "object"
208+
? data
209+
: { publicConfig: publicConfigCache };
210+
publicConfigSource = source;
211+
publishPublicConfigDiagnostics();
212+
return publicConfigCache;
213+
}
214+
215+
function requestPublicConfigSync() {
216+
if (publicConfigLoaded) {
217+
return publicConfigCache;
218+
}
219+
if (window.GameFoundryPublicConfig && typeof window.GameFoundryPublicConfig === "object") {
220+
return setLoadedPublicConfig(window.GameFoundryPublicConfig, "browser-global");
221+
}
222+
for (const url of publicConfigCandidateUrls()) {
223+
try {
224+
const request = new XMLHttpRequest();
225+
request.open("GET", url, false);
226+
request.setRequestHeader("Accept", "application/json");
227+
request.send(null);
228+
if (request.status < 200 || request.status >= 300) {
229+
continue;
230+
}
231+
const payload = request.responseText ? JSON.parse(request.responseText) : null;
232+
const config = publicConfigFromPayload(payload);
233+
if (config) {
234+
return setLoadedPublicConfig(config, url, payload?.data || {});
235+
}
236+
} catch {
237+
// Try the next public config discovery URL.
238+
}
239+
}
240+
recordPublicConfigDiagnostic(missingApiUrlDiagnostic());
241+
return setLoadedPublicConfig({}, "same-origin-fallback");
242+
}
243+
244+
async function requestPublicConfigAsync() {
245+
if (publicConfigLoaded) {
246+
return publicConfigCache;
247+
}
248+
if (window.GameFoundryPublicConfig && typeof window.GameFoundryPublicConfig === "object") {
249+
return setLoadedPublicConfig(window.GameFoundryPublicConfig, "browser-global");
250+
}
251+
for (const url of publicConfigCandidateUrls()) {
252+
const response = await fetch(url, {
253+
headers: { "Accept": "application/json" },
254+
method: "GET"
255+
}).catch(function () {
256+
return null;
257+
});
258+
if (!response?.ok) {
259+
continue;
260+
}
261+
const payload = await response.json().catch(function () {
262+
return null;
263+
});
264+
const config = publicConfigFromPayload(payload);
265+
if (config) {
266+
return setLoadedPublicConfig(config, url, payload?.data || {});
267+
}
268+
}
269+
recordPublicConfigDiagnostic(missingApiUrlDiagnostic());
270+
return setLoadedPublicConfig({}, "same-origin-fallback");
271+
}
272+
273+
async function requestPublicConfigDataAsync() {
274+
await requestPublicConfigAsync();
275+
return publicConfigDataCache || { publicConfig: publicConfigCache || {} };
276+
}
277+
278+
function normalizeApiPath(path) {
279+
const value = String(path || "/").trim() || "/";
280+
if (/^https?:\/\//i.test(value)) {
281+
return value;
282+
}
283+
const rootedPath = value.startsWith("/") ? value : "/" + value;
284+
return rootedPath.indexOf("/api/") === 0 || rootedPath === "/api"
285+
? rootedPath.slice(4) || "/"
286+
: rootedPath;
287+
}
288+
289+
function sameOriginApiUrl(path) {
290+
const normalizedPath = normalizeApiPath(path);
291+
if (/^https?:\/\//i.test(normalizedPath)) {
292+
return normalizedPath;
293+
}
294+
return "/api" + normalizedPath;
295+
}
296+
297+
function configuredApiUrl(path, config) {
298+
const normalizedPath = normalizeApiPath(path);
299+
if (/^https?:\/\//i.test(normalizedPath)) {
300+
return normalizedPath;
301+
}
302+
const apiUrl = String(config?.apiUrl || "").trim().replace(/\/+$/, "");
303+
if (!apiUrl) {
304+
recordPublicConfigDiagnostic(missingApiUrlDiagnostic());
305+
return sameOriginApiUrl(normalizedPath);
306+
}
307+
return apiUrl + normalizedPath;
308+
}
309+
310+
function resolveApiUrl(path) {
311+
return configuredApiUrl(path, requestPublicConfigSync());
312+
}
313+
314+
async function resolveApiFetchUrl(path) {
315+
return configuredApiUrl(path, await requestPublicConfigAsync());
316+
}
317+
318+
async function fetchApi(path, options) {
319+
return fetch(await resolveApiFetchUrl(path), options);
320+
}
130321

131322
function assetUrl(path) {
132323
if (!assetRoot) return rootPrefix() + path;
@@ -193,13 +384,14 @@
193384
}
194385
try {
195386
const request = new XMLHttpRequest();
196-
request.open("GET", "/api/navigation/admin-menu", false);
387+
const url = resolveApiUrl("/navigation/admin-menu");
388+
request.open("GET", url, false);
197389
request.setRequestHeader("Accept", "application/json");
198390
request.send(null);
199391
const payload = request.responseText ? JSON.parse(request.responseText) : null;
200392
if (request.status < 200 || request.status >= 300 || payload?.ok === false) {
201393
if (request.status === 404 || request.status === 405) {
202-
throw new Error(serverRouteUnavailableDiagnostic("GET", "/api/navigation/admin-menu", request.status));
394+
throw new Error(serverRouteUnavailableDiagnostic("GET", url, request.status));
203395
}
204396
throw new Error(payload?.error || "Navigation API did not return Admin menu data.");
205397
}
@@ -353,7 +545,8 @@
353545

354546
function requestSessionApi(method, url, body) {
355547
const request = new XMLHttpRequest();
356-
request.open(method, url, false);
548+
const apiUrl = resolveApiUrl(url);
549+
request.open(method, apiUrl, false);
357550
request.setRequestHeader("Accept", "application/json");
358551
if (body !== undefined) {
359552
request.setRequestHeader("Content-Type", "application/json");
@@ -362,7 +555,7 @@
362555
const payload = request.responseText ? JSON.parse(request.responseText) : null;
363556
if (request.status < 200 || request.status >= 300 || payload?.ok === false) {
364557
if (request.status === 404 || request.status === 405) {
365-
throw new Error(serverRouteUnavailableDiagnostic(method, url, request.status));
558+
throw new Error(serverRouteUnavailableDiagnostic(method, apiUrl, request.status));
366559
}
367560
throw new Error(payload?.error || "Session API did not return a valid auth response.");
368561
}
@@ -395,7 +588,7 @@
395588

396589
function currentLoginState() {
397590
try {
398-
return authSessionFromApiData(requestSessionApi("GET", "/api/session/current"));
591+
return authSessionFromApiData(requestSessionApi("GET", "/session/current"));
399592
} catch (error) {
400593
const diagnostic = error instanceof Error ? error.message : "";
401594
if (diagnostic) {
@@ -419,7 +612,7 @@
419612
}
420613

421614
async function requestPlatformBanner() {
422-
const response = await fetch("/api/platform-settings/banner", {
615+
const response = await fetchApi("/platform-settings/banner", {
423616
headers: { "Accept": "application/json" },
424617
method: "GET"
425618
});
@@ -440,17 +633,7 @@
440633
}
441634

442635
async function requestEnvironmentBanner() {
443-
const response = await fetch("/api/public/config", {
444-
headers: { "Accept": "application/json" },
445-
method: "GET"
446-
});
447-
const payload = await response.json().catch(function () {
448-
return null;
449-
});
450-
if (!response.ok || payload?.ok === false) {
451-
throw new Error(payload?.error || "Public configuration is unavailable.");
452-
}
453-
const data = payload?.data || {};
636+
const data = await requestPublicConfigDataAsync();
454637
return {
455638
banner: normalizedPlatformBanner(data.environmentBanner || {}, "environment-config"),
456639
diagnostics: data.diagnostics || {}
@@ -784,7 +967,7 @@
784967
event.preventDefault();
785968
const session = (() => {
786969
try {
787-
return authSessionFromApiData(requestSessionApi("POST", "/api/session/logout"));
970+
return authSessionFromApiData(requestSessionApi("POST", "/session/logout"));
788971
} catch (error) {
789972
return missingSessionApiLoginState(error instanceof Error ? error.message : "");
790973
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# PR_26169_025-browser-api-url-config Report
2+
3+
## Summary
4+
5+
Implemented a browser-safe public API URL resolver so browser API calls use `GAMEFOUNDRY_API_URL` from `.env` through `/api/public/config` when configured. Static Live Server pages opened on `http://127.0.0.1:5500` now discover the API server through the public config endpoint and call the configured API root, without redirecting the browser and without exposing secrets.
6+
7+
## Branch Guard
8+
9+
| Check | Expected | Actual | Status |
10+
| --- | --- | --- | --- |
11+
| Current branch | `main` | `main` | PASS |
12+
| Local branches found | includes `main` | `main` | PASS |
13+
14+
## Requirement Checklist
15+
16+
| Requirement | Evidence | Status |
17+
| --- | --- | --- |
18+
| Expose only browser-safe public config values | Existing `/api/public/config` returns `siteUrl`, `apiUrl`, and `environmentLabel`; `tests/dev-runtime/PublicEnvironmentConfig.test.mjs` verifies no secret keys/values are exposed. | PASS |
19+
| Do not expose secrets or `.env` directly | Browser fetches server JSON only; no browser `.env` reads were added. Public config tests cover secret exclusions. | PASS |
20+
| Use configured `apiUrl` for auth APIs | `tests/playwright/tools/BrowserApiUrlConfig.spec.mjs` verifies `5500` sign-in page calls `http://127.0.0.1:5501/api/auth/status`. | PASS |
21+
| Use configured `apiUrl` for Admin and Owner APIs | `BrowserApiUrlConfig.spec.mjs` verifies Admin System Health and Owner Memberships calls use `5501/api`, not `5500/api`. | PASS |
22+
| Shared clients cover memberships, invitations, AI credits, marketplace, legal, owner, and admin APIs | `src/api/server-api-client.js` now resolves all shared `safeRequestServerApi` calls through `src/api/public-config-client.js`; affected feature clients already route through this shared client. | PASS |
23+
| Preserve same-origin fallback only when `apiUrl` is missing | `tests/dev-runtime/PublicApiUrlClient.test.mjs` verifies fallback to `/api/...` and an actionable `GAMEFOUNDRY_API_URL` diagnostic. | PASS |
24+
| Do not hardcode `5501` in browser code | Browser code derives the companion local config port from the current `5500` port and then uses the server-returned `apiUrl`. | PASS |
25+
| Do not redirect from `5500` to `5501` | Playwright remains on static `5500` pages while API calls use configured `5501/api`. | PASS |
26+
| Do not add a JSON config file | No JSON config file was added. | PASS |
27+
| Add CORS support needed for static Live Server to call local API | `src/dev-runtime/server/local-api-router.mjs` applies API CORS headers for API responses and preflight. | PASS |
28+
29+
## Files Changed
30+
31+
| File | Purpose |
32+
| --- | --- |
33+
| `src/api/public-config-client.js` | New browser-safe public config resolver and API URL builder. |
34+
| `src/api/server-api-client.js` | Routes shared sync browser API requests through configured `apiUrl`. |
35+
| `assets/theme-v2/js/account-auth-service.js` | Routes account auth/session fetches through configured `apiUrl`. |
36+
| `assets/theme-v2/js/gamefoundry-partials.js` | Routes shared-layout session, navigation, platform banner, and environment banner API calls through configured `apiUrl`. |
37+
| `src/dev-runtime/server/local-api-router.mjs` | Adds CORS headers for local API responses. |
38+
| `scripts/validate-browser-env-agnostic.mjs` | Updates account-service validation snippets for the configured API resolver contract. |
39+
| `tests/dev-runtime/PublicApiUrlClient.test.mjs` | Adds resolver/config fallback unit coverage. |
40+
| `tests/playwright/tools/BrowserApiUrlConfig.spec.mjs` | Adds static `5500` to configured `5501/api` browser coverage for auth, Admin, and Owner APIs. |
41+
42+
## Validation
43+
44+
| Command | Result |
45+
| --- | --- |
46+
| `git branch --show-current` | PASS, `main` |
47+
| `node --check` for touched JavaScript files | PASS |
48+
| `node --test tests/dev-runtime/PublicApiUrlClient.test.mjs tests/dev-runtime/PublicEnvironmentConfig.test.mjs` | PASS, 5 tests |
49+
| `npm run validate:browser-env-agnostic` | PASS |
50+
| `npx playwright test --config=playwright.config.cjs tests/playwright/tools/BrowserApiUrlConfig.spec.mjs` | PASS, 1 test |
51+
| `npx playwright test --config=playwright.config.cjs tests/playwright/tools/EnvironmentBannerCoverage.spec.mjs` | PASS, 3 tests |
52+
53+
## Notes
54+
55+
- Full samples smoke was skipped because samples are not in scope and no sample runtime was changed.
56+
- The broad Playwright structure audit was run accidentally and failed on existing unrelated lane-structure findings; its generated report churn was restored and the audit is not counted as this PR validation.
57+
- An exploratory full account sign-in Playwright run failed in the fake Supabase create-account fixture path, outside the URL-routing behavior covered by this PR. The scoped static-origin auth/Admin/Owner routing Playwright test passes.
58+
59+
## Impacted Lanes
60+
61+
| Lane | Decision |
62+
| --- | --- |
63+
| Runtime/config | Executed targeted public config and resolver tests. |
64+
| Browser account/API behavior | Executed static-origin Playwright API routing test. |
65+
| Theme V2 shared layout | Executed environment banner coverage because `gamefoundry-partials.js` changed. |
66+
| Admin/Owner | Covered through static-origin configured API routing for Admin System Health and Owner Memberships. |
67+
| Samples | Skipped; no samples touched or affected. |

0 commit comments

Comments
 (0)