{"record":{"id":"0df6850902eb7caa","repo":"Stirling-Tools/Stirling-PDF","slug":"no-saas-session-admin-must-link-an-account-befor","errorCode":null,"errorMessage":"No SaaS session — admin must link an account before attended SaaS reads.","messagePattern":"No SaaS session — admin must link an account before attended SaaS reads\\.","errorType":"exception","errorClass":"SaasNotLinkedError","httpStatus":null,"severity":"error","filePath":"frontend/editor/src/portal/api/http.ts","lineNumber":249,"sourceCode":"// ────────────────────────────────────────────────────────────────────────────\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\n/** Fetch a plain-text SaaS response (e.g. a downloadable licence file). Throws on a non-2xx. */\nasync function saasText(","sourceCodeStart":231,"sourceCodeEnd":267,"githubUrl":"https://github.com/Stirling-Tools/Stirling-PDF/blob/9ef20dcab80b85041912f045e17a6aea1d08f969/frontend/editor/src/portal/api/http.ts#L231-L267","documentation":"SaasNotLinkedError thrown by saasJson() after the base URL is valid but getPortalSaasToken() returns a falsy token. This means the SaaS backend is configured but the admin has not completed the account-link login that mints/persists the Supabase JWT used for attended portal->SaaS reads. The module header distinguishes the device credential (server-side, unattended) from this human-admin JWT.","triggerScenarios":"VITE_SAAS_API_URL is set (or same-origin SaaS), no demo response intercepts, but portalSaasSession has no token — the admin never ran the account-link flow, or the session expired and was cleared.","commonSituations":"Fresh portal install where the admin opened a SaaS-reading view before linking; the persisted JWT expired and the refresh failed silently; the account was unlinked; running in a context where the Supabase client exists but no session is active.","solutions":["Complete the account-link login flow (the portalSaasSession minting path) before invoking .saas.* reads.","Catch SaasNotLinkedError and route the user to the link/login view.","Ensure getPortalSaasToken()'s refresh path works — a silently-failed refresh looks identical to 'never linked'.","Distinguish this from SaasUnconfiguredError: the fix is auth, not env config."],"exampleFix":"// before — unguarded saas read throws when no session\nconst data = await apiClient.saas.json('/api/v1/payg/wallet');\n\n// after — branch on the named error and prompt linking\nimport { SaasNotLinkedError } from \"@portal/api/http\";\ntry {\n  const data = await apiClient.saas.json('/api/v1/payg/wallet');\n} catch (e) {\n  if (e instanceof SaasNotLinkedError) { navigate('/portal/link'); return; }\n  throw e;\n}","handlingStrategy":"validation","validationCode":"import { getPortalSaasToken } from \"@portal/auth/portalSaasSession\";\nasync function isSaasLinked(): Promise<boolean> { return !!(await getPortalSaasToken()); }","typeGuard":"import { SaasNotLinkedError } from \"@portal/api/http\";\nfunction isSaasNotLinked(e: unknown): e is SaasNotLinkedError {\n  return e instanceof SaasNotLinkedError;\n}","tryCatchPattern":"import { SaasNotLinkedError } from \"@portal/api/http\";\ntry { return await apiClient.saas.json(path); }\ncatch (e) { if (e instanceof SaasNotLinkedError) { navigate('/portal/link'); return; } throw e; }","preventionTips":["Complete account-link before enabling SaaS-reading views.","Verify token refresh so expiry is not misread as 'never linked'.","Distinguish SaasNotLinkedError (auth) from SaasUnconfiguredError (config)."],"tags":["portal","http","saas","auth","session"],"backgroundTag":null,"analyzedSha":"9ef20dcab80b85041912f045e17a6aea1d08f969","analyzedAt":"2026-08-13T22:11:39.827Z","schemaVersion":2},"datasetVersion":"2026-08-14T00:17:13.853Z"}