Stirling-Tools/Stirling-PDF · error · SaasUnconfiguredError
SaaS API not configured — set VITE_SAAS_API_URL to enable po
Error message
SaaS API not configured — set VITE_SAAS_API_URL to enable portal→SaaS reads.
What it means
SaasUnconfiguredError thrown by saasJson() when saasBaseUrl() returns null. Per the module header, null specifically means 'VITE_SAAS_API_URL is unset' — a self-hosted-only state. (An empty string is valid: it means same-origin SaaS.) The demo-response layer is consulted first, so this only throws when no mock intercepts the request AND the base URL is unset.
Source
Thrown at frontend/editor/src/portal/api/http.ts:247
// ────────────────────────────────────────────────────────────────────────────
// saas — hosted SaaS Java, admin's Supabase JWT
// ────────────────────────────────────────────────────────────────────────────
async function saasJson<T>(
path: string,
options: HttpRequestOptions = {},
): Promise<T> {
// Resolved before the config/session gates so demo data works on an
// unlinked or unconfigured org. http://saas.mock is the origin the SaaS
// handlers are written against (same one Storybook injects).
const demo = await resolveDemoResponse(
new URL(path, "http://saas.mock"),
options,
);
if (demo) return unwrap<T>(demo);
const base = saasBaseUrl();
// null = unset (self-hosted, no VITE_SAAS_API_URL). "" is same-origin (SaaS) — valid.
if (base === null) throw new SaasUnconfiguredError();
const token = await getPortalSaasToken();
if (!token) throw new SaasNotLinkedError();
const res = await fetch(`${base}${path}`, {
method: options.method ?? "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
...(options.body !== undefined
? { "Content-Type": "application/json" }
: {}),
...options.headers,
},
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
signal: options.signal,
});
return unwrap<T>(res);
}
View on GitHub (pinned to 9ef20dcab8)
Solutions
- Set VITE_SAAS_API_URL in the correct committed env file (frontend/editor/.env.saas layered on .env) for SaaS builds.
- Gate the calling view on whether SaaS is configured (catch SaasUnconfiguredError and show a 'configure SaaS' state instead of calling blindly).
- If the build is intentionally self-hosted-only, ensure the code path that calls apiClient.saas.* is not reached (route guard / feature flag).
- Verify the flavor seam (saasApiBase) returns '' (same-origin) rather than null for the SaaS build.
Example fix
// before — throws SaasUnconfiguredError in self-hosted builds
const wallet = await apiClient.saas.json('/api/v1/payg/wallet');
// after — guard the call and surface a configuration state
import { SaasUnconfiguredError } from "@portal/api/http";
try {
const wallet = await apiClient.saas.json('/api/v1/payg/wallet');
} catch (e) {
if (e instanceof SaasUnconfiguredError) { setView('saas-unconfigured'); return; }
throw e;
} Defensive patterns
Strategy: validation
Validate before calling
import { saasApiBase } from "@portal/api/saasApiBase";
function isSaasConfigured(): boolean { return saasApiBase() !== null; }
// before calling .saas.* if (!isSaasConfigured()) showSaasConfigState(); Type guard
import { SaasUnconfiguredError } from "@portal/api/http";
function isSaasUnconfigured(e: unknown): e is SaasUnconfiguredError {
return e instanceof SaasUnconfiguredError;
} Try / catch
import { SaasUnconfiguredError } from "@portal/api/http";
try { return await apiClient.saas.json(path); }
catch (e) { if (e instanceof SaasUnconfiguredError) { setView('saas-unconfigured'); return; } throw e; } Prevention
- Set VITE_SAAS_API_URL in the committed .env.saas for SaaS builds.
- Gate SaaS-reading views on saasApiBase() !== null.
- Distinguish null (unset) from '' (same-origin) — both are valid SaaS states except null means unconfigured.
When it happens
Trigger: A portal view calls apiClient.saas.json() in a self-hosted build where VITE_SAAS_API_URL was never set, and no MSW/demo handler covers the path. saasApiBase() (the flavor seam) returns null.
Common situations: Self-hosted deployment that legitimately has no SaaS backend, but a portal view unconditionally calls a .saas.* method; the env var was misspelled or stripped during build; a developer ran the SaaS-reading code path in the core/self-hosted flavor by mistake.
Related errors
- No SaaS session — admin must link an account before attended
- SaaS request failed (${res.status})
- ${status} ${statusText}
- No SaaS session
- unconfigured
AI-assisted analysis of Stirling-Tools/Stirling-PDF@9ef20dcab8 (2026-08-13).
Data as JSON: /api/errors/49a184d93df6b4d1.
Report an issue: GitHub.