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

  1. Set VITE_SAAS_API_URL in the correct committed env file (frontend/editor/.env.saas layered on .env) for SaaS builds.
  2. Gate the calling view on whether SaaS is configured (catch SaasUnconfiguredError and show a 'configure SaaS' state instead of calling blindly).
  3. If the build is intentionally self-hosted-only, ensure the code path that calls apiClient.saas.* is not reached (route guard / feature flag).
  4. 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

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


AI-assisted analysis of Stirling-Tools/Stirling-PDF@9ef20dcab8 (2026-08-13). Data as JSON: /api/errors/49a184d93df6b4d1. Report an issue: GitHub.