diff --git a/AGENTS.md b/AGENTS.md index 8dbb78824..ae8eb6031 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,6 +193,8 @@ What goes where: Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`). +**Cloud feature flags on desktop.** The local `AppConfigContext` reads `/api/v1/config/app-config` from the LOCAL bundled backend, so cloud-only flags (`aiEngineEnabled`, `premiumEnabled`, …) are never seen on desktop. To read the cloud's view, use `useSaasAppConfig()` (`desktop/hooks/useSaasAppConfig.ts`, backed by the general `saasAppConfigService` — SaaS-mode-only, public endpoint, native HTTP, 5-min cache). It returns `null` outside SaaS mode, so cloud features stay off in local/self-hosted and the server keeps the on/off switch (no desktop release needed to flip a flag). Gate a feature behind a per-platform seam — e.g. `useAiEngineEnabled()` (core reads `useAppConfig()`, desktop reads `useSaasAppConfig()`) — rather than hardcoding the flag on. + #### Component Override Pattern (Stub/Shadow) Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals. diff --git a/frontend/editor/src/core/hooks/useAiEngineEnabled.ts b/frontend/editor/src/core/hooks/useAiEngineEnabled.ts new file mode 100644 index 000000000..058c13985 --- /dev/null +++ b/frontend/editor/src/core/hooks/useAiEngineEnabled.ts @@ -0,0 +1,13 @@ +import { useAppConfig } from "@app/contexts/AppConfigContext"; + +/** + * Whether the AI engine is enabled, per the backend's app-config. + * + * Web builds read it straight from the app-config (which already comes from the + * backend the app talks to). Desktop shadows this (desktop/hooks/useAiEngineEnabled) + * to read it from the SaaS backend in SaaS mode instead of the local bundled one. + */ +export function useAiEngineEnabled(): boolean { + const { config } = useAppConfig(); + return Boolean(config?.aiEngineEnabled); +} diff --git a/frontend/editor/src/core/services/aiBaseUrl.ts b/frontend/editor/src/core/services/aiBaseUrl.ts new file mode 100644 index 000000000..e990b29cf --- /dev/null +++ b/frontend/editor/src/core/services/aiBaseUrl.ts @@ -0,0 +1,12 @@ +import { getApiBaseUrl } from "@app/services/apiClientConfig"; + +/** + * Base URL for AI-engine calls (orchestrate stream, AI result-file download). + * + * Web builds talk to whichever backend the app is served from, so the normal API + * base is correct. Desktop shadows this (desktop/services/aiBaseUrl) to point at + * the SaaS backend, since the AI engine only runs in the cloud. + */ +export function getAiBaseUrl(): string { + return getApiBaseUrl(); +} diff --git a/frontend/editor/src/desktop/hooks/useAiEngineEnabled.ts b/frontend/editor/src/desktop/hooks/useAiEngineEnabled.ts new file mode 100644 index 000000000..70c14fb79 --- /dev/null +++ b/frontend/editor/src/desktop/hooks/useAiEngineEnabled.ts @@ -0,0 +1,13 @@ +import { useSaasAppConfig } from "@app/hooks/useSaasAppConfig"; + +/** + * Desktop: the AI engine runs on the SaaS backend, so its enabled flag must come + * from the SaaS app-config (not the local bundled backend, which never has the + * engine). useSaasAppConfig() returns null outside SaaS mode, so AI is implicitly + * hidden in local/self-hosted — and the cloud retains the on/off switch (flip + * aiEngineEnabled server-side and the desktop FAB disappears on next load, no + * release required). + */ +export function useAiEngineEnabled(): boolean { + return Boolean(useSaasAppConfig()?.aiEngineEnabled); +} diff --git a/frontend/editor/src/desktop/hooks/useSaasAppConfig.ts b/frontend/editor/src/desktop/hooks/useSaasAppConfig.ts new file mode 100644 index 000000000..fee8278dc --- /dev/null +++ b/frontend/editor/src/desktop/hooks/useSaasAppConfig.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; +import { saasAppConfigService } from "@app/services/saasAppConfigService"; +import { connectionModeService } from "@app/services/connectionModeService"; +import type { AppConfig } from "@app/types/appConfig"; + +/** + * The SaaS backend's app-config while in desktop SaaS mode, or null otherwise. + * + * General-purpose: read any cloud feature flag from it (e.g. + * `useSaasAppConfig()?.aiEngineEnabled`, `?.premiumEnabled`). Reloads when the + * connection mode changes, so switching into/out of SaaS updates the flags + * (and a server-side flag flip is picked up on the next load). + */ +export function useSaasAppConfig(): AppConfig | null { + const [config, setConfig] = useState(null); + + useEffect(() => { + let alive = true; + const load = () => { + void saasAppConfigService.getConfig().then((c) => { + if (alive) setConfig(c); + }); + }; + load(); + const unsubscribe = connectionModeService.subscribeToModeChanges(() => { + saasAppConfigService.clearCache(); + load(); + }); + return () => { + alive = false; + unsubscribe?.(); + }; + }, []); + + return config; +} diff --git a/frontend/editor/src/desktop/services/aiBaseUrl.ts b/frontend/editor/src/desktop/services/aiBaseUrl.ts new file mode 100644 index 000000000..ba629a974 --- /dev/null +++ b/frontend/editor/src/desktop/services/aiBaseUrl.ts @@ -0,0 +1,14 @@ +import { STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection"; + +/** + * Desktop: AI-engine calls must hit the SaaS backend (the local bundled backend + * has no AI engine). The AI surface is only shown in SaaS mode (gated by + * useAiEngineEnabled), so returning the SaaS base unconditionally is correct — + * when an AI call is made, the app is in SaaS mode. + * + * Used for the orchestrate stream (a raw fetch) and the AI result-file download, + * which would otherwise resolve to the empty/local base and miss the engine. + */ +export function getAiBaseUrl(): string { + return (STIRLING_SAAS_BACKEND_API_URL ?? "").replace(/\/$/, ""); +} diff --git a/frontend/editor/src/desktop/services/apiClientSetup.test.ts b/frontend/editor/src/desktop/services/apiClientSetup.test.ts index fd46abd72..2cf811556 100644 --- a/frontend/editor/src/desktop/services/apiClientSetup.test.ts +++ b/frontend/editor/src/desktop/services/apiClientSetup.test.ts @@ -6,6 +6,11 @@ import { expectConsole } from "@app/tests/failOnConsole"; // Exercise the error-interceptor logic against a hand-rolled axios-like // client; transitive imports are mocked out so this is a pure unit test. +const { getAccessTokenMock } = vi.hoisted(() => ({ + getAccessTokenMock: vi.fn(), +})); + +vi.mock("@app/auth/session", () => ({ getAccessToken: getAccessTokenMock })); vi.mock("@app/components/toast", () => ({ alert: vi.fn() })); vi.mock("@core/services/apiClientSetup", () => ({ setupApiInterceptors: vi.fn(), @@ -50,7 +55,11 @@ vi.mock("@app/i18n", () => ({ default: { t: (_key: string, fallback: string) => fallback || _key }, })); -import { setupApiInterceptors } from "@app/services/apiClientSetup"; +import { + setupApiInterceptors, + getAuthHeaders, +} from "@app/services/apiClientSetup"; +import { operationRouter } from "@app/services/operationRouter"; type Interceptor = (value: unknown) => unknown; @@ -177,3 +186,66 @@ describe("desktop apiClientSetup - 401 silent-path", () => { expect(events).toHaveLength(0); }); }); + +describe("desktop request interceptor - auth for SaaS-backend requests", () => { + type ReqConfig = { + url: string; + method: string; + headers: Record; + }; + + async function runRequestInterceptor(config: ReqConfig): Promise { + const { client, handlers } = makeMockClient(); + setupApiInterceptors(client as unknown as AxiosInstance); + const handler = handlers.request[0]; + expect(handler).toBeTypeOf("function"); + return (await handler(config)) as ReqConfig; + } + + beforeEach(() => { + getAccessTokenMock.mockReset(); + // SaaS mode (not self-hosted), local-first routing for tool paths. + vi.mocked(operationRouter.isSelfHostedMode).mockResolvedValue(false); + vi.mocked(operationRouter.isSaaSMode).mockResolvedValue(true); + vi.mocked(operationRouter.getBaseUrl).mockResolvedValue( + "http://localhost:8080", + ); + vi.mocked(operationRouter.shouldSkipBackendReadyCheck).mockResolvedValue( + true, + ); + }); + + test("attaches the Bearer token to an absolute SaaS-backend URL (AI file download)", async () => { + getAccessTokenMock.mockResolvedValue("jwt-123"); + const result = await runRequestInterceptor({ + url: "https://api.saas.test/api/v1/general/files/abc", + method: "get", + headers: {}, + }); + expect(result.headers.Authorization).toBe("Bearer jwt-123"); + }); + + test("does NOT attach a token to a relative, local-routed tool request", async () => { + getAccessTokenMock.mockResolvedValue("jwt-123"); + const result = await runRequestInterceptor({ + url: "/api/v1/general/merge-pdfs", + method: "get", + headers: {}, + }); + expect(result.headers.Authorization).toBeUndefined(); + }); +}); + +describe("desktop getAuthHeaders (raw fetch / AI SSE stream)", () => { + beforeEach(() => getAccessTokenMock.mockReset()); + + test("attaches the Tauri-store JWT as a Bearer header", async () => { + getAccessTokenMock.mockResolvedValue("jwt-123"); + expect(await getAuthHeaders()).toEqual({ Authorization: "Bearer jwt-123" }); + }); + + test("returns no header when there is no token", async () => { + getAccessTokenMock.mockResolvedValue(null); + expect(await getAuthHeaders()).toEqual({}); + }); +}); diff --git a/frontend/editor/src/desktop/services/apiClientSetup.ts b/frontend/editor/src/desktop/services/apiClientSetup.ts index f94b226a6..c4a167013 100644 --- a/frontend/editor/src/desktop/services/apiClientSetup.ts +++ b/frontend/editor/src/desktop/services/apiClientSetup.ts @@ -1,11 +1,6 @@ import type { AxiosInstance, InternalAxiosRequestConfig } from "axios"; import { alert } from "@app/components/toast"; -import { - setupApiInterceptors as coreSetup, - getAuthHeaders, -} from "@core/services/apiClientSetup"; - -export { getAuthHeaders }; +import { setupApiInterceptors as coreSetup } from "@core/services/apiClientSetup"; import { tauriBackendService } from "@app/services/tauriBackendService"; import { createBackendNotReadyError } from "@app/constants/backendErrors"; import { operationRouter } from "@app/services/operationRouter"; @@ -19,6 +14,21 @@ import { import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents"; import i18n from "@app/i18n"; +/** + * Auth headers for raw fetch() calls (the AI SSE stream) — desktop variant. + * + * Core's getAuthHeaders returns {} and proprietary's reads the JWT from + * localStorage, but desktop keeps its JWT in the Tauri secure store. So this + * pulls the token via getAccessToken() (mirroring the axios request interceptor, + * including waiting out an in-flight refresh) — without it the orchestrate + * stream hits the SaaS backend with no Authorization header and 401s. + */ +export async function getAuthHeaders(): Promise> { + await authService.awaitRefreshIfInProgress(); + const token = await getAccessToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + const BACKEND_TOAST_COOLDOWN_MS = 4000; let lastBackendToast = 0; @@ -71,7 +81,17 @@ export function setupApiInterceptors(client: AxiosInstance): void { // - SaaS backend: Needs auth token // - Self-hosted backend: Needs auth token const isRemote = await operationRouter.isSelfHostedMode(); - const isSaaSBackendRequest = baseUrl === STIRLING_SAAS_BACKEND_API_URL; + // A request targets the SaaS backend either because operationRouter + // routed a relative path there, OR because the caller passed an absolute + // SaaS URL. The latter matters for the AI result-file download: it hits + // ${getAiBaseUrl()}/api/v1/general/files/..., and /api/v1/general/* + // normally routes local-first — so without this it would reach the cloud + // engine with no token and 401. Check the FINAL url (post-prefixing). + const saasBase = STIRLING_SAAS_BACKEND_API_URL ?? ""; + const finalUrl = extendedConfig.url ?? ""; + const isSaaSBackendRequest = + baseUrl === STIRLING_SAAS_BACKEND_API_URL || + (saasBase !== "" && finalUrl.startsWith(saasBase)); const needsAuth = isRemote || isSaaSBackendRequest; // Tag request so error handler can identify SaaS backend errors without URL matching diff --git a/frontend/editor/src/desktop/services/operationRouter.ts b/frontend/editor/src/desktop/services/operationRouter.ts index f588f12f2..0cf557eec 100644 --- a/frontend/editor/src/desktop/services/operationRouter.ts +++ b/frontend/editor/src/desktop/services/operationRouter.ts @@ -60,6 +60,7 @@ export class OperationRouter { /^\/api\/v1\/auth\//, // Supabase auth (SaaS mode) /^\/api\/v1\/payg\//, // PAYG wallet / spend-cap / billing /^\/api\/v1\/policies(?:\/|$)/, // Policy runs — must bill via the cloud + /^\/api\/v1\/ai\//, // AI engine (orchestrate, etc.) — runs in the cloud // Add more cloud-only feature prefixes here as they land. ]; diff --git a/frontend/editor/src/desktop/services/saasAppConfigService.test.ts b/frontend/editor/src/desktop/services/saasAppConfigService.test.ts new file mode 100644 index 000000000..2eaf8edf8 --- /dev/null +++ b/frontend/editor/src/desktop/services/saasAppConfigService.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; +import { expectConsole } from "@app/tests/failOnConsole"; + +const { fetchMock, getModeMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getModeMock: vi.fn(), +})); +vi.mock("@tauri-apps/plugin-http", () => ({ fetch: fetchMock })); +vi.mock("@app/constants/connection", () => ({ + STIRLING_SAAS_BACKEND_API_URL: "https://api.saas.test", +})); +vi.mock("@app/services/connectionModeService", () => ({ + connectionModeService: { getCurrentMode: getModeMock }, +})); + +import { saasAppConfigService } from "@app/services/saasAppConfigService"; + +function okConfig(body: Record) { + return { ok: true, status: 200, json: async () => body }; +} + +describe("saasAppConfigService", () => { + beforeEach(() => { + fetchMock.mockReset(); + getModeMock.mockReset(); + saasAppConfigService.clearCache(); + }); + + test("returns null outside SaaS mode without fetching", async () => { + getModeMock.mockResolvedValue("local"); + expect(await saasAppConfigService.getConfig()).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("fetches the SaaS app-config in SaaS mode and caches it", async () => { + getModeMock.mockResolvedValue("saas"); + fetchMock.mockResolvedValue( + okConfig({ aiEngineEnabled: true, premiumEnabled: false }), + ); + + const first = await saasAppConfigService.getConfig(); + expect(first?.aiEngineEnabled).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe( + "https://api.saas.test/api/v1/config/app-config", + ); + + // Second read is served from cache (no extra fetch). + const second = await saasAppConfigService.getConfig(); + expect(second?.aiEngineEnabled).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("returns null on a non-ok response", async () => { + getModeMock.mockResolvedValue("saas"); + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({}), + }); + expectConsole.warn(/SaaS app-config fetch failed: 500/); + expect(await saasAppConfigService.getConfig()).toBeNull(); + }); +}); diff --git a/frontend/editor/src/desktop/services/saasAppConfigService.ts b/frontend/editor/src/desktop/services/saasAppConfigService.ts new file mode 100644 index 000000000..349a3de24 --- /dev/null +++ b/frontend/editor/src/desktop/services/saasAppConfigService.ts @@ -0,0 +1,82 @@ +import { fetch } from "@tauri-apps/plugin-http"; +import { STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection"; +import { connectionModeService } from "@app/services/connectionModeService"; +import type { AppConfig } from "@app/types/appConfig"; + +/** + * General fetch+cache of the SaaS backend's app-config for the desktop app. + * + * The local AppConfigContext reads /api/v1/config/app-config from the LOCAL + * bundled backend (that's what apiClient routes to on desktop), so cloud-only + * feature flags (aiEngineEnabled, premiumEnabled, paygEnabled, ...) are never + * seen. This service fetches the SAME endpoint from the SaaS backend so desktop + * SaaS mode can read the cloud's view of those flags. It is intentionally + * generic — not AI-specific — so other desktop SaaS features can consume it. + * + * - Only returns config in SaaS connection mode; null in local/self-hosted (so + * callers naturally treat cloud features as off outside SaaS, and a server-side + * flag flip — e.g. turning AI off — propagates without a desktop release). + * - The endpoint is public on the SaaS backend (/api/v1/config/** is permitAll), + * so no auth is required. + * - Native HTTP (@tauri-apps/plugin-http) — no CORS, mirrors endpointAvailabilityService. + */ +const CACHE_MS = 5 * 60 * 1000; + +class SaasAppConfigService { + private cache: AppConfig | null = null; + private cacheExpiry = 0; + private inFlight: Promise | null = null; + + /** The SaaS backend's app-config, or null outside SaaS mode / on failure. */ + async getConfig(force = false): Promise { + const mode = await connectionModeService.getCurrentMode(); + if (mode !== "saas" || !STIRLING_SAAS_BACKEND_API_URL) { + this.clearCache(); + return null; + } + if (!force && this.cache && Date.now() < this.cacheExpiry) { + return this.cache; + } + if (this.inFlight) return this.inFlight; + this.inFlight = this.fetchConfig(); + try { + return await this.inFlight; + } finally { + this.inFlight = null; + } + } + + private async fetchConfig(): Promise { + try { + const base = STIRLING_SAAS_BACKEND_API_URL.replace(/\/$/, ""); + const response = await fetch(`${base}/api/v1/config/app-config`, { + method: "GET", + headers: { "Cache-Control": "no-store" }, + }); + if (!response.ok) { + console.warn( + `[saasAppConfigService] SaaS app-config fetch failed: ${response.status}`, + ); + return null; + } + const data = (await response.json()) as AppConfig; + this.cache = data; + this.cacheExpiry = Date.now() + CACHE_MS; + return data; + } catch (error) { + console.error( + "[saasAppConfigService] SaaS app-config fetch error:", + error, + ); + return null; + } + } + + /** Drop the cache (e.g. on connection-mode change) so the next read re-fetches. */ + clearCache(): void { + this.cache = null; + this.cacheExpiry = 0; + } +} + +export const saasAppConfigService = new SaasAppConfigService(); diff --git a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx index 05577885b..590000087 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx @@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next"; import { generateId } from "@app/utils/generateId"; import { useAllFiles, useFileActions } from "@app/contexts/FileContext"; import apiClient from "@app/services/apiClient"; -import { getApiBaseUrl } from "@app/services/apiClientConfig"; +import { getAiBaseUrl } from "@app/services/aiBaseUrl"; import { getAuthHeaders } from "@app/services/apiClientSetup"; import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge"; import { createChildStub } from "@app/contexts/file/fileActions"; @@ -407,8 +407,10 @@ export function ChatProvider({ children }: { children: ReactNode }) { // Download a File from the Stirling files endpoint. const downloadFile = useCallback( async (descriptor: AiWorkflowResultFile): Promise => { + // AI result files live on the backend that ran the workflow (the SaaS + // engine on desktop), so fetch from the AI base, not the local backend. const response = await apiClient.get( - `/api/v1/general/files/${descriptor.fileId}`, + `${getAiBaseUrl()}/api/v1/general/files/${descriptor.fileId}`, { responseType: "blob" }, ); return new File([response.data], descriptor.fileName, { @@ -520,7 +522,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { formData.append(`conversationHistory[${i}].content`, message.content); }); const response = await fetch( - `${getApiBaseUrl()}/api/v1/ai/orchestrate/stream`, + `${getAiBaseUrl()}/api/v1/ai/orchestrate/stream`, { method: "POST", body: formData, diff --git a/frontend/editor/src/proprietary/components/chat/ChatFAB.tsx b/frontend/editor/src/proprietary/components/chat/ChatFAB.tsx index 382094cb6..8c0a426a8 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatFAB.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatFAB.tsx @@ -6,7 +6,7 @@ import { ChatFABButton } from "@shared/components/ChatFABButton"; import { ChatFABWindow } from "@shared/components/ChatFABWindow"; import { ChatPanel } from "@app/components/chat/ChatPanel"; import { useChat } from "@app/components/chat/ChatContext"; -import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled"; import { Z_INDEX_CHAT_FAB_OVERLAY } from "@app/styles/zIndex"; import "@app/components/chat/ChatFAB.css"; @@ -53,8 +53,9 @@ export function ChatFAB() { const [isOpen, setIsOpen] = useState(false); const [hasUnviewedResult, setHasUnviewedResult] = useState(false); const { isLoading } = useChat(); - const { config } = useAppConfig(); - const enabled = Boolean(config?.aiEngineEnabled); + // Desktop sources this from the SaaS backend (cloud kill switch); web reads it + // from the local app-config. Either way the AI engine drives FAB visibility. + const enabled = useAiEngineEnabled(); // Detect loading → done transition. If the FAB is closed when the agent // finishes, show the tick badge until the user opens the panel.