diff --git a/package.json b/package.json index a884a08..771aa3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@base44/sdk", - "version": "0.8.32", + "version": "0.8.34", "description": "JavaScript SDK for Base44 API", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/modules/integrations.ts b/src/modules/integrations.ts index cc72661..959586a 100644 --- a/src/modules/integrations.ts +++ b/src/modules/integrations.ts @@ -1,6 +1,10 @@ import { AxiosInstance } from "axios"; import { IntegrationsModule } from "./integrations.types.js"; import { createCustomIntegrationsModule } from "./custom-integrations.js"; +import { + APP_SESSION_HEADER, + createAppSessionProvider, +} from "../utils/app-session.js"; /** * Creates the integrations module for the Base44 SDK. @@ -17,6 +21,12 @@ export function createIntegrationsModule( // Create the custom integrations module once const customModule = createCustomIntegrationsModule(axios, appId); + // BUG-438: anonymous Core integration calls on public apps carry a + // short-lived, app-bound session token so the backend can tell a call from + // the served app apart from an arbitrary request. Best-effort — see + // createAppSessionProvider. + const sessionProvider = createAppSessionProvider(axios, appId); + return new Proxy( {}, { @@ -89,10 +99,17 @@ export function createIntegrationsModule( // For Core package if (packageName === "Core") { + const sessionToken = await sessionProvider.getToken(); + const headers: Record = { + "Content-Type": contentType, + }; + if (sessionToken) { + headers[APP_SESSION_HEADER] = sessionToken; + } return axios.post( `/apps/${appId}/integration-endpoints/Core/${endpointName}`, formData || data, - { headers: { "Content-Type": contentType } } + { headers } ); } diff --git a/src/utils/app-session.ts b/src/utils/app-session.ts new file mode 100644 index 0000000..80b562b --- /dev/null +++ b/src/utils/app-session.ts @@ -0,0 +1,111 @@ +import { AxiosInstance } from "axios"; +import { getTurnstileToken } from "./turnstile.js"; + +/** + * App-session token provider (BUG-438). + * + * Public apps (`public_without_login`) expose Core integration endpoints to + * anonymous callers by design — the app's own browser frontend invokes them + * with no logged-in user. To distinguish "request from the served app" from + * "arbitrary request against the public endpoint", the backend mints a + * short-lived, app-bound session token and the SDK replays it on Core calls via + * the `X-Base44-App-Session` header. + * + * Minting is a two-step challenge-response so a plain script can't just fetch a + * token: + * 1. GET /apps/{appId}/integration-session → whether a Turnstile challenge + * is required and, if so, the public site key to render it with. + * 2. POST /apps/{appId}/integration-session → the session token, after the + * Turnstile response token (when required) is attached. + * + * Best-effort throughout: if minting (or the challenge) fails the provider + * returns `null` and the integration call proceeds without the header. The + * backend runs observe-only until per-app enforcement is enabled, so missing + * tokens never break legitimate traffic during rollout. + */ +export const APP_SESSION_HEADER = "X-Base44-App-Session"; +export const TURNSTILE_RESPONSE_HEADER = "Cf-Turnstile-Response"; + +// Refresh slightly before expiry so an in-flight call never races the TTL. +const EXPIRY_MARGIN_SECONDS = 60; + +interface ChallengeResponse { + turnstile_required?: boolean; + turnstile_site_key?: string | null; +} + +interface SessionResponse { + session_token?: string; + expires_in?: number; +} + +export interface AppSessionProvider { + /** Returns a valid token, refreshing if needed. Never throws; null on failure. */ + getToken(): Promise; +} + +export function createAppSessionProvider( + axios: AxiosInstance, + appId: string +): AppSessionProvider { + let token: string | null = null; + let expiresAtMs = 0; + let inFlight: Promise | null = null; + + const nowMs = () => Date.now(); + const isFresh = () => token !== null && nowMs() < expiresAtMs; + const sessionPath = `/apps/${appId}/integration-session`; + + async function fetchToken(): Promise { + try { + // The integrations axios client unwraps responses to the body directly. + const challenge = (await axios.get( + sessionPath + )) as unknown as ChallengeResponse; + + const headers: Record = {}; + if (challenge?.turnstile_required && challenge.turnstile_site_key) { + const turnstileToken = await getTurnstileToken( + challenge.turnstile_site_key + ); + // If the challenge couldn't be solved, POST anyway: the backend will + // reject when enforcing (observe-only otherwise), and we fail soft. + if (turnstileToken) { + headers[TURNSTILE_RESPONSE_HEADER] = turnstileToken; + } + } + + const res = (await axios.post( + sessionPath, + {}, + { headers } + )) as unknown as SessionResponse; + if (res && typeof res.session_token === "string") { + token = res.session_token; + const ttl = typeof res.expires_in === "number" ? res.expires_in : 0; + expiresAtMs = nowMs() + Math.max(0, ttl - EXPIRY_MARGIN_SECONDS) * 1000; + return token; + } + } catch { + /* best-effort: fall through and return null */ + } + token = null; + expiresAtMs = 0; + return null; + } + + return { + async getToken() { + if (isFresh()) { + return token; + } + // De-dupe concurrent refreshes (e.g. a burst of integration calls on load). + if (!inFlight) { + inFlight = fetchToken().finally(() => { + inFlight = null; + }); + } + return inFlight; + }, + }; +} diff --git a/src/utils/turnstile.ts b/src/utils/turnstile.ts new file mode 100644 index 0000000..692f89f --- /dev/null +++ b/src/utils/turnstile.ts @@ -0,0 +1,132 @@ +/** + * Cloudflare Turnstile helper (BUG-438). + * + * Obtains a one-shot Turnstile response token in the browser so the backend can + * gate anonymous integration-session minting behind a human/browser challenge. + * Best-effort: returns `null` outside a browser, if the script can't load, or on + * any challenge failure/timeout — the caller then proceeds without a token (the + * backend runs observe-only until per-app enforcement, so this never hard-breaks + * legitimate traffic during rollout). + */ + +const TURNSTILE_SCRIPT_URL = + "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; +const DEFAULT_TIMEOUT_MS = 15000; + +interface TurnstileApi { + render(container: HTMLElement, opts: Record): string; + remove(widgetId: string): void; + execute?(widgetId: string): void; +} + +function getTurnstile(): TurnstileApi | undefined { + return (globalThis as any).turnstile as TurnstileApi | undefined; +} + +let scriptPromise: Promise | null = null; + +function loadScript(): Promise { + if (getTurnstile()) { + return Promise.resolve(); + } + if (scriptPromise) { + return scriptPromise; + } + scriptPromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = TURNSTILE_SCRIPT_URL; + script.async = true; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => { + scriptPromise = null; // allow a later retry + reject(new Error("Failed to load Cloudflare Turnstile script")); + }; + document.head.appendChild(script); + }); + return scriptPromise; +} + +/** + * Render an invisible Turnstile widget and resolve with its response token, or + * `null` if a token can't be obtained. + */ +export async function getTurnstileToken( + siteKey: string, + timeoutMs: number = DEFAULT_TIMEOUT_MS +): Promise { + if (typeof window === "undefined" || typeof document === "undefined") { + return null; + } + try { + await loadScript(); + const turnstile = getTurnstile(); + if (!turnstile) { + return null; + } + + const container = document.createElement("div"); + container.style.display = "none"; + document.body.appendChild(container); + + return await new Promise((resolve) => { + let settled = false; + let widgetId: string | undefined; + let removed = false; + + // Remove the widget once we know its id. The success callback can fire + // synchronously *inside* render() (before widgetId is assigned), so this + // is also called right after render() to clean up that case. + const removeWidget = () => { + if (removed || !widgetId) return; + removed = true; + try { + turnstile.remove(widgetId); + } catch { + /* ignore cleanup errors */ + } + }; + + const finish = (value: string | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + removeWidget(); + try { + container.remove(); + } catch { + /* ignore cleanup errors */ + } + resolve(value); + }; + + const timer = setTimeout(() => finish(null), timeoutMs); + + try { + widgetId = turnstile.render(container, { + sitekey: siteKey, + size: "invisible", + callback: (token: string) => finish(token), + "error-callback": () => finish(null), + "timeout-callback": () => finish(null), + }); + if (settled) { + // Callback already fired synchronously; widgetId is set now. + removeWidget(); + } else { + // Invisible widgets auto-run on render; execute() is a no-op fallback + // for builds that require an explicit trigger. + try { + turnstile.execute?.(widgetId); + } catch { + /* some versions auto-execute; ignore */ + } + } + } catch { + finish(null); + } + }); + } catch { + return null; + } +} diff --git a/tests/unit/app-session.test.ts b/tests/unit/app-session.test.ts new file mode 100644 index 0000000..672e052 --- /dev/null +++ b/tests/unit/app-session.test.ts @@ -0,0 +1,96 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import nock from 'nock'; +import { createClient } from '../../src/index.ts'; + +const SESSION_HEADER = 'x-base44-app-session'; +const TURNSTILE_HEADER = 'cf-turnstile-response'; + +// Control the Turnstile util so these tests don't need a real browser/DOM. +const getTurnstileToken = vi.fn(); +vi.mock('../../src/utils/turnstile.ts', () => ({ + getTurnstileToken: (...args: unknown[]) => getTurnstileToken(...args), +})); + +describe('BUG-438 app-session token on Core integration calls', () => { + let base44: ReturnType; + let scope: nock.Scope; + const appId = 'test-app-id'; + const serverUrl = 'https://base44.app'; + const sessionPath = `/api/apps/${appId}/integration-session`; + + beforeEach(() => { + base44 = createClient({ serverUrl, appId }); + scope = nock(serverUrl); + getTurnstileToken.mockReset(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + test('mints without a challenge and attaches the session header', async () => { + scope.get(sessionPath).reply(200, { turnstile_required: false, turnstile_site_key: null }); + let sentSession: string | undefined; + scope.post(sessionPath).reply(function () { + sentSession = this.req.headers[SESSION_HEADER] as unknown as string; + return [200, { session_token: 'tok-123', expires_in: 1800 }]; + }); + scope.post(`/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`).reply(function () { + return [200, this.req.headers[SESSION_HEADER] === 'tok-123' ? 'ok' : 'no-header']; + }); + + const result = await base44.integrations.Core.InvokeLLM({ prompt: 'hi' }); + expect(result).toBe('ok'); + expect(sentSession).toBeUndefined(); // not sent to the mint endpoint itself + expect(getTurnstileToken).not.toHaveBeenCalled(); + expect(scope.isDone()).toBe(true); + }); + + test('solves the Turnstile challenge and forwards the response token to mint', async () => { + getTurnstileToken.mockResolvedValue('turnstile-tok'); + scope.get(sessionPath).reply(200, { turnstile_required: true, turnstile_site_key: '0xSITE' }); + let sentTurnstile: string | undefined; + scope.post(sessionPath).reply(function () { + sentTurnstile = this.req.headers[TURNSTILE_HEADER] as unknown as string; + return [200, { session_token: 'tok-abc', expires_in: 1800 }]; + }); + scope.post(`/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`).reply(function () { + return [200, this.req.headers[SESSION_HEADER] === 'tok-abc' ? 'ok' : 'no-header']; + }); + + const result = await base44.integrations.Core.InvokeLLM({ prompt: 'hi' }); + expect(result).toBe('ok'); + expect(getTurnstileToken).toHaveBeenCalledWith('0xSITE'); + expect(sentTurnstile).toBe('turnstile-tok'); + expect(scope.isDone()).toBe(true); + }); + + test('mints once and reuses the token across calls', async () => { + scope.get(sessionPath).once().reply(200, { turnstile_required: false }); + scope.post(sessionPath).once().reply(200, { session_token: 'tok-abc', expires_in: 1800 }); + const seen: (string | undefined)[] = []; + scope.post(`/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`).twice().reply(function () { + seen.push(this.req.headers[SESSION_HEADER] as unknown as string); + return [200, 'ok']; + }); + + await base44.integrations.Core.InvokeLLM({ prompt: 'one' }); + await base44.integrations.Core.InvokeLLM({ prompt: 'two' }); + expect(seen).toEqual(['tok-abc', 'tok-abc']); + expect(scope.isDone()).toBe(true); + }); + + test('proceeds without the header when minting fails (best-effort)', async () => { + scope.get(sessionPath).reply(500, { message: 'boom' }); + let sentHeader: string | undefined = 'unset'; + scope.post(`/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`).reply(function () { + sentHeader = this.req.headers[SESSION_HEADER] as unknown as string; + return [200, 'ok']; + }); + + const result = await base44.integrations.Core.InvokeLLM({ prompt: 'hi' }); + expect(result).toBe('ok'); + expect(sentHeader).toBeUndefined(); + expect(scope.isDone()).toBe(true); + }); +}); diff --git a/tests/unit/turnstile.test.ts b/tests/unit/turnstile.test.ts new file mode 100644 index 0000000..9a9f41a --- /dev/null +++ b/tests/unit/turnstile.test.ts @@ -0,0 +1,62 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import { getTurnstileToken } from '../../src/utils/turnstile.ts'; + +// The SDK test env is `node` (no DOM). Stub the minimal browser surface the +// helper touches so we can exercise the render/callback flow. +function installFakeDom(turnstile: any) { + const container: any = { style: {}, remove: vi.fn() }; + const doc: any = { + createElement: vi.fn(() => container), + head: { appendChild: vi.fn() }, + body: { appendChild: vi.fn() }, + }; + (globalThis as any).window = {}; + (globalThis as any).document = doc; + (globalThis as any).turnstile = turnstile; + return { container }; +} + +describe('getTurnstileToken', () => { + afterEach(() => { + delete (globalThis as any).window; + delete (globalThis as any).document; + delete (globalThis as any).turnstile; + vi.restoreAllMocks(); + }); + + test('returns null outside a browser', async () => { + expect((globalThis as any).window).toBeUndefined(); + await expect(getTurnstileToken('0xSITE')).resolves.toBeNull(); + }); + + test('resolves with the token from the widget callback', async () => { + const turnstile = { + render: vi.fn((_el: unknown, opts: any) => { + // Simulate Cloudflare invoking the success callback. + opts.callback('the-token'); + return 'widget-1'; + }), + remove: vi.fn(), + }; + installFakeDom(turnstile); + + const token = await getTurnstileToken('0xSITE'); + expect(token).toBe('the-token'); + expect(turnstile.render).toHaveBeenCalledTimes(1); + expect(turnstile.render.mock.calls[0][1].sitekey).toBe('0xSITE'); + expect(turnstile.remove).toHaveBeenCalledWith('widget-1'); + }); + + test('resolves null when the widget reports an error', async () => { + const turnstile = { + render: vi.fn((_el: unknown, opts: any) => { + opts['error-callback'](); + return 'widget-2'; + }), + remove: vi.fn(), + }; + installFakeDom(turnstile); + + await expect(getTurnstileToken('0xSITE')).resolves.toBeNull(); + }); +});