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

  1. Complete the account-link login flow (the portalSaasSession minting path) before invoking .saas.* reads.
  2. Catch SaasNotLinkedError and route the user to the link/login view.
  3. Ensure getPortalSaasToken()'s refresh path works — a silently-failed refresh looks identical to 'never linked'.
  4. 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

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


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