Stirling-Tools/Stirling-PDF · error · SaasNotLinkedError
No SaaS session — admin must link an account before attended
Error message
No SaaS session — admin must link an account before attended SaaS reads.
What it means
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.
Source
Thrown at frontend/editor/src/portal/api/http.ts:249
// ────────────────────────────────────────────────────────────────────────────
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);
}
/** Fetch a plain-text SaaS response (e.g. a downloadable licence file). Throws on a non-2xx. */
async function saasText(View on GitHub (pinned to 9ef20dcab8)
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.
Example fix
// before — unguarded saas read throws when no session
const data = await apiClient.saas.json('/api/v1/payg/wallet');
// after — branch on the named error and prompt linking
import { SaasNotLinkedError } from "@portal/api/http";
try {
const data = await apiClient.saas.json('/api/v1/payg/wallet');
} catch (e) {
if (e instanceof SaasNotLinkedError) { navigate('/portal/link'); return; }
throw e;
} Defensive patterns
Strategy: validation
Validate before calling
import { getPortalSaasToken } from "@portal/auth/portalSaasSession";
async function isSaasLinked(): Promise<boolean> { return !!(await getPortalSaasToken()); } Type guard
import { SaasNotLinkedError } from "@portal/api/http";
function isSaasNotLinked(e: unknown): e is SaasNotLinkedError {
return e instanceof SaasNotLinkedError;
} Try / catch
import { SaasNotLinkedError } from "@portal/api/http";
try { return await apiClient.saas.json(path); }
catch (e) { if (e instanceof SaasNotLinkedError) { navigate('/portal/link'); return; } throw e; } Prevention
- 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).
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- SaaS API not configured — set VITE_SAAS_API_URL to enable po
- SaaS request failed (${res.status})
- No SaaS session
- Timed out waiting for anonymous session token
- errorData.error || Failed to synchronize user upgrade
AI-assisted analysis of Stirling-Tools/Stirling-PDF@9ef20dcab8 (2026-08-13).
Data as JSON: /api/errors/0df6850902eb7caa.
Report an issue: GitHub.