Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 18 additions & 1 deletion src/modules/integrations.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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(
{},
{
Expand Down Expand Up @@ -89,10 +99,17 @@ export function createIntegrationsModule(

// For Core package
if (packageName === "Core") {
const sessionToken = await sessionProvider.getToken();
const headers: Record<string, string> = {
"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 }
);
}

Expand Down
111 changes: 111 additions & 0 deletions src/utils/app-session.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>;
}

export function createAppSessionProvider(
axios: AxiosInstance,
appId: string
): AppSessionProvider {
let token: string | null = null;
let expiresAtMs = 0;
let inFlight: Promise<string | null> | null = null;

const nowMs = () => Date.now();
const isFresh = () => token !== null && nowMs() < expiresAtMs;
const sessionPath = `/apps/${appId}/integration-session`;

async function fetchToken(): Promise<string | null> {
try {
// The integrations axios client unwraps responses to the body directly.
const challenge = (await axios.get(
sessionPath
)) as unknown as ChallengeResponse;

const headers: Record<string, string> = {};
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;
},
};
}
132 changes: 132 additions & 0 deletions src/utils/turnstile.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): string;
remove(widgetId: string): void;
execute?(widgetId: string): void;
}

function getTurnstile(): TurnstileApi | undefined {
return (globalThis as any).turnstile as TurnstileApi | undefined;
}

let scriptPromise: Promise<void> | null = null;

function loadScript(): Promise<void> {
if (getTurnstile()) {
return Promise.resolve();
}
if (scriptPromise) {
return scriptPromise;
}
scriptPromise = new Promise<void>((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<string | null> {
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<string | null>((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;
}
}
96 changes: 96 additions & 0 deletions tests/unit/app-session.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createClient>;
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);
});
});
Loading