From 9faf42661468f1eaade9c08b7bfb538df7881630 Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Thu, 11 Jun 2026 13:31:49 +0300 Subject: [PATCH 1/2] feat: attach short-lived app-session token to Core integration calls BUG-438: public apps expose Core integration endpoints to anonymous callers by design, with nothing distinguishing a call from the served app frontend from an arbitrary request against the public endpoint. The backend now mints a short-lived, app-bound session token; the SDK fetches it lazily on the first Core integration call and replays it via the X-Base44-App-Session header. Best-effort: if minting fails the call proceeds without the header, so this is non-breaking. The backend enforces the token only once a per-app flag is enabled (observe-only until then). Co-Authored-By: Claude Fable 5 --- package.json | 2 +- src/modules/integrations.ts | 19 +++++++- src/utils/app-session.ts | 80 ++++++++++++++++++++++++++++++++++ tests/unit/app-session.test.ts | 77 ++++++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 src/utils/app-session.ts create mode 100644 tests/unit/app-session.test.ts diff --git a/package.json b/package.json index a884a08..54419d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@base44/sdk", - "version": "0.8.32", + "version": "0.8.33", "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..7d6162d --- /dev/null +++ b/src/utils/app-session.ts @@ -0,0 +1,80 @@ +import { AxiosInstance } from "axios"; + +/** + * 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 at app boot. The SDK fetches it lazily + * and replays it on Core integration calls via the `X-Base44-App-Session` + * header. + * + * This is intentionally best-effort: if minting fails the provider returns + * `null` and the integration call proceeds without the header. The backend + * runs in observe-only mode until per-app enforcement is enabled, so missing + * tokens never break legitimate traffic during rollout. + */ +export const APP_SESSION_HEADER = "X-Base44-App-Session"; + +// Refresh slightly before expiry so an in-flight call never races the TTL. +const EXPIRY_MARGIN_SECONDS = 60; + +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; + + async function fetchToken(): Promise { + try { + // The integrations axios client unwraps responses to the body directly. + const res = (await axios.get( + `/apps/${appId}/integration-session` + )) 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/tests/unit/app-session.test.ts b/tests/unit/app-session.test.ts new file mode 100644 index 0000000..1aed77d --- /dev/null +++ b/tests/unit/app-session.test.ts @@ -0,0 +1,77 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import nock from 'nock'; +import { createClient } from '../../src/index.ts'; + +const SESSION_HEADER = 'x-base44-app-session'; + +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'; + + beforeEach(() => { + base44 = createClient({ serverUrl, appId }); + scope = nock(serverUrl); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + test('attaches the session header when a token is minted', async () => { + let sentHeader: string | undefined; + scope + .get(`/api/apps/${appId}/integration-session`) + .reply(200, { session_token: 'tok-123', expires_in: 1800 }); + 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).toBe('tok-123'); + expect(scope.isDone()).toBe(true); + }); + + test('mints the token once and reuses it across calls', async () => { + scope + .get(`/api/apps/${appId}/integration-session`) + .once() // a second mint would leave this unsatisfied → isDone() stays true only if reused + .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 () => { + let sentHeader: string | undefined = 'unset'; + scope + .get(`/api/apps/${appId}/integration-session`) + .reply(500, { message: 'boom' }); + 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); + }); +}); From f2a023d7d3fe2b2e78ab96d4a0dbdb5a87dd9156 Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Thu, 11 Jun 2026 15:09:17 +0300 Subject: [PATCH 2/2] feat: gate integration-session minting behind Cloudflare Turnstile BUG-438: the session token alone only turns a one-line curl into a two-line curl (fetch token, then call the integration). Minting is now a two-step challenge-response: GET advertises whether a Turnstile challenge is required (and the public site key); the SDK renders an invisible Turnstile widget and forwards the response token via Cf-Turnstile-Response on the POST that mints the session token. Best-effort: outside a browser, or if the script/challenge fails, getToken returns null and the integration call proceeds without the header (backend is observe-only until per-app enforcement). Co-Authored-By: Claude Fable 5 --- package.json | 2 +- src/utils/app-session.ts | 51 ++++++++++--- src/utils/turnstile.ts | 132 +++++++++++++++++++++++++++++++++ tests/unit/app-session.test.ts | 87 +++++++++++++--------- tests/unit/turnstile.test.ts | 62 ++++++++++++++++ 5 files changed, 289 insertions(+), 45 deletions(-) create mode 100644 src/utils/turnstile.ts create mode 100644 tests/unit/turnstile.test.ts diff --git a/package.json b/package.json index 54419d5..771aa3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@base44/sdk", - "version": "0.8.33", + "version": "0.8.34", "description": "JavaScript SDK for Base44 API", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/utils/app-session.ts b/src/utils/app-session.ts index 7d6162d..80b562b 100644 --- a/src/utils/app-session.ts +++ b/src/utils/app-session.ts @@ -1,4 +1,5 @@ import { AxiosInstance } from "axios"; +import { getTurnstileToken } from "./turnstile.js"; /** * App-session token provider (BUG-438). @@ -7,20 +8,32 @@ import { AxiosInstance } from "axios"; * 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 at app boot. The SDK fetches it lazily - * and replays it on Core integration calls via the `X-Base44-App-Session` - * header. + * short-lived, app-bound session token and the SDK replays it on Core calls via + * the `X-Base44-App-Session` header. * - * This is intentionally best-effort: if minting fails the provider returns - * `null` and the integration call proceeds without the header. The backend - * runs in observe-only mode until per-app enforcement is enabled, so missing + * 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; @@ -41,17 +54,35 @@ export function createAppSessionProvider( 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 res = (await axios.get( - `/apps/${appId}/integration-session` + 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; + const ttl = typeof res.expires_in === "number" ? res.expires_in : 0; expiresAtMs = nowMs() + Math.max(0, ttl - EXPIRY_MARGIN_SECONDS) * 1000; return token; } 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 index 1aed77d..672e052 100644 --- a/tests/unit/app-session.test.ts +++ b/tests/unit/app-session.test.ts @@ -1,55 +1,78 @@ -import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +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('attaches the session header when a token is minted', async () => { - let sentHeader: string | undefined; - scope - .get(`/api/apps/${appId}/integration-session`) - .reply(200, { session_token: 'tok-123', expires_in: 1800 }); - scope - .post(`/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`) - .reply(function () { - sentHeader = this.req.headers[SESSION_HEADER] as unknown as string; - return [200, 'ok']; - }); + 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(sentHeader).toBe('tok-123'); + expect(getTurnstileToken).toHaveBeenCalledWith('0xSITE'); + expect(sentTurnstile).toBe('turnstile-tok'); expect(scope.isDone()).toBe(true); }); - test('mints the token once and reuses it across calls', async () => { - scope - .get(`/api/apps/${appId}/integration-session`) - .once() // a second mint would leave this unsatisfied → isDone() stays true only if reused - .reply(200, { session_token: 'tok-abc', expires_in: 1800 }); + 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']; - }); + 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' }); @@ -58,16 +81,12 @@ describe('BUG-438 app-session token on Core integration calls', () => { }); test('proceeds without the header when minting fails (best-effort)', async () => { + scope.get(sessionPath).reply(500, { message: 'boom' }); let sentHeader: string | undefined = 'unset'; - scope - .get(`/api/apps/${appId}/integration-session`) - .reply(500, { message: 'boom' }); - scope - .post(`/api/apps/${appId}/integration-endpoints/Core/InvokeLLM`) - .reply(function () { - sentHeader = this.req.headers[SESSION_HEADER] as unknown as string; - return [200, 'ok']; - }); + 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'); 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(); + }); +});