Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .env.public.example
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,19 @@ CORS_ORIGINS=https://<your-web-domain>
METRICS_TOKEN=<64 hex chars>

# ---- Web --------------------------------------------------------------------
# Every NEXT_PUBLIC_* value below is baked into the client bundle AT BUILD
# TIME - changing one and redeploying without rebuilding does nothing; the
# old value is still what ships in the bundle a visitor's browser downloads.
#
# MUST match STELLAR_NETWORK above. The browser signs with the passphrase this
# selects; a mismatch means every wallet signature is rejected by the network
# the API is watching, with no error that names the cause.
NEXT_PUBLIC_STELLAR_NETWORK=public
# Leaving this unset on a production build has actually broken production
# before (docs/FIXLOG.md, BUG-1.4): the code's local-dev localhost fallback
# got baked into the deployed bundle, so every visitor's browser silently
# tried to reach localhost on their OWN machine. A production build with
# this unset now fails loudly in the browser instead - set it and rebuild.
NEXT_PUBLIC_API_URL=https://<api.your-domain>
API_URL=https://<api.your-domain>
# Must mirror OFFRAMP above. "none" hides the cash-out button, the KYC panel
Expand Down
39 changes: 38 additions & 1 deletion apps/web/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,44 @@ export interface KycView {
}

// Browser calls go to NEXT_PUBLIC_API_URL; server-side calls fall back to API_URL.
const BROWSER_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8787";
//
// This has actually broken production once already (docs/FIXLOG.md, BUG-1.4,
// 2026-07-14): a Vercel build ran with NEXT_PUBLIC_API_URL unset, so this
// fallback got baked into the client bundle, and every visitor's browser
// silently tried (and failed) to reach `localhost:8787` on their own
// machine - no error naming the real cause, just "Create link" doing
// nothing. The fix that shipped afterward was procedural (a deploy-checklist
// reminder), not code - nothing here actually stopped it from recurring.
// This does: the fallback only applies outside production, and a production
// build (NODE_ENV=production) missing the variable fails loudly, in the
// browser, at load time - before any component gets a chance to issue a
// doomed request. See docs/MAINNET.md's "NEXT_PUBLIC_*" footgun section.
const DEV_FALLBACK = "http://localhost:8787";

const BROWSER_BASE = ((): string => {
if (process.env.NEXT_PUBLIC_API_URL) return process.env.NEXT_PUBLIC_API_URL;
if (process.env.NODE_ENV !== "production") return DEV_FALLBACK;

// `typeof window` is a reliable environment check here (not a runtime
// toggle): Next.js produces genuinely separate server and browser
// bundles, and each evaluates this module's top level for the first time
// in its own environment - a browser bundle really does run this inside
// an actual browser. The server bundle doesn't need NEXT_PUBLIC_API_URL at
// all if API_URL is set (see apiBase() below), so it isn't punished for a
// client-only variable it never uses.
if (typeof window !== "undefined") {
throw new Error(
"NEXT_PUBLIC_API_URL is not set. This is a production build, so there is no " +
"localhost fallback - without it, every request from this browser would " +
"silently target the visitor's own machine (this exact failure has happened " +
"before - see docs/FIXLOG.md, BUG-1.4). Set NEXT_PUBLIC_API_URL and REBUILD: " +
"NEXT_PUBLIC_* values are baked in at build time, so redeploying alone will " +
"not pick up a newly-set value.",
);
}

return DEV_FALLBACK;
})();

export function apiBase(): string {
if (typeof window === "undefined") {
Expand Down
81 changes: 81 additions & 0 deletions apps/web/test/api-base.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect, afterEach, vi } from "vitest";

/**
* Regression for issue 5.9 / BUG-1.4 (2026-07-14).
*
* A Vercel build ran with NEXT_PUBLIC_API_URL unset, so the `http://localhost:8787`
* local-dev fallback was baked into the client bundle and every visitor's browser
* silently tried to reach localhost on their own machine. The fix that shipped at
* the time was a deploy-checklist reminder, not code — so nothing stopped it from
* recurring. These pin the four branches of `BROWSER_BASE`.
*
* `BROWSER_BASE` is resolved at module load, so each case re-imports the module
* under a fresh environment rather than calling a function.
*/
const DEV_FALLBACK = "http://localhost:8787";

async function loadApiBase(): Promise<string> {
vi.resetModules();
const mod = await import("../lib/api");
return mod.apiBase();
}

function withBrowser(present: boolean): void {
if (present) {
(globalThis as { window?: unknown }).window = globalThis;
} else {
delete (globalThis as { window?: unknown }).window;
}
}

describe("BROWSER_BASE — a production build must not ship the localhost fallback", () => {
afterEach(() => {
vi.unstubAllEnvs();
withBrowser(false);
vi.resetModules();
});

it("uses NEXT_PUBLIC_API_URL when it is set, in production", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("NEXT_PUBLIC_API_URL", "https://api.example.com");
withBrowser(true);

await expect(loadApiBase()).resolves.toBe("https://api.example.com");
});

it("keeps the localhost fallback outside production — local dev is unaffected", async () => {
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("NEXT_PUBLIC_API_URL", "");
withBrowser(true);

await expect(loadApiBase()).resolves.toBe(DEV_FALLBACK);
});

it("throws at module load in a production browser bundle with the variable unset", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("NEXT_PUBLIC_API_URL", "");
withBrowser(true);

await expect(loadApiBase()).rejects.toThrow(/NEXT_PUBLIC_API_URL is not set/);
});

it("names the variable and says to rebuild, not just redeploy", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("NEXT_PUBLIC_API_URL", "");
withBrowser(true);

await expect(loadApiBase()).rejects.toThrow(/REBUILD/);
});

// The server bundle reaches the API via API_URL and never needs the
// NEXT_PUBLIC_ one, so it must not be punished for a client-only variable.
// Throwing here would take down prerendering during `next build`.
it("does not throw in the production server bundle — API_URL is that path's variable", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("NEXT_PUBLIC_API_URL", "");
vi.stubEnv("API_URL", "https://api.example.com");
withBrowser(false);

await expect(loadApiBase()).resolves.toBe("https://api.example.com");
});
});
17 changes: 16 additions & 1 deletion docs/MAINNET.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,22 @@ testnet one.
This one is easy to miss and fails opaquely: the browser signs with the
passphrase this variable selects, so leaving it unset means every wallet
signature is built for testnet and rejected by the network the API is watching —
with no error message that names the cause. Also set:
with no error message that names the cause. Like every `NEXT_PUBLIC_*`
variable, this is baked into the client bundle **at build time** — changing
it and redeploying without rebuilding does nothing; the old value is still
what's in the bundle a visitor's browser downloads.

`NEXT_PUBLIC_API_URL` has the exact same failure shape, and it has actually
happened: a Vercel build once ran with this unset, so the code's own
`http://localhost:8787` local-dev fallback got baked into the production
bundle instead, and every visitor's browser silently tried (and failed) to
reach `localhost:8787` **on their own machine** — "Create link" just did
nothing, no error naming the cause (`docs/FIXLOG.md`, BUG-1.4). The fallback
now only applies outside a production build; a production build with this
unset fails loudly in the browser instead, at load time, rather than issuing
doomed requests — but that guard only catches "unset," not "wrong region/
wrong deployment," so still set it deliberately rather than relying on the
guard to catch a typo'd URL. Also set:

- `NEXT_PUBLIC_API_URL` / `API_URL` — the mainnet API origin
- `NEXT_PUBLIC_ENABLE_WALLET_PAY=true` — enable the lazy-loaded desktop wallet
Expand Down