{"record":{"id":"49a184d93df6b4d1","repo":"Stirling-Tools/Stirling-PDF","slug":"saas-api-not-configured-set-vite-saas-api-url-to","errorCode":null,"errorMessage":"SaaS API not configured — set VITE_SAAS_API_URL to enable portal→SaaS reads.","messagePattern":"SaaS API not configured — set VITE_SAAS_API_URL to enable portal→SaaS reads\\.","errorType":"exception","errorClass":"SaasUnconfiguredError","httpStatus":null,"severity":"error","filePath":"frontend/editor/src/portal/api/http.ts","lineNumber":247,"sourceCode":"// ────────────────────────────────────────────────────────────────────────────\n// saas — hosted SaaS Java, admin's Supabase JWT\n// ────────────────────────────────────────────────────────────────────────────\n\nasync function saasJson<T>(\n  path: string,\n  options: HttpRequestOptions = {},\n): Promise<T> {\n  // Resolved before the config/session gates so demo data works on an\n  // unlinked or unconfigured org. http://saas.mock is the origin the SaaS\n  // handlers are written against (same one Storybook injects).\n  const demo = await resolveDemoResponse(\n    new URL(path, \"http://saas.mock\"),\n    options,\n  );\n  if (demo) return unwrap<T>(demo);\n  const base = saasBaseUrl();\n  // null = unset (self-hosted, no VITE_SAAS_API_URL). \"\" is same-origin (SaaS) — valid.\n  if (base === null) throw new SaasUnconfiguredError();\n  const token = await getPortalSaasToken();\n  if (!token) throw new SaasNotLinkedError();\n  const res = await fetch(`${base}${path}`, {\n    method: options.method ?? \"GET\",\n    headers: {\n      Accept: \"application/json\",\n      Authorization: `Bearer ${token}`,\n      ...(options.body !== undefined\n        ? { \"Content-Type\": \"application/json\" }\n        : {}),\n      ...options.headers,\n    },\n    body: options.body !== undefined ? JSON.stringify(options.body) : undefined,\n    signal: options.signal,\n  });\n  return unwrap<T>(res);\n}\n","sourceCodeStart":229,"sourceCodeEnd":265,"githubUrl":"https://github.com/Stirling-Tools/Stirling-PDF/blob/9ef20dcab80b85041912f045e17a6aea1d08f969/frontend/editor/src/portal/api/http.ts#L229-L265","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before — throws SaasUnconfiguredError in self-hosted builds\nconst wallet = await apiClient.saas.json('/api/v1/payg/wallet');\n\n// after — guard the call and surface a configuration state\nimport { SaasUnconfiguredError } from \"@portal/api/http\";\ntry {\n  const wallet = await apiClient.saas.json('/api/v1/payg/wallet');\n} catch (e) {\n  if (e instanceof SaasUnconfiguredError) { setView('saas-unconfigured'); return; }\n  throw e;\n}","handlingStrategy":"validation","validationCode":"import { saasApiBase } from \"@portal/api/saasApiBase\";\nfunction isSaasConfigured(): boolean { return saasApiBase() !== null; }\n// before calling .saas.* if (!isSaasConfigured()) showSaasConfigState();","typeGuard":"import { SaasUnconfiguredError } from \"@portal/api/http\";\nfunction isSaasUnconfigured(e: unknown): e is SaasUnconfiguredError {\n  return e instanceof SaasUnconfiguredError;\n}","tryCatchPattern":"import { SaasUnconfiguredError } from \"@portal/api/http\";\ntry { return await apiClient.saas.json(path); }\ncatch (e) { if (e instanceof SaasUnconfiguredError) { setView('saas-unconfigured'); return; } throw e; }","preventionTips":["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."],"tags":["portal","http","saas","config","env-var"],"backgroundTag":null,"analyzedSha":"9ef20dcab80b85041912f045e17a6aea1d08f969","analyzedAt":"2026-08-13T22:11:39.827Z","schemaVersion":2},"datasetVersion":"2026-08-14T00:17:13.853Z"}