diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 333e029..89c6a4c 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,8 +1,24 @@
import type { Metadata, Viewport } from "next"
import { Rajdhani, Plus_Jakarta_Sans, Fira_Code } from "next/font/google"
import { TooltipProvider } from "@/components/ui/tooltip"
+import {
+ RUNTIME_CONFIG_GLOBAL,
+ readRuntimeConfig,
+ serializeRuntimeConfig,
+} from "@/lib/runtime-config"
import "./globals.css"
+/**
+ * Required for runtime configuration to mean anything.
+ *
+ * Next prerenders a layout with no dynamic inputs at build time, which would
+ * evaluate readRuntimeConfig() during `next build` and bake that value into the
+ * HTML — reintroducing exactly the build-time freezing this replaces. Rendering
+ * per request is what lets GRAPH_MINDSET_API_URL be read from the live
+ * environment on every boot.
+ */
+export const dynamic = "force-dynamic"
+
const rajdhani = Rajdhani({
variable: "--font-heading",
subsets: ["latin"],
@@ -51,6 +67,17 @@ export default function RootLayout({
className={`${rajdhani.variable} ${jakarta.variable} ${firaCode.variable} h-full antialiased dark`}
>
+ {/*
+ Inline and first in the document so it runs during parse, before the
+ deferred application bundles — getApiUrl() therefore always sees it.
+ */}
+
GraphMindset
diff --git a/src/lib/agent-api.ts b/src/lib/agent-api.ts
index dac5e6f..089bfb1 100644
--- a/src/lib/agent-api.ts
+++ b/src/lib/agent-api.ts
@@ -1,6 +1,6 @@
"use client"
-import { API_URL } from "./api"
+import { getApiUrl } from "./api"
import { getSignedMessage, getL402, payL402 } from "./sphinx"
import { useModalStore } from "@/stores/modal-store"
import { isMocksEnabled } from "./mock-data"
@@ -60,7 +60,7 @@ function unwrapEnvelope(raw: string): { answer: string; cited_ref_ids: string[]
// Builds a signed URL for a given API path
async function buildSignedUrl(path: string): Promise {
- const url = new URL(`${API_URL}${path}`)
+ const url = new URL(`${getApiUrl()}${path}`)
const signed = await getSignedMessage()
if (signed.signature) {
url.searchParams.append("sig", signed.signature)
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 66f18fe..3e0ce69 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -1,10 +1,27 @@
import { getSignedMessage, getL402 } from "./sphinx"
+import { injectedApiUrl } from "./runtime-config"
function resolveApiUrl(): string {
if (typeof window === "undefined") {
- return process.env.NEXT_PUBLIC_API_URL || "https://bitcoin.sphinx.chat/api"
+ // Server render: read the live environment directly. GRAPH_MINDSET_API_URL
+ // is not inlined at build time, so this reflects the running container.
+ return (
+ process.env.GRAPH_MINDSET_API_URL?.trim() ||
+ process.env.NEXT_PUBLIC_API_URL ||
+ "https://bitcoin.sphinx.chat/api"
+ )
}
+ // Runtime value injected into the document by the root layout. Takes
+ // precedence over NEXT_PUBLIC_API_URL, which is only ever what happened to be
+ // set when the image was built.
+ const injected = injectedApiUrl()
+
+ if (injected) {
+ return injected
+ }
+
+ // Still honoured for builds that bake the URL in via --build-arg.
if (process.env.NEXT_PUBLIC_API_URL) {
return process.env.NEXT_PUBLIC_API_URL
}
@@ -25,7 +42,21 @@ function resolveApiUrl(): string {
return `${origin}/api`
}
-export const API_URL = resolveApiUrl()
+let cachedApiUrl: string | undefined
+
+/**
+ * Resolved on first use rather than at module load, so the runtime value the
+ * root layout injects into the document is guaranteed to be in place by the
+ * time anything asks for it. Import this instead of a module-level constant.
+ */
+export function getApiUrl(): string {
+ return (cachedApiUrl ??= resolveApiUrl())
+}
+
+/** Test seam — forces the next getApiUrl() call to re-resolve. */
+export function resetApiUrlCache(): void {
+ cachedApiUrl = undefined
+}
async function request(
url: string,
@@ -74,7 +105,7 @@ export const api = {
endpoint: string,
headers?: RequestInit["headers"],
signal?: AbortSignal
- ) => request(`${API_URL}${endpoint}`, headers ? { headers } : undefined, signal),
+ ) => request(`${getApiUrl()}${endpoint}`, headers ? { headers } : undefined, signal),
post: (
endpoint: string,
@@ -83,7 +114,7 @@ export const api = {
signal?: AbortSignal
) =>
request(
- `${API_URL}${endpoint}`,
+ `${getApiUrl()}${endpoint}`,
{
body: JSON.stringify(body),
headers: { ...headers, "Content-Type": "application/json" },
@@ -99,7 +130,7 @@ export const api = {
signal?: AbortSignal
) =>
request(
- `${API_URL}${endpoint}`,
+ `${getApiUrl()}${endpoint}`,
{
body: JSON.stringify(body),
headers: { ...headers, "Content-Type": "application/json" },
@@ -114,7 +145,7 @@ export const api = {
signal?: AbortSignal
) =>
request(
- `${API_URL}${endpoint}`,
+ `${getApiUrl()}${endpoint}`,
{
headers: { ...headers, "Content-Type": "application/json" },
method: "DELETE",
diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts
index 8b84acc..9d2ecb4 100644
--- a/src/lib/graph-api.ts
+++ b/src/lib/graph-api.ts
@@ -1,4 +1,4 @@
-import { api, API_URL } from "./api"
+import { api, getApiUrl } from "./api"
import { getSignedMessage, getL402 } from "./sphinx"
import { isMocksEnabled, MOCK_REVIEWS } from "./mock-data"
@@ -216,7 +216,7 @@ export async function uploadImageToNode(
file: File,
signal?: AbortSignal
): Promise<{ url: string; ref_id: string; status?: string }> {
- const url = new URL(`${API_URL}/v2/nodes/${refId}/image`)
+ const url = new URL(`${getApiUrl()}/v2/nodes/${refId}/image`)
// Sphinx-signed admin path piggybacks on query params (matches api.ts).
const signed = await getSignedMessage()
@@ -284,7 +284,7 @@ export async function addImageContent(
status_messages: string[]
temp_url?: string
}> {
- const url = new URL(`${API_URL}/v2/content/image`)
+ const url = new URL(`${getApiUrl()}/v2/content/image`)
const signed = await getSignedMessage()
if (signed.signature) {
@@ -1219,7 +1219,7 @@ export async function addLegalDocumentFile(
file: File,
signal?: AbortSignal
): Promise<{ status: string; nodes: Array>; status_messages: string[] }> {
- const url = new URL(`${API_URL}/v2/legal/upload`)
+ const url = new URL(`${getApiUrl()}/v2/legal/upload`)
const signed = await getSignedMessage()
if (signed.signature) {
diff --git a/src/lib/runtime-config.ts b/src/lib/runtime-config.ts
new file mode 100644
index 0000000..62ade08
--- /dev/null
+++ b/src/lib/runtime-config.ts
@@ -0,0 +1,64 @@
+/**
+ * Runtime configuration.
+ *
+ * NEXT_PUBLIC_* variables are inlined by Next at build time, so they freeze
+ * whatever value was present when the image was built. That makes a published
+ * image unusable in any deployment with a different API host — which is why
+ * the container could be handed the correct URL at runtime and still call the
+ * compiled-in default.
+ *
+ * GRAPH_MINDSET_API_URL carries no NEXT_PUBLIC_ prefix, so it is never inlined:
+ * the server reads it from the real environment on each boot and hands it to
+ * the browser through a