diff --git a/src/components/session/token/SessionUser.vue b/src/components/session/token/SessionUser.vue index ccf0e3e5..56e929ff 100644 --- a/src/components/session/token/SessionUser.vue +++ b/src/components/session/token/SessionUser.vue @@ -48,6 +48,7 @@ import Swal from "sweetalert2"; import { SubuserGrants } from "@/components/session/token/SessionUser/Objects/SubuserGrants.vue"; import { Subusers } from "@/components/session/token/SessionUser/Objects/Subusers.vue"; import { configureReleaseRuntime } from "@/services/releaseTimeline.js"; +import { fetchReleaseRuntime } from "@/services/releaseBootstrap.js"; import { isReleaseChannelApiAvailabilityError, markReleaseChannelApiUnavailable, @@ -128,9 +129,9 @@ const applyReleaseRuntimeConfig = (runtime) => { }; export const refreshReleaseRuntime = async () => { - return authenticatedRequest("/release/runtime", "GET", releaseChannelRuntimeRequestParams()) - .then((response) => { - applyReleaseRuntimeConfig(response?.data?.data || response?.data || {}); + return fetchReleaseRuntime() + .then((runtime) => { + applyReleaseRuntimeConfig(runtime || {}); }) .catch((error) => { console.warn("Could not refresh release runtime", error); diff --git a/src/config.js b/src/config.js index 323df142..a9dbf160 100644 --- a/src/config.js +++ b/src/config.js @@ -26,6 +26,9 @@ export const API_URL = normalizeApiUrl( export const RELEASE_MANAGER_CONTROL_API_URL = normalizeApiUrl( import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_URL || API_URL ); +export const RELEASE_PUBLIC_GATEWAY_API_URL = normalizeApiUrl( + import.meta.env.VITE_RELEASE_PUBLIC_GATEWAY_API_URL || "https://api-v2.truckwash.io" +); export const RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS = String( import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS || (IS_DEV ? "https://api.truckwash.io:4433,https://api.truckwash.io" : "") diff --git a/src/services/releaseBootstrap.js b/src/services/releaseBootstrap.js index c2376f44..94037c38 100644 --- a/src/services/releaseBootstrap.js +++ b/src/services/releaseBootstrap.js @@ -1,4 +1,4 @@ -import { API_URL } from "@/config.js"; +import { API_URL, RELEASE_PUBLIC_GATEWAY_API_URL } from "@/config.js"; import { buildReleaseHeaders } from "@/services/releaseHeaders.js"; export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__"; @@ -30,6 +30,20 @@ const browserStorage = () => { const readSelectedReleaseChannel = () => normalizeReleaseChannelSlug(browserStorage()?.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY) || ""); +const releaseChannelGatewayApiBaseUrl = (channelSlug, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => { + const slug = normalizeReleaseChannelSlug(channelSlug); + if (!slug || slug === "stable") { + return ""; + } + + const baseUrl = normalizeBaseUrl(gatewayBaseUrl); + if (!baseUrl || !/^https?:\/\//i.test(baseUrl)) { + return ""; + } + + return new URL(`${slug}/api/`, `${baseUrl}/`).href.replace(/\/+$/, ""); +}; + const buildRuntimeHeaders = () => { const storage = browserStorage(); const headers = { @@ -47,9 +61,11 @@ const buildRuntimeHeaders = () => { return headers; }; -export const runtimeApiUrl = (apiBaseUrl = API_URL) => { - const url = new URL("release/runtime", `${normalizeBaseUrl(apiBaseUrl)}/`); +export const runtimeApiUrl = (apiBaseUrl = API_URL, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => { const selectedChannel = readSelectedReleaseChannel(); + const selectedChannelApiBaseUrl = releaseChannelGatewayApiBaseUrl(selectedChannel, gatewayBaseUrl); + const runtimeBaseUrl = selectedChannelApiBaseUrl || normalizeBaseUrl(apiBaseUrl); + const url = new URL("release/runtime", `${runtimeBaseUrl}/`); if (selectedChannel) { url.searchParams.set("release_channel", selectedChannel); } @@ -71,12 +87,16 @@ const parseJsonResponse = async (response, label) => { return response?.json?.(); }; -export const fetchReleaseRuntime = async ({ fetchFn = globalThis.fetch, apiBaseUrl = API_URL } = {}) => { +export const fetchReleaseRuntime = async ({ + fetchFn = globalThis.fetch, + apiBaseUrl = API_URL, + gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL, +} = {}) => { if (typeof fetchFn !== "function") { return null; } - const response = await fetchFn(runtimeApiUrl(apiBaseUrl), { + const response = await fetchFn(runtimeApiUrl(apiBaseUrl, gatewayBaseUrl), { method: "GET", headers: buildRuntimeHeaders(), credentials: "omit", diff --git a/tests/e2e/release-bootstrap.spec.js b/tests/e2e/release-bootstrap.spec.js index 61e2babd..f6f15064 100644 --- a/tests/e2e/release-bootstrap.spec.js +++ b/tests/e2e/release-bootstrap.spec.js @@ -15,6 +15,7 @@ const json = (body, status = 200) => ({ test("non-default release frontend loads from api-v2.truckwash.io without redirecting", async ({ page }) => { const releaseApiRequests = []; const releaseEntryRequests = []; + const runtimeRequests = []; const runtime = { generated_at: "2026-05-20T10:00:00.000Z", trace_id: "trace-release-bootstrap", @@ -46,7 +47,12 @@ test("non-default release frontend loads from api-v2.truckwash.io without redire }, }; - await page.route("**/release/runtime**", async (route) => { + await page.addInitScript(() => { + window.localStorage.setItem("release_channel_selected_slug", "canary"); + }); + + await page.route("https://api-v2.truckwash.io/canary/api/release/runtime**", async (route) => { + runtimeRequests.push(route.request().url()); await route.fulfill(json({ data: runtime })); }); await page.route("https://api-v2.truckwash.io/canary/frontend/release-entry.json", async (route) => { @@ -89,6 +95,7 @@ test("non-default release frontend loads from api-v2.truckwash.io without redire const currentOrigin = await page.evaluate(() => window.location.origin); expect(page.url()).toContain("/shared/passkey-safe-link"); expect(page.url()).not.toContain("api-v2.truckwash.io"); + expect(runtimeRequests).toEqual(["https://api-v2.truckwash.io/canary/api/release/runtime?release_channel=canary"]); expect(releaseEntryRequests).toEqual(["https://api-v2.truckwash.io/canary/frontend/release-entry.json"]); await expect.poll(() => releaseApiRequests.length).toBe(1); expect(releaseApiRequests[0]).toBe("https://api-v2.truckwash.io/canary/api/ping"); diff --git a/tests/unit/release-bootstrap.spec.js b/tests/unit/release-bootstrap.spec.js index 22af7402..ea34b205 100644 --- a/tests/unit/release-bootstrap.spec.js +++ b/tests/unit/release-bootstrap.spec.js @@ -26,7 +26,15 @@ describe("release bootstrap", () => { localStorage.setItem("release_channel_selected_slug", "Canary Preview!"); expect(runtimeApiUrl("https://api.truckwash.io")).toBe( - "https://api.truckwash.io/release/runtime?release_channel=canary-preview" + "https://api-v2.truckwash.io/canary-preview/api/release/runtime?release_channel=canary-preview" + ); + }); + + it("keeps stable runtime requests on the configured control API", () => { + localStorage.setItem("release_channel_selected_slug", "stable"); + + expect(runtimeApiUrl("https://api.truckwash.io")).toBe( + "https://api.truckwash.io/release/runtime?release_channel=stable" ); }); @@ -38,9 +46,10 @@ describe("release bootstrap", () => { json: async () => ({ data: { channel: { slug: "internal" } } }), })); - await fetchReleaseRuntime({ fetchFn, apiBaseUrl: "https://api-v2.truckwash.io" }); + await fetchReleaseRuntime({ fetchFn, apiBaseUrl: "https://api.truckwash.io" }); - const [, options] = fetchFn.mock.calls[0]; + const [url, options] = fetchFn.mock.calls[0]; + expect(url).toBe("https://api-v2.truckwash.io/internal/api/release/runtime?release_channel=internal"); expect(options.headers).toMatchObject({ "X-Release-Trace": "trace-runtime", "X-Release-Channel": "internal",